import json import os import sys import tarfile import urllib.request 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 = "1.2.4a1" BEHAVE_PARALLEL_URL = ( "https://files.pythonhosted.org/packages/05/9d/22f74dd77bc4fa85d391564a232c49b4e99cfdeac7bfdee8151ea4606632/" f"behave-parallel-{BEHAVE_PARALLEL_VERSION}.tar.gz" ) 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 return cpus 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 _install_behave_parallel(session: nox.Session) -> None: """Install behave-parallel with a custom CLI wrapper.""" tmp_dir = Path(session.create_tmp()) archive_path = tmp_dir / "behave-parallel.tar.gz" with ( urllib.request.urlopen(BEHAVE_PARALLEL_URL) as response, archive_path.open("wb") as target, ): target.write(response.read()) with tarfile.open(archive_path, "r:gz") as tar: tar.extractall(tmp_dir) source_dir = next(tmp_dir.glob("behave-parallel-*/")) # Recreate package with a parallel-aware CLI that aggregates summaries pkg_dir = source_dir / "behave_parallel" pkg_dir.mkdir(parents=True, exist_ok=True) (pkg_dir / "__init__.py").write_text("\n") (pkg_dir / "cli.py").write_text( """ import argparse import concurrent.futures import os import re import subprocess import sys from pathlib import Path from typing import Dict, Iterable, List, Tuple DEFAULT_FEATURE_ROOT = "features/" SUMMARY_PATTERNS = { "features": re.compile( r"(\\d+)\\s+feature(?:s)?\\s+passed,\\s+(\\d+)\\s+failed" r"(?:,\\s+(\\d+)\\s+error(?:s)?)?,\\s+(\\d+)\\s+skipped" ), "scenarios": re.compile( r"(\\d+)\\s+scenario(?:s)?\\s+passed,\\s+(\\d+)\\s+failed" r"(?:,\\s+(\\d+)\\s+error(?:s)?)?,\\s+(\\d+)\\s+skipped" ), "steps": re.compile( r"(\\d+)\\s+step(?:s)?\\s+passed,\\s+(\\d+)\\s+failed" r"(?:,\\s+(\\d+)\\s+error(?:s)?)?,\\s+(\\d+)\\s+skipped" ), } DURATION_PATTERN = re.compile(r"Took\\s+(?:(\\d+)m\\s*)?([\\d\\.]+)s") def _extract_features_and_args(argv: List[str]) -> Tuple[List[str], List[str]]: positional: List[str] = [] options: List[str] = [] for arg in argv: if arg.startswith("-"): options.append(arg) else: positional.append(arg) if positional: return positional, options return [DEFAULT_FEATURE_ROOT], options def _empty_summary() -> Dict[str, Dict[str, int] | float]: return { "features": {"passed": 0, "failed": 0, "errors": 0, "skipped": 0}, "scenarios": {"passed": 0, "failed": 0, "errors": 0, "skipped": 0}, "steps": {"passed": 0, "failed": 0, "errors": 0, "skipped": 0}, "duration": 0.0, } def _parse_summary(text: str) -> Dict[str, Dict[str, int] | float]: summary = _empty_summary() for key, pattern in SUMMARY_PATTERNS.items(): match = pattern.search(text) if match: summary[key]["passed"] = int(match.group(1)) summary[key]["failed"] = int(match.group(2)) summary[key]["errors"] = int(match.group(3) or 0) summary[key]["skipped"] = int(match.group(4)) duration_match = DURATION_PATTERN.search(text) if duration_match: minutes = duration_match.group(1) seconds = float(duration_match.group(2)) if minutes: seconds += int(minutes) * 60 summary["duration"] = seconds return summary def _merge_summaries( summaries: List[Dict[str, Dict[str, int] | float]], ) -> Dict[str, Dict[str, int] | float]: total = _empty_summary() for summary in summaries: for bucket in ("features", "scenarios", "steps"): for field in ("passed", "failed", "errors", "skipped"): total[bucket][field] += summary.get(bucket, {}).get(field, 0) total["duration"] += float(summary.get("duration", 0.0)) return total def _format_duration(seconds: float) -> str: minutes = int(seconds // 60) remainder = seconds % 60 if minutes: return f"{minutes}m {remainder:.3f}s" return f"{remainder:.3f}s" def _print_overall_summary(total: Dict[str, Dict[str, int] | float]) -> None: print("\\nOverall summary:") for bucket in ("features", "scenarios", "steps"): b = total[bucket] print( f"{b['passed']} {bucket} passed, {b['failed']} failed, " f"{b['errors']} errored, {b['skipped']} skipped" ) if total["duration"]: print(f"Took {_format_duration(float(total['duration']))} (sum across workers)") def _run_feature( base_args: List[str], feature: str ) -> Tuple[int, str, str, str, Dict[str, Dict[str, int] | float]]: proc = subprocess.run([*base_args, feature], capture_output=True, text=True) combined_output = (proc.stdout or "") + "\\n" + (proc.stderr or "") summary = _parse_summary(combined_output) return proc.returncode, feature, proc.stdout or "", proc.stderr or "", summary def _build_base_args(other_args: List[str]) -> List[str]: if os.environ.get("BEHAVE_PARALLEL_COVERAGE"): rcfile = os.environ.get("COVERAGE_RCFILE") base_args = [sys.executable, "-m", "coverage", "run", "--parallel-mode"] if rcfile: base_args.extend(["--rcfile", rcfile]) base_args.extend(["-m", "behave", *other_args]) return base_args return [sys.executable, "-m", "behave", *other_args] def _iter_features(paths: Iterable[str]) -> List[str]: collected: List[str] = [] for path in paths: p = Path(path) if p.is_dir(): collected.extend(str(fp) for fp in p.rglob("*.feature")) else: collected.append(str(p)) return collected def main(argv: List[str] | None = None) -> None: argv = list(sys.argv[1:] if argv is None else argv) parser = argparse.ArgumentParser(add_help=False) parser.add_argument("--processes", "-j", type=int, default=None) known, remaining = parser.parse_known_args(argv) processes = known.processes or os.cpu_count() or 1 feature_args, other_args = _extract_features_and_args(remaining) if any(flag in remaining for flag in ("-h", "--help", "--version")): code = subprocess.run( [sys.executable, "-m", "behave", *other_args, *feature_args] ).returncode sys.exit(code) feature_paths = _iter_features(feature_args) if not feature_paths: print("No feature files found", file=sys.stderr) sys.exit(0) base_args = _build_base_args(other_args) summaries: List[Dict[str, Dict[str, int] | float]] = [] failures: List[Tuple[int, str]] = [] with concurrent.futures.ThreadPoolExecutor(max_workers=processes) as executor: futures = [ executor.submit(_run_feature, base_args, feature) for feature in feature_paths ] for fut in concurrent.futures.as_completed(futures): code, feature, stdout, stderr, summary = fut.result() if stdout: print(f"=== Output for {feature} ===") print(stdout, end="") if stderr: print(f"=== Stderr for {feature} ===", file=sys.stderr) print(stderr, file=sys.stderr, end="") summaries.append(summary) if code: failures.append((code, feature)) total = _merge_summaries(summaries) _print_overall_summary(total) if failures: for code, feature in failures: print(f"behave failed for {feature} with code {code}", file=sys.stderr) sys.exit(1) if __name__ == "__main__": main() """ ) setup_path = source_dir / "setup.py" setup_path.write_text( """ from setuptools import find_packages, setup setup( name="behave-parallel", version="1.2.4a1", packages=find_packages(), include_package_data=True, install_requires=["behave>=1.2.6"], entry_points={ "console_scripts": ["behave-parallel=behave_parallel.cli:main"], }, ) """ ) 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) 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()) 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") 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" # 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") pabot_args, robot_args = _split_pabot_args(session.posargs) parallel_args = _pabot_parallel_args(pabot_args) 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}", "--exclude", "slow", "--exclude", "discovery", *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" session.run( "robot", "--outputdir", "build/reports/robot", "--loglevel", "INFO", "--report", "report.html", "--log", "log.html", "--xunit", "xunit.xml", "robot/", *session.posargs, ) 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 behave tests in parallel via behave-parallel (same approach as unit_tests) with each worker collecting coverage independently using ``coverage run --parallel-mode``. After all workers finish, the per-process data files are merged with ``coverage combine``. Coverage threshold is enforced at >=97%. On success, emits: COVERAGE OK: % (threshold: 97%) On failure, emits: COVERAGE FAILED: % < 97% threshold Both are single-line, CI-parseable summary strings. """ session.install("-e", ".[tests]") _install_behave_parallel(session) os.makedirs("build", exist_ok=True) session.env["PYTHONPATH"] = str(Path("src").resolve()) session.env["COVERAGE_RCFILE"] = "pyproject.toml" session.env["COVERAGE_FILE"] = "build/.coverage" # Tell behave-parallel workers to run under coverage --parallel-mode session.env["BEHAVE_PARALLEL_COVERAGE"] = "1" # Clean up any existing coverage data session.run("coverage", "erase") # Run behave tests in parallel; each worker collects coverage separately behave_cmd = session.bin + "/behave-parallel" parallel_args = _behave_parallel_args(session.posargs) if session.posargs and session.posargs[0].endswith(".feature"): args = [ behave_cmd, "-q", "--tags=-discovery", "--no-capture", *parallel_args, *session.posargs, ] else: args = [ behave_cmd, "-q", "--tags=-discovery", "--no-capture", *parallel_args, "features/", *session.posargs, ] session.run(*args) # Merge per-worker coverage data files into a single build/.coverage session.run("coverage", "combine") # Always generate HTML and XML reports before checking threshold session.run("coverage", "html") session.run("coverage", "xml") # Generate terminal report and check threshold. # coverage report --fail-under exits non-zero if below threshold, # but nox intercepts the exit. We run it with success_codes to capture # the failure and emit a clear, CI-parseable single-line summary. session.run( "coverage", "report", "--show-missing", f"--fail-under={COVERAGE_THRESHOLD}", success_codes=[0, 2], silent=False, ) # Parse the total percentage from the coverage JSON report for the summary total_pct: float = 0.0 try: session.run("coverage", "json", "-o", "build/coverage.json", silent=True) with open("build/coverage.json") as f: total_pct = json.load(f)["totals"]["percent_covered"] except (FileNotFoundError, KeyError, json.JSONDecodeError): # Fall back: if json report failed, re-read xml 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]") # 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", "--yes", f"--config={config_path}") session.run( "asv", "run", "--append-samples", "--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") session.run("asv", "machine", "--yes", f"--config={config_path}") session.run( "asv", "continuous", "--append-samples", "--show-stderr", "--verbose", f"--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) ]