diff --git a/noxfile.py b/noxfile.py index 90e8665c5..3e422e52b 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,5 +1,9 @@ import contextlib +import os import sys +import tarfile +import urllib.request +from pathlib import Path import nox @@ -9,7 +13,156 @@ 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 _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(os.cpu_count() or 1)] + + +def _install_behave_parallel(session: nox.Session) -> None: + """Install behave-parallel, repackaging the sdist if needed.""" + try: + session.install(f"behave-parallel=={BEHAVE_PARALLEL_VERSION}") + return + except Exception as exc: # noqa: BLE001 + session.log(f"behave-parallel direct install failed, repackaging: {exc}") + + 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 minimal parallel-aware CLI + 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 subprocess +import sys +from pathlib import Path +from typing import Iterable, List, Tuple + +DEFAULT_FEATURE_ROOT = "features/" + + +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 _run_feature(base_args: List[str], feature: str) -> Tuple[int, str]: + proc = subprocess.run([*base_args, feature]) + return proc.returncode, feature + + +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 = [sys.executable, "-m", "behave", *other_args] + + failures: List[Tuple[int, str]] = [] + with concurrent.futures.ProcessPoolExecutor(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 = fut.result() + if code: + failures.append((code, feature)) + + 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) # ============================================================================= @@ -43,31 +196,26 @@ def typecheck(session: nox.Session): def unit_tests(session: nox.Session): """Run BDD tests with Behave.""" session.install("-e", ".[tests]") - session.install("behave") + _install_behave_parallel(session) - # Run Behave BDD tests (excluding discovery tests) - python_executable = session.bin + "/python" + behave_cmd = session.bin + "/behave-parallel" + parallel_args = _behave_parallel_args(session.posargs) - # Check if a specific feature file is passed - args = [] + # If a specific feature file is passed, run only that file if session.posargs and session.posargs[0].endswith(".feature"): - # If a specific feature file is passed, run only that file args = [ - python_executable, - "-m", - "behave", + behave_cmd, "-q", "--tags=-discovery", + *parallel_args, *session.posargs, ] else: - # Default: run all features with posargs appended args = [ - python_executable, - "-m", - "behave", + behave_cmd, "-q", "--tags=-discovery", + *parallel_args, "features/", *session.posargs, ] @@ -181,7 +329,7 @@ def coverage_report(session: nox.Session): # Clean up any existing coverage data session.run("coverage", "erase") - # Run Behave tests with coverage + # Run Behave tests with coverage (sequential to simplify coverage collection) session.run( "coverage", "run", @@ -189,9 +337,10 @@ def coverage_report(session: nox.Session): "--omit=src/cleveragents/discovery/*,*/dependency_injector/*", "-m", "behave", - "features/", "-q", "--tags=not @discovery", + "features/", + *session.posargs, ) # Combine parallel coverage data (required when parallel=true in config) @@ -209,15 +358,27 @@ def coverage_report(session: nox.Session): def discovery_tests(session: nox.Session): """Run Phase 0 discovery tests (tagged with @discovery).""" session.install("-e", ".[tests]") - session.install("behave") - python_executable = session.bin + "/python" - # Run only tests tagged with @discovery + _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"): + session.run( + behave_cmd, + "-q", + "--tags=@discovery", + *parallel_args, + *session.posargs, + ) + return + session.run( - python_executable, - "-m", - "behave", + behave_cmd, "-q", "--tags=@discovery", + *parallel_args, "features/", *session.posargs, ) @@ -231,16 +392,17 @@ def discovery_coverage(session: nox.Session): # Clean up any existing coverage data session.run("coverage", "erase") - # Run only discovery tests with coverage + # Run only discovery tests with coverage (sequential) session.run( "coverage", "run", "--source=src/cleveragents/discovery", "-m", "behave", - "features/", "-q", "--tags=@discovery", + "features/", + *session.posargs, ) # Combine parallel coverage data (required when parallel=true in config) diff --git a/pyproject.toml b/pyproject.toml index e5165fbbf..a7d68d371 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,7 +73,6 @@ dev = [ "pytest-cov>=4.1.0", ] tests = [ - "behave>=1.3.3", "coverage>=7.11.0", "asv>=0.6.5", "robotframework>=7.3.2"