forked from cleveragents/cleveragents-core
37586882b3
Move the large embedded `_BEHAVE_PARALLEL_CLI_SOURCE` string constant out of noxfile.py and into a standalone `scripts/run_behave_parallel.py` module. The `_install_behave_parallel()` helper now reads the script from disk via `Path(__file__).parent / 'scripts' / 'run_behave_parallel.py'` instead of embedding the source as a raw string literal. This allows ruff to lint and type-check the runner independently, and makes noxfile.py significantly shorter and easier to read. No functional changes: the installed `behave-parallel` entry point is identical to the previous embedded version. Parallel and sequential modes, coverage integration, and the multiprocessing fork model are all preserved. Fixed two SIM105 lint violations in the extracted script (replaced try/except/pass with contextlib.suppress). ISSUES CLOSED: #1538
797 lines
25 KiB
Python
797 lines
25 KiB
Python
import json
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import nox
|
|
|
|
# Global configuration
|
|
DEFAULT_PYTHON = "3.13"
|
|
SUPPORTED_PYTHONS = ["3.13"]
|
|
nox.options.reuse_existing_virtualenvs = True
|
|
nox.options.error_on_external_run = True
|
|
|
|
BEHAVE_PARALLEL_VERSION = "2.0.0"
|
|
|
|
|
|
def _default_processes() -> int:
|
|
env_override = os.environ.get("TEST_PROCESSES")
|
|
if env_override:
|
|
return int(env_override)
|
|
try:
|
|
cpus = len(os.sched_getaffinity(0)) or 1
|
|
except AttributeError:
|
|
cpus = os.cpu_count() or 1
|
|
# Keep default parallelism conservative to avoid timeout/OOM flakes
|
|
# under heavy Robot/pabot subprocess fan-out in CI and shared runners.
|
|
# Callers can still override with TEST_PROCESSES / --processes.
|
|
return min(cpus, 2)
|
|
|
|
|
|
def _behave_parallel_args(posargs: list[str]) -> list[str]:
|
|
has_custom_processes = any(
|
|
arg in {"--processes", "-j"} or arg.startswith(("--processes=", "-j"))
|
|
for arg in posargs
|
|
)
|
|
if has_custom_processes:
|
|
return []
|
|
return ["--processes", str(_default_processes())]
|
|
|
|
|
|
def _pabot_parallel_args(posargs: list[str]) -> list[str]:
|
|
has_custom_processes = any(
|
|
arg in {"--processes"} or arg.startswith("--processes=") for arg in posargs
|
|
)
|
|
if has_custom_processes:
|
|
return []
|
|
return ["--processes", str(_default_processes())]
|
|
|
|
|
|
def _split_pabot_args(posargs: list[str]) -> tuple[list[str], list[str]]:
|
|
pabot_args: list[str] = []
|
|
robot_args: list[str] = []
|
|
i = 0
|
|
while i < len(posargs):
|
|
arg = posargs[i]
|
|
if arg == "--processes" and i + 1 < len(posargs):
|
|
pabot_args.extend([arg, posargs[i + 1]])
|
|
i += 2
|
|
continue
|
|
if arg.startswith("--processes="):
|
|
pabot_args.append(arg)
|
|
i += 1
|
|
continue
|
|
robot_args.append(arg)
|
|
i += 1
|
|
return pabot_args, robot_args
|
|
|
|
|
|
def _create_template_db(session: nox.Session) -> str:
|
|
"""Build the pre-migrated SQLite template and return its path.
|
|
|
|
Runs ``scripts/create_template_db.py`` to produce a SQLite file with all
|
|
tables already created via ``Base.metadata.create_all()`` and the
|
|
alembic_version table stamped at HEAD. Each test scenario can then
|
|
``shutil.copy`` this file instead of running 25 Alembic migrations.
|
|
"""
|
|
template_path = str(Path("build/.template-migrated.db").resolve())
|
|
session.run(
|
|
"python",
|
|
"scripts/create_template_db.py",
|
|
template_path,
|
|
silent=True,
|
|
)
|
|
return template_path
|
|
|
|
|
|
def _install_behave_parallel(session: nox.Session) -> None:
|
|
"""Install behave-parallel with an in-process parallel runner.
|
|
|
|
Instead of spawning one subprocess per feature file (the old model),
|
|
the new CLI runs features in-process via behave's Python API. Step
|
|
definitions and environment hooks are loaded once; each feature file
|
|
is then parsed and executed without interpreter startup overhead.
|
|
|
|
Parallel execution uses ``multiprocessing.Pool`` with the ``fork``
|
|
start method so that already-imported modules are shared (copy-on-write)
|
|
across workers.
|
|
|
|
The runner script is read from ``scripts/run_behave_parallel.py`` so
|
|
that the noxfile stays concise and the script can be linted and typed
|
|
independently.
|
|
"""
|
|
tmp_dir = Path(session.create_tmp())
|
|
source_dir = tmp_dir / "behave-parallel-inprocess"
|
|
source_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
pkg_dir = source_dir / "behave_parallel"
|
|
pkg_dir.mkdir(parents=True, exist_ok=True)
|
|
(pkg_dir / "__init__.py").write_text("\n")
|
|
|
|
runner_script = Path(__file__).parent / "scripts" / "run_behave_parallel.py"
|
|
(pkg_dir / "cli.py").write_text(runner_script.read_text(encoding="utf-8"))
|
|
|
|
setup_path = source_dir / "setup.py"
|
|
setup_path.write_text(
|
|
"from setuptools import find_packages, setup\n"
|
|
"setup(\n"
|
|
' name="behave-parallel",\n'
|
|
f' version="{BEHAVE_PARALLEL_VERSION}",\n'
|
|
" packages=find_packages(),\n"
|
|
' install_requires=["behave>=1.2.6"],\n'
|
|
" entry_points={\n"
|
|
' "console_scripts": '
|
|
'["behave-parallel=behave_parallel.cli:main"],\n'
|
|
" },\n"
|
|
")\n"
|
|
)
|
|
|
|
session.install("setuptools", "wheel")
|
|
session.install(str(source_dir))
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# QUICK DEVELOPMENT SESSIONS (Fast - Run daily)
|
|
# =============================================================================
|
|
|
|
|
|
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
|
|
def lint(session: nox.Session):
|
|
"""Check code formatting and linting."""
|
|
session.install("ruff>=0.15,<0.16")
|
|
session.run("ruff", "check", "src/", "scripts/", "examples/", "features/", "robot/")
|
|
|
|
|
|
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
|
|
def format(session: nox.Session):
|
|
"""Format code with ruff. Pass --check to verify without modifying."""
|
|
session.install("ruff>=0.15,<0.16")
|
|
session.run("ruff", "format", *session.posargs, ".")
|
|
|
|
|
|
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
|
|
def typecheck(session: nox.Session):
|
|
"""Check types with pyright."""
|
|
session.install("pyright")
|
|
session.install("-e", ".")
|
|
session.run("pyright", stderr=sys.stdout)
|
|
|
|
|
|
@nox.session(python=SUPPORTED_PYTHONS, reuse_venv=True, venv_backend="uv")
|
|
def unit_tests(session: nox.Session):
|
|
"""Run BDD tests with Behave."""
|
|
session.install("-e", ".[tests]")
|
|
_install_behave_parallel(session)
|
|
|
|
# Build a pre-migrated template DB so each scenario can copy it
|
|
# instead of running 25 Alembic migrations from scratch.
|
|
template_path = _create_template_db(session)
|
|
session.env["CLEVERAGENTS_TEMPLATE_DB"] = template_path
|
|
|
|
behave_cmd = session.bin + "/behave-parallel"
|
|
parallel_args = _behave_parallel_args(session.posargs)
|
|
|
|
# If a specific feature file is passed, run only that file
|
|
if session.posargs and session.posargs[0].endswith(".feature"):
|
|
args = [
|
|
behave_cmd,
|
|
"-q",
|
|
*parallel_args,
|
|
*session.posargs,
|
|
]
|
|
else:
|
|
args = [
|
|
behave_cmd,
|
|
"-q",
|
|
*parallel_args,
|
|
"features/",
|
|
*session.posargs,
|
|
]
|
|
|
|
session.env["PYTHONPATH"] = str(Path("src").resolve())
|
|
# Prevent Rich from injecting ANSI escape codes into machine-readable
|
|
# CLI output (JSON/YAML) which causes json.loads() failures on CI.
|
|
session.env["NO_COLOR"] = "1"
|
|
session.run(*args)
|
|
|
|
|
|
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
|
|
def docs(session: nox.Session):
|
|
"""Build documentation with MkDocs."""
|
|
session.install("-e", ".[docs]")
|
|
# Suppress DeprecationWarning from the third-party `rx` package which calls
|
|
# datetime.datetime.utcfromtimestamp() (deprecated since Python 3.12).
|
|
session.env["PYTHONWARNINGS"] = "ignore::DeprecationWarning:rx"
|
|
session.run("mkdocs", "build")
|
|
|
|
|
|
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
|
|
def serve_docs(session: nox.Session):
|
|
"""Serve docs locally for development."""
|
|
session.install("-e", ".[docs]")
|
|
# Suppress DeprecationWarning from the third-party `rx` package which calls
|
|
# datetime.datetime.utcfromtimestamp() (deprecated since Python 3.12).
|
|
session.env["PYTHONWARNINGS"] = "ignore::DeprecationWarning:rx"
|
|
session.run("mkdocs", "serve", "--dev-addr", "0.0.0.0:8000")
|
|
|
|
|
|
@nox.session(python=SUPPORTED_PYTHONS, reuse_venv=True, venv_backend="uv")
|
|
def build(session: nox.Session):
|
|
"""Build the wheel distribution."""
|
|
session.install("build", "pip")
|
|
session.run("python", "-m", "build", "--wheel")
|
|
|
|
|
|
@nox.session(python=SUPPORTED_PYTHONS, reuse_venv=True, venv_backend="uv")
|
|
def integration_tests(session: nox.Session):
|
|
"""Run Robot Framework integration tests (parallel via pabot).
|
|
|
|
Defaults to conservative parallelism (<=2 processes) to avoid
|
|
resource pressure in CI. Override via PABOT_PROCESSES or by passing
|
|
--processes/--processes=N in session arguments.
|
|
"""
|
|
session.install("-e", ".[tests]")
|
|
session.env["CLEVERAGENTS_AUTO_APPLY_MIGRATIONS"] = "true"
|
|
session.env["NO_COLOR"] = "1"
|
|
# Override PYTHONPATH so the editable install (src/) for this project
|
|
# is used instead of any inherited PYTHONPATH from the outer environment.
|
|
session.env["PYTHONPATH"] = "src"
|
|
|
|
# Propagate venv bin to PATH so Run Process in robot files finds
|
|
# the venv's python/robot rather than the system copies.
|
|
venv_bin = os.path.join(session.virtualenv.location, "bin")
|
|
session.env["PATH"] = venv_bin + os.pathsep + os.environ.get("PATH", "")
|
|
|
|
# Ensure output directory exists (CI starts with a clean checkout)
|
|
os.makedirs("build/reports/robot", exist_ok=True)
|
|
|
|
# Pass the venv Python path explicitly so Run Process calls use it
|
|
# instead of relying on PATH (which may not propagate to subprocesses).
|
|
venv_python = os.path.join(venv_bin, "python")
|
|
|
|
# Pre-compile bytecode so that parallel pabot workers (and the
|
|
# Python sub-processes they spawn via ``Run Python Script``) can
|
|
# read cached .pyc files instead of each cold-compiling every
|
|
# module from source simultaneously — avoids a thundering-herd
|
|
# race on CI runners with high core counts.
|
|
session.run("python", "-m", "compileall", "-q", "src/")
|
|
|
|
# Build a pre-migrated template DB so helper scripts that call
|
|
# setup_workspace() can copy it instead of running 25+ Alembic
|
|
# migrations per test — critical for parallel pabot execution.
|
|
template_path = _create_template_db(session)
|
|
session.env["CLEVERAGENTS_TEMPLATE_DB"] = template_path
|
|
|
|
pabot_args, robot_args = _split_pabot_args(session.posargs)
|
|
parallel_args = _pabot_parallel_args(pabot_args)
|
|
|
|
# TDD expected-fail listener — inverts results for @tdd_expected_fail
|
|
# tagged tests and validates TDD tag combinations.
|
|
# See CONTRIBUTING.md > TDD Issue Test Tags.
|
|
# Resolved relative to this file (not CWD) so ``nox`` invocations from
|
|
# a non-root directory still find the listener module.
|
|
tdd_listener = str(
|
|
Path(__file__).parent / "robot" / "tdd_expected_fail_listener.py"
|
|
)
|
|
|
|
session.run(
|
|
"pabot",
|
|
*parallel_args,
|
|
*pabot_args,
|
|
"--outputdir",
|
|
"build/reports/robot",
|
|
"--loglevel",
|
|
"INFO",
|
|
"--report",
|
|
"report.html",
|
|
"--log",
|
|
"log.html",
|
|
"--xunit",
|
|
"xunit.xml",
|
|
"--variable",
|
|
f"PYTHON:{venv_python}",
|
|
"--listener",
|
|
tdd_listener,
|
|
"--exclude",
|
|
"slow",
|
|
"--exclude",
|
|
"discovery",
|
|
"--exclude",
|
|
"code_blocks",
|
|
"--exclude",
|
|
"wip",
|
|
"--exclude",
|
|
"E2E",
|
|
"--exclude",
|
|
"tdd_fixture",
|
|
*robot_args,
|
|
"robot/",
|
|
)
|
|
|
|
|
|
@nox.session(python=SUPPORTED_PYTHONS, reuse_venv=True, venv_backend="uv")
|
|
def slow_integration_tests(session: nox.Session):
|
|
"""Run Robot Framework integration tests."""
|
|
session.install("-e", ".[tests]")
|
|
session.env["CLEVERAGENTS_AUTO_APPLY_MIGRATIONS"] = "true"
|
|
|
|
# TDD expected-fail listener — inverts results for @tdd_expected_fail
|
|
# tagged tests and validates TDD tag combinations.
|
|
# See CONTRIBUTING.md > TDD Issue Test Tags.
|
|
# Resolved relative to this file (not CWD) so ``nox`` invocations from
|
|
# a non-root directory still find the listener module.
|
|
tdd_listener = str(
|
|
Path(__file__).parent / "robot" / "tdd_expected_fail_listener.py"
|
|
)
|
|
|
|
session.run(
|
|
"robot",
|
|
"--outputdir",
|
|
"build/reports/robot",
|
|
"--loglevel",
|
|
"INFO",
|
|
"--report",
|
|
"report.html",
|
|
"--log",
|
|
"log.html",
|
|
"--xunit",
|
|
"xunit.xml",
|
|
"--listener",
|
|
tdd_listener,
|
|
"--exclude",
|
|
"tdd_fixture",
|
|
"robot/",
|
|
*session.posargs,
|
|
)
|
|
|
|
|
|
@nox.session(python=SUPPORTED_PYTHONS, reuse_venv=True, venv_backend="uv")
|
|
def e2e_tests(session: nox.Session):
|
|
"""Run end-to-end Robot Framework tests with real LLM API keys.
|
|
|
|
E2E tests use zero mocking — they exercise the real CleverAgents CLI
|
|
against real LLM API keys (Anthropic/OpenAI). Tests are tagged with
|
|
``E2E`` and live in the ``robot/e2e/`` directory.
|
|
|
|
This session is NOT included in the default ``nox`` run because it
|
|
requires real API keys. Run explicitly via ``nox -s e2e_tests``.
|
|
|
|
Tests that require LLM API keys will skip gracefully when the keys
|
|
are not present in the environment.
|
|
"""
|
|
session.install("-e", ".[tests]")
|
|
session.env["CLEVERAGENTS_AUTO_APPLY_MIGRATIONS"] = "true"
|
|
session.env["NO_COLOR"] = "1"
|
|
session.env["PYTHONPATH"] = "src"
|
|
|
|
# Propagate venv bin to PATH so Run Process in robot files finds
|
|
# the venv's python/robot rather than the system copies.
|
|
venv_bin = os.path.join(session.virtualenv.location, "bin")
|
|
session.env["PATH"] = venv_bin + os.pathsep + os.environ.get("PATH", "")
|
|
venv_python = os.path.join(venv_bin, "python")
|
|
|
|
# Ensure output directory exists
|
|
os.makedirs("build/reports/robot-e2e", exist_ok=True)
|
|
|
|
# Pre-compile bytecode to avoid cold-compilation overhead.
|
|
session.run("python", "-m", "compileall", "-q", "src/")
|
|
|
|
# Propagate LLM API keys from the environment into the session
|
|
# so that real E2E tests can authenticate with providers.
|
|
for key in (
|
|
"ANTHROPIC_API_KEY",
|
|
"OPENAI_API_KEY",
|
|
"GOOGLE_API_KEY",
|
|
"GEMINI_API_KEY",
|
|
):
|
|
value = os.environ.get(key)
|
|
if value:
|
|
session.env[key] = value
|
|
|
|
session.run(
|
|
"robot",
|
|
"--outputdir",
|
|
"build/reports/robot-e2e",
|
|
"--loglevel",
|
|
"INFO",
|
|
"--report",
|
|
"report.html",
|
|
"--log",
|
|
"log.html",
|
|
"--xunit",
|
|
"xunit.xml",
|
|
"--variable",
|
|
f"PYTHON:{venv_python}",
|
|
"--include",
|
|
"E2E",
|
|
"--listener",
|
|
str(Path(__file__).parent / "robot" / "tdd_expected_fail_listener.py"),
|
|
*session.posargs,
|
|
"robot/e2e/",
|
|
)
|
|
|
|
|
|
COVERAGE_THRESHOLD = 97
|
|
|
|
|
|
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
|
|
def coverage_report(session: nox.Session):
|
|
"""Generate coverage report from Behave tests.
|
|
|
|
Runs all behave features in a single process under slipcover.
|
|
The in-process behave-parallel runner avoids subprocess overhead,
|
|
so a single slipcover invocation collects coverage for the entire
|
|
suite -- no per-worker files or merge step required.
|
|
|
|
Coverage threshold is enforced at >=97%.
|
|
|
|
On success, emits: COVERAGE OK: <pct>% (threshold: 97%)
|
|
On failure, emits: COVERAGE FAILED: <pct>% < 97% threshold
|
|
Both are single-line, CI-parseable summary strings.
|
|
"""
|
|
session.install("-e", ".[tests]")
|
|
_install_behave_parallel(session)
|
|
|
|
os.makedirs("build", exist_ok=True)
|
|
|
|
# Build a pre-migrated template DB so each scenario can copy it
|
|
# instead of running 25 Alembic migrations from scratch.
|
|
template_path = _create_template_db(session)
|
|
session.env["CLEVERAGENTS_TEMPLATE_DB"] = template_path
|
|
|
|
session.env["PYTHONPATH"] = str(Path("src").resolve())
|
|
session.env["NO_COLOR"] = "1"
|
|
source_paths = "src"
|
|
omit_patterns = ",".join(
|
|
[
|
|
"*/tests/*",
|
|
"*/test_*",
|
|
"features/*",
|
|
"*/features/*",
|
|
"*/__pycache__/*",
|
|
"*/site-packages/*",
|
|
"*/dependency_injector/*",
|
|
"*/venv/*",
|
|
"*/.venv/*",
|
|
"*/.nox/*",
|
|
"src/cleveragents/discovery/*",
|
|
]
|
|
)
|
|
|
|
# Force sequential mode so slipcover wraps the entire process.
|
|
session.env["BEHAVE_PARALLEL_COVERAGE"] = "1"
|
|
|
|
# Clean up any existing slipcover data
|
|
for path in Path("build").glob(".slipcover.*.json"):
|
|
path.unlink()
|
|
|
|
# Build behave-parallel args (sequential for coverage).
|
|
behave_cmd = session.bin + "/behave-parallel"
|
|
if session.posargs and session.posargs[0].endswith(".feature"):
|
|
behave_args = [
|
|
behave_cmd,
|
|
"-q",
|
|
"--tags=-discovery",
|
|
"--no-capture",
|
|
*session.posargs,
|
|
]
|
|
else:
|
|
behave_args = [
|
|
behave_cmd,
|
|
"-q",
|
|
"--tags=-discovery",
|
|
"--no-capture",
|
|
"features/",
|
|
*session.posargs,
|
|
]
|
|
|
|
# Wrap the entire behave-parallel run under slipcover.
|
|
# A single process produces a single JSON output file directly.
|
|
# Allow exit code 1 (test failures) — coverage data is still produced.
|
|
session.run(
|
|
"python",
|
|
"-m",
|
|
"slipcover",
|
|
"--json",
|
|
"--out",
|
|
"build/coverage.json",
|
|
"--source",
|
|
source_paths,
|
|
"--omit",
|
|
omit_patterns,
|
|
"--",
|
|
*behave_args,
|
|
success_codes=[0, 1],
|
|
)
|
|
|
|
# Generate XML report for CI
|
|
session.run(
|
|
"python",
|
|
"-m",
|
|
"slipcover",
|
|
"--merge",
|
|
"build/coverage.json",
|
|
"--xml",
|
|
"--out",
|
|
"build/coverage.xml",
|
|
)
|
|
|
|
# Generate terminal report and check threshold.
|
|
# slipcover exits with code 2 if below threshold; nox intercepts the exit.
|
|
report_path = "build/coverage-report.txt"
|
|
session.run(
|
|
"python",
|
|
"-m",
|
|
"slipcover",
|
|
"--merge",
|
|
"build/coverage.json",
|
|
"--out",
|
|
report_path,
|
|
"--source",
|
|
source_paths,
|
|
"--omit",
|
|
omit_patterns,
|
|
f"--fail-under={COVERAGE_THRESHOLD}",
|
|
success_codes=[0, 2],
|
|
silent=False,
|
|
)
|
|
|
|
try:
|
|
with open(report_path) as report_file:
|
|
print(report_file.read(), end="")
|
|
except FileNotFoundError:
|
|
session.log(f"Slipcover report not found at {report_path}")
|
|
|
|
# Parse the total percentage from the slipcover JSON report for the summary
|
|
total_pct: float = 0.0
|
|
try:
|
|
with open("build/coverage.json") as f:
|
|
payload = json.load(f)
|
|
|
|
summary = payload.get("summary") or payload.get("totals") or {}
|
|
percent_value = None
|
|
for key in (
|
|
"percent_covered",
|
|
"percent_covered_display",
|
|
"line_coverage_percent",
|
|
):
|
|
percent_value = summary.get(key)
|
|
if percent_value is not None:
|
|
break
|
|
|
|
if percent_value is None:
|
|
covered = (
|
|
summary.get("lines_covered")
|
|
or summary.get("covered_lines")
|
|
or summary.get("covered")
|
|
)
|
|
total = (
|
|
summary.get("lines")
|
|
or summary.get("total_lines")
|
|
or summary.get("num_lines")
|
|
or summary.get("total")
|
|
)
|
|
if covered is not None and total:
|
|
percent_value = (float(covered) / float(total)) * 100.0
|
|
|
|
if isinstance(percent_value, str):
|
|
percent_value = percent_value.strip().rstrip("%")
|
|
|
|
if percent_value is not None:
|
|
total_pct = float(percent_value)
|
|
except (FileNotFoundError, KeyError, TypeError, ValueError, json.JSONDecodeError):
|
|
total_pct = 0.0
|
|
|
|
rounded_pct = round(total_pct, 1)
|
|
|
|
if rounded_pct >= COVERAGE_THRESHOLD:
|
|
session.log(f"COVERAGE OK: {rounded_pct}% (threshold: {COVERAGE_THRESHOLD}%)")
|
|
else:
|
|
session.error(
|
|
f"COVERAGE FAILED: {rounded_pct}% < {COVERAGE_THRESHOLD}% threshold"
|
|
)
|
|
|
|
|
|
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
|
|
def pre_commit(session: nox.Session):
|
|
"""Run all pre-commit hooks on all files."""
|
|
session.install("-e", ".[dev]")
|
|
session.run("pre-commit", "run", "--all-files", *session.posargs)
|
|
|
|
|
|
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
|
|
def security_scan(session: nox.Session):
|
|
"""Run security checks matching Forgejo CI: bandit + semgrep + vulture.
|
|
|
|
Steps:
|
|
1. Bandit medium-severity report (non-blocking JSON export to build/)
|
|
2. Bandit high-severity gate (blocks on findings)
|
|
3. Semgrep custom rules from .semgrep.yml (blocks on ERROR-level findings)
|
|
4. Vulture dead-code detection (blocks on confidence >=80%)
|
|
|
|
Severity gates:
|
|
- Bandit HIGH: hard fail (blocks merge)
|
|
- Bandit MEDIUM: report-only (review, do not block)
|
|
- Semgrep ERROR: hard fail (eval/exec/os.system detection)
|
|
- Semgrep WARNING: report-only (pickle.loads)
|
|
- Vulture >=80% confidence: hard fail
|
|
"""
|
|
session.install("-e", ".[dev]")
|
|
# Python 3.13 no longer bundles pkg_resources. Semgrep transitively imports
|
|
# it via opentelemetry instrumentation, so pin setuptools to a version that
|
|
# still provides pkg_resources.
|
|
session.install("setuptools<81")
|
|
|
|
# Ensure output directory exists (CI starts with a clean checkout)
|
|
os.makedirs("build", exist_ok=True)
|
|
|
|
# Step 1: Bandit - medium-severity report (non-blocking, mirrors CI JSON export)
|
|
session.run(
|
|
"bandit",
|
|
"-c",
|
|
"pyproject.toml",
|
|
"-r",
|
|
"src/cleveragents",
|
|
"--severity-level",
|
|
"medium",
|
|
"--format",
|
|
"json",
|
|
"--output",
|
|
"build/bandit-report.json",
|
|
success_codes=[0, 1],
|
|
)
|
|
|
|
# Step 2: Bandit - high-severity gate (blocks on findings)
|
|
session.run(
|
|
"bandit",
|
|
"-c",
|
|
"pyproject.toml",
|
|
"-r",
|
|
"src/cleveragents",
|
|
"--severity-level",
|
|
"high",
|
|
)
|
|
|
|
# Step 3: Semgrep custom rules (blocks on ERROR-severity matches)
|
|
semgrep_config = Path(".semgrep.yml")
|
|
if semgrep_config.exists():
|
|
session.run(
|
|
"semgrep",
|
|
"--config=.semgrep.yml",
|
|
"--error",
|
|
"--quiet",
|
|
"src/",
|
|
success_codes=[0, 1],
|
|
)
|
|
else:
|
|
session.warn("No .semgrep.yml found, skipping semgrep scan")
|
|
|
|
# Step 4: Vulture dead-code detection
|
|
session.run(
|
|
"vulture",
|
|
"src/cleveragents",
|
|
"vulture_whitelist.py",
|
|
"--min-confidence",
|
|
"80",
|
|
"--exclude",
|
|
"src/cleveragents/discovery",
|
|
)
|
|
|
|
|
|
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
|
|
def dead_code(session: nox.Session):
|
|
"""Run vulture to detect dead code."""
|
|
session.install("-e", ".[dev]")
|
|
session.run(
|
|
"vulture",
|
|
"src/cleveragents",
|
|
"vulture_whitelist.py",
|
|
"--min-confidence",
|
|
"80",
|
|
"--exclude",
|
|
"src/cleveragents/discovery",
|
|
)
|
|
|
|
|
|
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
|
|
def complexity(session: nox.Session):
|
|
"""Check code complexity using radon."""
|
|
session.install("-e", ".[dev]")
|
|
session.run(
|
|
"radon",
|
|
"cc",
|
|
"src/cleveragents",
|
|
"--min",
|
|
"C",
|
|
"--show-complexity",
|
|
"--total-average",
|
|
)
|
|
|
|
|
|
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
|
|
def adr_compliance(session: nox.Session):
|
|
"""Check code compliance with Architecture Decision Records."""
|
|
session.install("-e", ".[dev]")
|
|
session.run("python", "scripts/check-adr-compliance.py")
|
|
|
|
|
|
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
|
|
def benchmark(session: nox.Session):
|
|
"""Run Airspeed Velocity benchmarks and publish results."""
|
|
session.install("-e", ".[tests]")
|
|
config_path = "asv.conf.json"
|
|
session.run(
|
|
"asv",
|
|
"machine",
|
|
"--machine=forgejo-runner",
|
|
"--os=Linux_6.x",
|
|
"--arch=x86_64",
|
|
"--num_cpu=32",
|
|
"--ram=32GB",
|
|
"--cpu=AMD",
|
|
f"--config={config_path}",
|
|
)
|
|
session.run(
|
|
"asv",
|
|
"run",
|
|
"--machine=forgejo-runner",
|
|
"--append-samples",
|
|
"--launch-method=spawn",
|
|
"--show-stderr",
|
|
"--verbose",
|
|
f"--config={config_path}",
|
|
success_codes=[0, 2],
|
|
)
|
|
session.run("asv", "publish", f"--config={config_path}")
|
|
|
|
|
|
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
|
|
def benchmark_regression(session: nox.Session):
|
|
"""Run Airspeed Velocity benchmarks regression test."""
|
|
session.install("-e", ".[tests]")
|
|
config_path = "asv.conf.json"
|
|
asv_base_sha = os.environ.get("ASV_BASE_SHA", "master")
|
|
session.run(
|
|
"asv",
|
|
"machine",
|
|
"--machine=forgejo-runner",
|
|
"--os=Linux_6.x",
|
|
"--arch=x86_64",
|
|
"--num_cpu=32",
|
|
"--ram=32GB",
|
|
"--cpu=AMD",
|
|
f"--config={config_path}",
|
|
)
|
|
session.run(
|
|
"asv",
|
|
"continuous",
|
|
"--machine=forgejo-runner",
|
|
"--append-samples",
|
|
"--show-stderr",
|
|
"--verbose",
|
|
"--factor=1.50",
|
|
f"--config={config_path}",
|
|
asv_base_sha,
|
|
"HEAD",
|
|
success_codes=[0, 2],
|
|
)
|
|
session.run("asv", "publish", f"--config={config_path}")
|
|
|
|
|
|
# Sessions to run by default when running `nox` without arguments
|
|
nox.options.sessions = [
|
|
"lint", # ~5-10 seconds
|
|
"format", # ~5-10 seconds (auto-fixes issues)
|
|
"typecheck", # ~10-20 seconds
|
|
"security_scan", # bandit + vulture (mirrors Forgejo CI security job)
|
|
"dead_code", # vulture dead-code detection
|
|
"unit_tests", # ~30-60 seconds (without ML deps)
|
|
"integration_tests",
|
|
"docs", # ~10-30 seconds
|
|
"build", # ~5-10 seconds
|
|
"benchmark", # ASV benchmarks for performance tracking
|
|
"coverage_report", # ~30-60 seconds (without ML deps)
|
|
]
|