From 3e26db12df0d0565c9e89ea50ddf27515bbd0938 Mon Sep 17 00:00:00 2001 From: Yernat Yestekov <2068106+doublewhy@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:07:50 -0700 Subject: [PATCH 1/3] ci: qualify CPython 3.14 and bound interpreter support (#1097) Qualify standard CPython 3.11 through 3.14 with exact-runtime, frozen-suite, distribution-build, clean-install, metadata, import, and CLI checks. Add a non-advisory workflow matrix and a separate nonblocking 3.14t preview, while bounding installation below 3.15. Harden MCP test loop ownership and Nox option parsing as required by the qualification graph. Closes #1097 Closes #1117 Closes #1100 --- .github/workflows/ci.yml | 23 ++- .../python-free-threaded-preview.yml | 29 +++ CONTRIBUTING.md | 2 +- README.md | 9 +- ...issue-1097-python-314-support-preflight.md | 79 ++++++++ docs/explain/reference/coding-standards.md | 9 +- docs/requirements/GOV-901/requirement.md | 11 +- implementations/python/pyproject.toml | 6 +- .../python/tests/test_mcp_server.py | 4 +- .../tests/test_public_project_readiness.py | 38 ++++ .../python/tests/test_repo_policy_tools.py | 153 +++++++++++++++ implementations/python/uv.lock | 10 +- noxfile.py | 182 +++++++++++++++++- 13 files changed, 533 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/python-free-threaded-preview.yml create mode 100644 docs/decisions/issue-1097-python-314-support-preflight.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01bc2d96c..172e5c18f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main] + branches: [main, dev] tags: ["v*"] pull_request: branches: [main, dev] @@ -91,6 +91,27 @@ jobs: - name: Run fuzz session run: uv tool run --from 'nox[uv]==2026.4.10' nox -f noxfile.py -s fuzz + interpreters: + name: CPython ${{ matrix.python-version }} compatibility + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13", "3.14"] + env: + UV_PYTHON: ${{ matrix.python-version }} + RAES_EXPECTED_PYTHON: ${{ matrix.python-version }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ matrix.python-version }} + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v8 + - name: Test exact interpreter and clean distribution + run: uv tool run --from 'nox[uv]==2026.4.10' nox -f noxfile.py -s python-compatibility + # Opt-in, non-blocking, runtime-gated container integration tests (RUN-314). # Kept out of the hermetic `verify` graph; the `docker` marker tests self-skip # when no runtime is present, and this whole job never fails the build. diff --git a/.github/workflows/python-free-threaded-preview.yml b/.github/workflows/python-free-threaded-preview.yml new file mode 100644 index 000000000..80766b979 --- /dev/null +++ b/.github/workflows/python-free-threaded-preview.yml @@ -0,0 +1,29 @@ +name: Python free-threaded preview + +on: + schedule: + - cron: "17 8 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + python-314t: + name: CPython 3.14t preview only + runs-on: ubuntu-latest + timeout-minutes: 30 + continue-on-error: true + env: + UV_PYTHON: 3.14t + RAES_EXPECTED_PYTHON: "3.14" + RAES_EXPECT_FREE_THREADED: "1" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: 3.14t + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v8 + - name: Test preview interpreter and clean distribution + run: uv tool run --from 'nox[uv]==2026.4.10' nox -f noxfile.py -s python-compatibility diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b82ab5a00..6537d6769 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,7 +19,7 @@ documentation more precise and easier to validate. Prerequisites: -- Python 3.11 or newer +- standard CPython 3.11, 3.12, 3.13, or 3.14 - [uv](https://github.com/astral-sh/uv) - [nox](https://nox.thea.codes/) or `uvx nox` diff --git a/README.md b/README.md index b1378c2f2..c996f4999 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,11 @@ backend. ## Validate your first scenario -You need Python 3.11 or newer. OCI module extraction additionally requires -Python 3.11.4 or newer because earlier 3.11 patch releases lack the mandatory -safe tar extraction filter. Those releases fail closed instead of extracting -without it. +You need standard CPython 3.11 through 3.14. Python 3.15 is not admitted until +its post-final dependency and compatibility qualification is complete. OCI +module extraction on Python 3.11 additionally requires Python 3.11.4 or newer. +Earlier patch releases lack the mandatory safe tar extraction filter and fail +closed instead of extracting without it. ```console python -m venv .venv diff --git a/docs/decisions/issue-1097-python-314-support-preflight.md b/docs/decisions/issue-1097-python-314-support-preflight.md new file mode 100644 index 000000000..436fb2c77 --- /dev/null +++ b/docs/decisions/issue-1097-python-314-support-preflight.md @@ -0,0 +1,79 @@ +# Issue 1097 Python 3.14 Support Preflight + +Date: 2026-08-11 + +Issue: #1097. Requirement: GOV-901. + +## Decision + +The `raes` distribution supports standard CPython 3.11, 3.12, 3.13, and +3.14. `Requires-Python` is therefore bounded to `>=3.11,<3.15`, and the Trove +classifiers list those four tested feature releases. The lower and upper +metadata bounds are install admission; the classifiers and blocking CI matrix +identify the versions for which this repository makes a tested support claim. + +Python 3.15 is outside this change. It remains pre-final until 2026-10-01 and +must not receive a classifier, compatibility job, or package-support claim +before final. A later qualification issue must refresh the governed lock, +review native-wheel coverage on every claimed platform, run clean build/install +and integration gates, and then decide whether to raise the upper bound. + +Free-threaded CPython 3.14 (`3.14t`) is an early-compatibility signal, not part +of the standard-interpreter guarantee. A nonblocking scheduled/manual lane may +install and test it, but must assert that the selected interpreter is actually +free-threaded and label the result preview-only. Failures there cannot be +described as a standard 3.14 regression without reproducing them on the normal +GIL build. + +## Compatibility Gates + +The blocking matrix must, for each standard feature release: + +1. select the requested interpreter explicitly and assert `sys.version_info`; +2. perform a frozen all-extras sync rather than silently resolving another + dependency graph; +3. run the hermetic default unit suite; +4. build both wheel and source distribution; +5. install the wheel into a fresh environment with dependencies; and +6. smoke distribution metadata, public package imports, `raes --version`, and + `raes --help` from that clean environment. + +The compatibility job is separate from canonical `verify`: proof replay, +policy, Sonar coverage, and contract generation remain single-version because +their result is not interpreter-specific. This avoids multiplying proof cost +while making every advertised interpreter blocking for code and packaging. + +The job must not trust its label. `UV_PYTHON`, the runtime assertion, wheel +smoke interpreter, and log output all resolve to the same matrix value. This +prevents a `.python-version`, reused environment, or hard-coded smoke command +from turning the matrix into cosmetic coverage. + +Nox removes an inherited `UV_PYTHON` selector from commands by default. The +compatibility session therefore copies the admitted selector into Nox's +per-session command environment before any nested `uv` call. A tooling +regression covers that handoff, while the exact-runtime assertion remains the +independent fail-closed check. + +## Dependency And Platform Boundary + +The frozen lock and current native dependency set must install on standard +CPython 3.14. A source build is not automatically a defect, but the build +prerequisites and platform claim must remain honest. The compatibility matrix +is initially the repository's existing Ubuntu execution contract; macOS local +verification supplements it but does not silently create a cross-platform +support promise. Backend-specific native integration remains governed by its +own runtime and host prerequisites. + +The 3.14 test change replaces deprecated `asyncio.get_event_loop()` use with +`asyncio.run()` only at synchronous test boundaries. Product async APIs retain +their existing lifecycle and do not create nested event loops. + +## Nonclaims + +This issue does not: + +- support or preview Python 3.15; +- claim production support for free-threaded builds; +- change SDL, contract, runtime, or backend semantics; +- make every optional native backend available on every operating system; or +- weaken canonical verification, coverage, policy, or release gates. diff --git a/docs/explain/reference/coding-standards.md b/docs/explain/reference/coding-standards.md index cfb044f3d..a39cb00d0 100644 --- a/docs/explain/reference/coding-standards.md +++ b/docs/explain/reference/coding-standards.md @@ -246,9 +246,12 @@ that makes the semantic behavior clear and testable. ## Static Analysis Alignment -The Python implementation targets Python 3.11+ and uses Ruff with pyupgrade as -the local style contract. SonarCloud is advisory but must not contradict that -contract. Rules that require Python 2 compatibility idioms, Python 3.12-only +The Python implementation supports standard CPython 3.11 through 3.14 and uses +Ruff with pyupgrade as the local style contract. `Requires-Python` is bounded +below 3.15; the blocking CI matrix and classifiers define the tested standard +interpreter set. A scheduled/manual 3.14t lane is preview-only and does not +extend the production support claim. SonarCloud is advisory but must not +contradict that contract. Rules that require Python 2 compatibility idioms, Python 3.12-only generic syntax, or mechanical docstrings on every private helper are disabled in `sonar-project.properties` rather than encoded as source churn. diff --git a/docs/requirements/GOV-901/requirement.md b/docs/requirements/GOV-901/requirement.md index aec2515e7..57ba9cbd9 100644 --- a/docs/requirements/GOV-901/requirement.md +++ b/docs/requirements/GOV-901/requirement.md @@ -6,7 +6,7 @@ type: NON_FUNCTIONAL priority: MUST wave: 2 created_at: 2026-04-03T07:16:00.634358Z -updated_at: 2026-06-14T02:41:58.145594Z +updated_at: 2026-08-12T00:00:00.000000Z --- # GOV-901 — Versioning And Compatibility Rules @@ -41,3 +41,12 @@ Requirement inventory expansion. Compatibility claims need explicit versioning a - DOCUMENTS → DOCUMENTATION `docs/decisions/issue-1111-gov-901-cli-version-startup-preflight.md` (Measured startup boundary and compatibility invariants) - TESTS → TEST `implementations/python/tests/test_issue_1111_cli_version_startup.py` (Source, fallback, delegation, and startup-budget guards) - TESTS → TEST `implementations/python/tests/test_corpus_packaging.py` (Clean installed-wheel entry-point acceptance) +- IMPLEMENTS → GITHUB_ISSUE `1097` (Standard CPython 3.14 support and bounded 3.15 admission) +- IMPLEMENTS → GITHUB_ISSUE `1100` (Fail-closed Nox option-value validation) +- IMPLEMENTS → GITHUB_ISSUE `1117` (Python 3.14-safe MCP test event-loop boundaries) +- DOCUMENTS → DOCUMENTATION `docs/decisions/issue-1097-python-314-support-preflight.md` (Blocking interpreter matrix, clean distribution smoke, and free-threaded preview boundary) +- IMPLEMENTS → CONFIG `noxfile.py` (Exact interpreter selection, hermetic suite, build, install, and smoke graph) +- IMPLEMENTS → CONFIG `.github/workflows/ci.yml` (Blocking CPython 3.11 through 3.14 matrix) +- IMPLEMENTS → CONFIG `.github/workflows/python-free-threaded-preview.yml` (Nonblocking scheduled/manual 3.14t preview) +- TESTS → TEST `implementations/python/tests/test_repo_policy_tools.py` (Selector handoff and compatibility-graph regression tests) +- TESTS → TEST `implementations/python/tests/test_mcp_server.py` (Deprecation-free synchronous MCP test boundaries) diff --git a/implementations/python/pyproject.toml b/implementations/python/pyproject.toml index 788f05f54..ead47517e 100644 --- a/implementations/python/pyproject.toml +++ b/implementations/python/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" name = "raes" description = "Contracts and reference tooling for reproducible agentic environments." dynamic = ["readme", "version"] -requires-python = ">=3.11" +requires-python = ">=3.11,<3.15" # Trove classifiers are the only source PyPI and downstream badges read for # supported interpreter versions; `requires-python` alone leaves them blank. # Keep this list in step with `requires-python`. @@ -14,6 +14,8 @@ classifiers = [ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] dependencies = [ "typer>=0.12.0", @@ -27,7 +29,7 @@ dependencies = [ "uvicorn[standard]>=0.34.0", "sse-starlette>=2.0.0", "asyncssh>=2.23.0", - "mcp>=1.28.1", + "mcp>=1.28.1,<2", "packaging>=23.0", "rfc8785>=0.1.4,<0.2", "blake3>=1.0.8,<2", diff --git a/implementations/python/tests/test_mcp_server.py b/implementations/python/tests/test_mcp_server.py index 725a443ef..9bdbaafa2 100644 --- a/implementations/python/tests/test_mcp_server.py +++ b/implementations/python/tests/test_mcp_server.py @@ -31,7 +31,7 @@ def _text(result) -> str: def _call(server, tool: str, args: dict | None = None) -> str: """Synchronously call a tool and return its text.""" - return asyncio.get_event_loop().run_until_complete(_async_call(server, tool, args or {})) + return asyncio.run(_async_call(server, tool, args or {})) async def _async_call(server, tool: str, args: dict) -> str: @@ -897,7 +897,7 @@ def test_server_has_all_tools(self): # Using the real registration surface (rather than a hand-copied # literal) means a drift between what the server exposes and what # raes_tool_surface advertises cannot pass silently. - registered = asyncio.get_event_loop().run_until_complete(server.list_tools()) + registered = asyncio.run(server.list_tools()) registered_names = {tool.name for tool in registered} assert registered_names, "server registered no tools" diff --git a/implementations/python/tests/test_public_project_readiness.py b/implementations/python/tests/test_public_project_readiness.py index 9fab6f27e..f651b687c 100644 --- a/implementations/python/tests/test_public_project_readiness.py +++ b/implementations/python/tests/test_public_project_readiness.py @@ -4,6 +4,7 @@ import json import re +import tomllib from pathlib import Path import yaml @@ -86,3 +87,40 @@ def test_publishers_build_only_the_curated_public_source() -> None: makefile = (REPO_ROOT / "docs" / "Makefile").read_text(encoding="utf-8") assert "SOURCEDIR = public" in makefile assert "BUILDDIR = _build" in makefile + + +def test_python_support_metadata_and_blocking_matrix_are_aligned() -> None: + pyproject = tomllib.loads((REPO_ROOT / "implementations" / "python" / "pyproject.toml").read_text(encoding="utf-8")) + project = pyproject["project"] + supported = ["3.11", "3.12", "3.13", "3.14"] + + assert project["requires-python"] == ">=3.11,<3.15" + assert { + classifier.removeprefix("Programming Language :: Python :: ") + for classifier in project["classifiers"] + if re.fullmatch(r"Programming Language :: Python :: 3\.\d+", classifier) + } == set(supported) + + ci = yaml.safe_load((REPO_ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8")) + interpreter_job = ci["jobs"]["interpreters"] + assert interpreter_job["strategy"]["fail-fast"] is False + assert interpreter_job["strategy"]["matrix"]["python-version"] == supported + assert interpreter_job["env"] == { + "UV_PYTHON": "${{ matrix.python-version }}", + "RAES_EXPECTED_PYTHON": "${{ matrix.python-version }}", + } + + preview = yaml.safe_load( + (REPO_ROOT / ".github" / "workflows" / "python-free-threaded-preview.yml").read_text(encoding="utf-8") + ) + preview_job = preview["jobs"]["python-314t"] + assert preview_job["continue-on-error"] is True + assert preview_job["env"] == { + "UV_PYTHON": "3.14t", + "RAES_EXPECTED_PYTHON": "3.14", + "RAES_EXPECT_FREE_THREADED": "1", + } + + noxfile = (REPO_ROOT / "noxfile.py").read_text(encoding="utf-8") + assert 'assert is_gil_enabled() is False, "interpreter is not free-threaded"' in noxfile + assert 'assert is_gil_enabled() is True, "standard lane selected a free-threaded interpreter"' in noxfile diff --git a/implementations/python/tests/test_repo_policy_tools.py b/implementations/python/tests/test_repo_policy_tools.py index e1c40e0dd..73f20167d 100644 --- a/implementations/python/tests/test_repo_policy_tools.py +++ b/implementations/python/tests/test_repo_policy_tools.py @@ -314,6 +314,143 @@ def chdir(self, _path: Path): ) +def test_policy_session_split_routes_base_revision_to_both_policy_layers( + monkeypatch: pytest.MonkeyPatch, +) -> None: + noxfile = load_noxfile_with_fake_nox(monkeypatch) + + repo_args, requirement_args, skip_requirement = noxfile._split_policy_session_args( + ["--base-rev", "origin/dev", "--requirement-uid", "API-404"] + ) + + assert repo_args == ["--base-rev", "origin/dev"] + assert requirement_args == ["--base-rev", "origin/dev", "--requirement-uid", "API-404"] + assert skip_requirement is False + + +def _exercise_python_compatibility( + monkeypatch: pytest.MonkeyPatch, + *, + build_artifacts: bool, +) -> tuple[types.ModuleType, list[tuple[str, ...]], list[tuple[str, ...]], list[str]]: + noxfile = load_noxfile_with_fake_nox(monkeypatch) + commands: list[tuple[str, ...]] = [] + pytest_calls: list[tuple[str, ...]] = [] + logs: list[str] = [] + + class FakeSession: + def __init__(self) -> None: + self.env: dict[str, str] = {} + + def log(self, message: str) -> None: + logs.append(message) + + def fake_run(session: FakeSession, *command: str, **_kwargs: object) -> None: + assert session.env["UV_PYTHON"] == "cpython-3.14" + commands.append(command) + if command[:2] != ("uv", "build"): + return + output_dir = Path(command[command.index("--out-dir") + 1]) + output_dir.mkdir(parents=True) + if build_artifacts: + (output_dir / "raes-3.3.0-py3-none-any.whl").write_bytes(b"wheel") + (output_dir / "raes-3.3.0.tar.gz").write_bytes(b"sdist") + + monkeypatch.setenv(noxfile.EXPECTED_PYTHON_ENV, "3.14") + monkeypatch.setenv("UV_PYTHON", "cpython-3.14") + monkeypatch.setenv(noxfile.EXPECT_FREE_THREADED_ENV, "1") + monkeypatch.setattr(noxfile, "_run", fake_run) + monkeypatch.setattr( + noxfile, + "_run_pytest", + lambda _session, *args, **_kwargs: pytest_calls.append(tuple(args)), + ) + reporter = noxfile.SessionReporter(FakeSession(), "python-compatibility") + noxfile._run_python_compatibility(reporter.session, reporter) + return noxfile, commands, pytest_calls, [result.name for result in reporter.results] + + +def test_python_compatibility_graph_builds_and_checks_clean_distribution( + monkeypatch: pytest.MonkeyPatch, +) -> None: + noxfile, commands, pytest_calls, stages = _exercise_python_compatibility( + monkeypatch, + build_artifacts=True, + ) + + assert stages == [ + "python compatibility / frozen sync", + "python compatibility / exact runtime", + "python compatibility / hermetic tests", + "python compatibility / build distributions", + "python compatibility / create clean environment", + "python compatibility / install wheel", + "python compatibility / installed metadata and imports", + "python compatibility / installed CLI version", + "python compatibility / installed CLI help", + ] + assert pytest_calls == [("-q",)] + runtime_command = next(command for command in commands if command[:2] == ("uv", "run")) + assert runtime_command[-2:] == ("3.14", "1") + build_command = next(command for command in commands if command[:2] == ("uv", "build")) + assert build_command[build_command.index("--python") :][:2] == ("--python", "cpython-3.14") + installed_python = next(command for command in commands if command and command[0].endswith("/bin/python")) + assert installed_python[-1] == "3.14" + assert any(command and command[0].endswith("/bin/raes") and command[-1] == "--version" for command in commands) + assert noxfile.PROJECT_ROOT.as_posix() in build_command + + +@pytest.mark.parametrize( + ("expected", "selector", "message"), + [ + ("3.15", "cpython-3.15", "must select a supported feature release"), + ("3.14", "", "UV_PYTHON must select the interpreter under test"), + ], +) +def test_python_compatibility_rejects_unsupported_or_missing_interpreter_selection( + monkeypatch: pytest.MonkeyPatch, + expected: str, + selector: str, + message: str, +) -> None: + noxfile = load_noxfile_with_fake_nox(monkeypatch) + monkeypatch.setenv(noxfile.EXPECTED_PYTHON_ENV, expected) + monkeypatch.setenv("UV_PYTHON", selector) + reporter = noxfile.SessionReporter(types.SimpleNamespace(log=lambda _message: None), "python-compatibility") + + with pytest.raises(RuntimeError, match=message): + noxfile._run_python_compatibility(reporter.session, reporter) + + +def test_python_compatibility_rejects_incomplete_distribution_build( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with pytest.raises(RuntimeError, match="must produce exactly one wheel and one source distribution"): + _exercise_python_compatibility(monkeypatch, build_artifacts=False) + + +def test_python_compatibility_and_osv_session_wrappers_always_summarize( + monkeypatch: pytest.MonkeyPatch, +) -> None: + noxfile = load_noxfile_with_fake_nox(monkeypatch) + calls: list[str] = [] + logs: list[str] = [] + session = types.SimpleNamespace(log=logs.append, posargs=[]) + monkeypatch.setattr( + noxfile, + "_run_python_compatibility", + lambda _session, _reporter: calls.append("python"), + ) + monkeypatch.setattr(noxfile, "_run_osv_scan", lambda _session, _reporter, **_kwargs: calls.append("osv")) + + noxfile.python_compatibility(session) + noxfile.osv_scan(session) + + assert calls == ["python", "osv"] + assert any("[python-compatibility] stage summary" in message for message in logs) + assert any("[osv_scan] stage summary" in message for message in logs) + + def test_docs_graph_uses_curated_root_and_reader_style_gate( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, @@ -509,6 +646,22 @@ def fake_changed_paths(*, staged: bool = False, base_rev: str | None = None) -> ] +@pytest.mark.parametrize("option", ["--base-rev", "--requirement-uid"]) +@pytest.mark.parametrize("trailing", [[], [""], ["--skip-requirement"]]) +def test_policy_arg_parsers_reject_missing_option_values( + monkeypatch: pytest.MonkeyPatch, + option: str, + trailing: list[str], +) -> None: + noxfile = load_noxfile_with_fake_nox(monkeypatch) + args = [option, *trailing] + + with pytest.raises(ValueError, match=rf"^{option} requires a value$"): + noxfile._split_policy_session_args(args) + with pytest.raises(ValueError, match=rf"^{option} requires a value$"): + noxfile._parse_hygiene_posargs(args, default_all_files=False) + + def test_structural_policy_runner_receives_policy_input(tmp_path: Path) -> None: repo_root = setup_policy_repo(tmp_path) captured: dict = {} diff --git a/implementations/python/uv.lock b/implementations/python/uv.lock index 34e920027..033e4e440 100644 --- a/implementations/python/uv.lock +++ b/implementations/python/uv.lock @@ -1,12 +1,13 @@ version = 1 revision = 3 -requires-python = ">=3.11" +requires-python = ">=3.11, <3.15" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform != 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32'", - "python_full_version < '3.12'", + "python_full_version < '3.12' and sys_platform != 'win32'", ] [[package]] @@ -1290,7 +1291,7 @@ requires-dist = [ { name = "furo", marker = "extra == 'docs'", specifier = ">=2024.5.6" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" }, { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.0.0" }, - { name = "mcp", specifier = ">=1.28.1" }, + { name = "mcp", specifier = ">=1.28.1,<2" }, { name = "myst-parser", marker = "extra == 'docs'", specifier = ">=3.0.0" }, { name = "packaging", specifier = ">=23.0" }, { name = "pydantic", specifier = ">=2.0.0" }, @@ -1522,7 +1523,8 @@ name = "sphinx" version = "9.0.4" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.12'", + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform != 'win32'", ] dependencies = [ { name = "alabaster", marker = "python_full_version < '3.12'" }, diff --git a/noxfile.py b/noxfile.py index b529b5384..bbc4ea77f 100644 --- a/noxfile.py +++ b/noxfile.py @@ -89,6 +89,8 @@ VERIFY_PROJECT_SYNCED_ENV = "RAES_VERIFY_PROJECT_SYNCED" VERIFY_COVERAGE_FILE_ENV = "RAES_VERIFY_COVERAGE_FILE" JSON_SCHEMA_WORKERS_ENV = "RAES_JSON_SCHEMA_WORKERS" +EXPECTED_PYTHON_ENV = "RAES_EXPECTED_PYTHON" +EXPECT_FREE_THREADED_ENV = "RAES_EXPECT_FREE_THREADED" nox.options.default_venv_backend = "none" nox.options.reuse_existing_virtualenvs = True @@ -282,6 +284,13 @@ def _run_pytest( ) +def _required_option_value(values: Sequence[str], index: int, option: str) -> str: + value_index = index + 1 + if value_index >= len(values) or not values[value_index] or values[value_index].startswith("--"): + raise ValueError(f"{option} requires a value") + return values[value_index] + + def _split_policy_session_args(posargs: list[str]) -> tuple[list[str], list[str], bool]: repo_args: list[str] = [] requirement_args: list[str] = [] @@ -294,12 +303,13 @@ def _split_policy_session_args(posargs: list[str]) -> tuple[list[str], list[str] index += 1 continue if arg == "--requirement-uid": - requirement_args.extend([arg, posargs[index + 1]]) + requirement_args.extend([arg, _required_option_value(posargs, index, arg)]) index += 2 continue if arg == "--base-rev": - repo_args.extend([arg, posargs[index + 1]]) - requirement_args.extend([arg, posargs[index + 1]]) + value = _required_option_value(posargs, index, arg) + repo_args.extend([arg, value]) + requirement_args.extend([arg, value]) index += 2 continue repo_args.append(arg) @@ -329,7 +339,7 @@ def _parse_hygiene_posargs(posargs: Sequence[str], *, default_all_files: bool) - index += 1 continue if arg == "--base-rev": - base_rev = values[index + 1] + base_rev = _required_option_value(values, index, arg) all_files = False index += 2 continue @@ -337,6 +347,7 @@ def _parse_hygiene_posargs(posargs: Sequence[str], *, default_all_files: bool) - index += 1 continue if arg == "--requirement-uid": + _required_option_value(values, index, arg) index += 2 continue explicit_paths.append(arg) @@ -840,6 +851,158 @@ def _run_tests( ) +def _run_python_compatibility(session: nox.Session, reporter: SessionReporter) -> None: + expected = os.environ.get(EXPECTED_PYTHON_ENV, "") + selector = os.environ.get("UV_PYTHON", "") + if expected not in {"3.11", "3.12", "3.13", "3.14"}: + raise RuntimeError(f"{EXPECTED_PYTHON_ENV} must select a supported feature release") + if not selector: + raise RuntimeError("UV_PYTHON must select the interpreter under test") + expect_free_threaded = os.environ.get(EXPECT_FREE_THREADED_ENV) == "1" + # Nox removes UV_PYTHON inherited from the parent process. Put the + # matrix selector back into the per-session command environment so every + # nested uv invocation uses the interpreter that the lane names. + session.env["UV_PYTHON"] = selector + + reporter.run( + "python compatibility / frozen sync", + lambda: _sync_project(session), + detail=f"selector={selector}", + ) + + runtime_assertion = """ +import sys + +expected = tuple(int(part) for part in sys.argv[1].split(".")) +assert sys.implementation.name == "cpython", sys.implementation.name +assert sys.version_info[:2] == expected, (sys.version, expected) +is_gil_enabled = getattr(sys, "_is_gil_enabled", None) +if sys.argv[2] == "1": + assert callable(is_gil_enabled), "interpreter does not disclose GIL state" + assert is_gil_enabled() is False, "interpreter is not free-threaded" +elif callable(is_gil_enabled): + assert is_gil_enabled() is True, "standard lane selected a free-threaded interpreter" +print(sys.version) +""" + reporter.run( + "python compatibility / exact runtime", + lambda: _run( + session, + "uv", + "run", + "--project", + str(PROJECT_ROOT), + "--all-extras", + "--frozen", + "python", + "-c", + runtime_assertion, + expected, + "1" if expect_free_threaded else "0", + ), + ) + reporter.run( + "python compatibility / hermetic tests", + lambda: _run_pytest(session, "-q", parallel=True), + detail="xdist auto, max 8, worksteal", + ) + + with tempfile.TemporaryDirectory(prefix="raes-python-compatibility-") as temporary_dir: + root = Path(temporary_dir) + dist_dir = root / "dist" + environment_dir = root / "installed" + + reporter.run( + "python compatibility / build distributions", + lambda: _run( + session, + "uv", + "build", + "--python", + selector, + "--out-dir", + str(dist_dir), + str(PROJECT_ROOT), + ), + ) + wheels = sorted(dist_dir.glob("raes-*.whl")) + source_distributions = sorted(dist_dir.glob("raes-*.tar.gz")) + if len(wheels) != 1 or len(source_distributions) != 1: + raise RuntimeError("compatibility build must produce exactly one wheel and one source distribution") + + reporter.run( + "python compatibility / create clean environment", + lambda: _run( + session, + "uv", + "venv", + "--no-project", + "--python", + selector, + str(environment_dir), + ), + ) + scripts_dir = environment_dir / ("Scripts" if os.name == "nt" else "bin") + python = scripts_dir / ("python.exe" if os.name == "nt" else "python") + raes = scripts_dir / ("raes.exe" if os.name == "nt" else "raes") + reporter.run( + "python compatibility / install wheel", + lambda: _run( + session, + "uv", + "pip", + "install", + "--python", + str(python), + str(wheels[0]), + ), + ) + + installed_assertion = """ +import importlib +import sys +from importlib.metadata import metadata + +from packaging.specifiers import SpecifierSet +from packaging.version import Version + +expected = tuple(int(part) for part in sys.argv[1].split(".")) +assert sys.version_info[:2] == expected, (sys.version, expected) +for module in ( + "raes", + "raes_backend_libvirt", + "raes_backend_protocols", + "raes_backend_stubs", + "raes_cli", + "raes_conformance", + "raes_contracts", + "raes_mcp", + "raes_operations", + "raes_processor", + "raes_reference_backend", + "raes_runtime", +): + importlib.import_module(module) +requires_python = metadata("raes")["Requires-Python"] +support = SpecifierSet(requires_python) +assert Version("3.11") in support +assert Version("3.14") in support +assert Version("3.15") not in support +""" + reporter.run( + "python compatibility / installed metadata and imports", + lambda: _run(session, str(python), "-c", installed_assertion, expected), + ) + reporter.run( + "python compatibility / installed CLI version", + lambda: _run(session, str(raes), "--version"), + ) + reporter.run( + "python compatibility / installed CLI help", + lambda: _run(session, str(raes), "--help"), + ) + + def _run_fuzz(session: nox.Session, reporter: SessionReporter) -> None: reporter.run( "tests / pytest fuzz", @@ -1097,6 +1260,17 @@ def tests(session: nox.Session) -> None: reporter.summary() +@nox.session(name="python-compatibility") +def python_compatibility(session: nox.Session) -> None: + """Test one exact supported interpreter and its installed distribution.""" + + reporter = SessionReporter(session, "python-compatibility") + try: + _run_python_compatibility(session, reporter) + finally: + reporter.summary() + + @nox.session def fuzz(session: nox.Session) -> None: reporter = SessionReporter(session, "fuzz") From c3966e9ad1b1ea30a44eeca097bcbd6ba181bc4a Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Fri, 14 Aug 2026 02:29:01 +0200 Subject: [PATCH 2/3] docs: record Python compatibility verification architecture --- tools/policy/historical_identity_records.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/policy/historical_identity_records.json b/tools/policy/historical_identity_records.json index 78a6e5010..120af436b 100644 --- a/tools/policy/historical_identity_records.json +++ b/tools/policy/historical_identity_records.json @@ -495,7 +495,7 @@ "record_class": "historical-index", "rationale": "Indexes immutable pre-cutover ADR titles, paths, pins, and amendment summaries without making them current identity surfaces.", "occurrences": 4, - "content_sha256": "2ec999f5656f32a22acf9f3c94c850fa6328ffd7ebc7e1e06607e5144e8fc05b" + "content_sha256": "349b0be47c3b0af9824b5df9bb1882ee203ddf5fa9965e10c0b93028354ac331" }, { "path": "docs/decisions/cage-2-replication-design.md", From eabc4b0052b0c03162a7151330f7bd752f0728bd Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Fri, 14 Aug 2026 02:31:13 +0200 Subject: [PATCH 3/3] style: align merged verification tooling --- noxfile.py | 252 +++++++++++++---------------------------------------- 1 file changed, 62 insertions(+), 190 deletions(-) diff --git a/noxfile.py b/noxfile.py index 4c30961dd..18e9471cc 100644 --- a/noxfile.py +++ b/noxfile.py @@ -130,17 +130,11 @@ def run(self, name: str, func: Callable[[], None], *, detail: str = "") -> None: func() except Exception: duration_s = perf_counter() - started - self.results.append( - StageResult( - name=name, status="FAIL", detail=detail, duration_s=duration_s - ) - ) + self.results.append(StageResult(name=name, status="FAIL", detail=detail, duration_s=duration_s)) self._log("FAIL", name, detail, duration_s) raise duration_s = perf_counter() - started - self.results.append( - StageResult(name=name, status="PASS", detail=detail, duration_s=duration_s) - ) + self.results.append(StageResult(name=name, status="PASS", detail=detail, duration_s=duration_s)) self._log("PASS", name, detail, duration_s) def skip(self, name: str, reason: str) -> None: @@ -153,17 +147,11 @@ def summary(self) -> None: self.session.log(f"[{self.session_name}] SKIP no stages executed") return for result in self.results: - duration = ( - f" ({result.duration_s:.2f}s)" if result.duration_s is not None else "" - ) + duration = f" ({result.duration_s:.2f}s)" if result.duration_s is not None else "" detail = f" :: {result.detail}" if result.detail else "" - self.session.log( - f"[{self.session_name}] {result.status:<4} {result.name}{duration}{detail}" - ) + self.session.log(f"[{self.session_name}] {result.status:<4} {result.name}{duration}{detail}") - def _log( - self, status: str, name: str, detail: str, duration_s: float | None = None - ) -> None: + def _log(self, status: str, name: str, detail: str, duration_s: float | None = None) -> None: duration = f" ({duration_s:.2f}s)" if duration_s is not None else "" suffix = f" :: {detail}" if detail else "" self.session.log(f"[{self.session_name}] {status}: {name}{duration}{suffix}") @@ -191,16 +179,10 @@ def _git_lines(*args: str) -> list[str]: def _changed_paths(*, staged: bool = False, base_rev: str | None = None) -> list[str]: if staged: - return _normalize_paths( - _git_lines("diff", "--name-only", "--diff-filter=d", "--cached") - ) + return _normalize_paths(_git_lines("diff", "--name-only", "--diff-filter=d", "--cached")) if base_rev: - return _normalize_paths( - _git_lines("diff", "--name-only", "--diff-filter=d", base_rev, "HEAD") - ) - return _normalize_paths( - _git_lines("diff", "--name-only", "--diff-filter=d", "HEAD") - ) + return _normalize_paths(_git_lines("diff", "--name-only", "--diff-filter=d", base_rev, "HEAD")) + return _normalize_paths(_git_lines("diff", "--name-only", "--diff-filter=d", "HEAD")) def _sync_project(session: nox.Session) -> None: @@ -278,9 +260,7 @@ def _run_pytest( ) -> None: _sync_project(session) normalized_args = [ - str((REPO_ROOT / arg).relative_to(PROJECT_ROOT)) - if arg.startswith("implementations/python/") - else arg + str((REPO_ROOT / arg).relative_to(PROJECT_ROOT)) if arg.startswith("implementations/python/") else arg for arg in args ] command = ["uv", "run", "--frozen", "python", "-m", "pytest"] @@ -301,11 +281,7 @@ def _run_pytest( def _required_option_value(values: Sequence[str], index: int, option: str) -> str: value_index = index + 1 - if ( - value_index >= len(values) - or not values[value_index] - or values[value_index].startswith("--") - ): + if value_index >= len(values) or not values[value_index] or values[value_index].startswith("--"): raise ValueError(f"{option} requires a value") return values[value_index] @@ -346,9 +322,7 @@ def _requirement_aware_policy_args(*args: str) -> list[str]: return [*args, "--skip-requirement"] -def _parse_hygiene_posargs( - posargs: Sequence[str], *, default_all_files: bool -) -> HygieneSelection: +def _parse_hygiene_posargs(posargs: Sequence[str], *, default_all_files: bool) -> HygieneSelection: staged = False base_rev: str | None = None all_files = default_all_files @@ -384,9 +358,7 @@ def _parse_hygiene_posargs( all_files = False index += 1 if explicit_paths: - return HygieneSelection( - paths=_normalize_paths(explicit_paths), source="explicit path selection" - ) + return HygieneSelection(paths=_normalize_paths(explicit_paths), source="explicit path selection") if staged: return HygieneSelection( paths=_changed_paths(staged=True), @@ -398,16 +370,12 @@ def _parse_hygiene_posargs( source=f"changes since {base_rev}", ) if all_files: - return HygieneSelection( - paths=_tracked_repo_paths(), source="tracked repository files" - ) + return HygieneSelection(paths=_tracked_repo_paths(), source="tracked repository files") return HygieneSelection(paths=_changed_paths(), source="working tree changes") def _tracked_repo_paths() -> list[str]: - return _normalize_paths( - _git_lines("ls-files", "--cached", "--others", "--exclude-standard") - ) + return _normalize_paths(_git_lines("ls-files", "--cached", "--others", "--exclude-standard")) def _normalize_paths(paths: Iterable[str]) -> list[str]: @@ -455,9 +423,7 @@ def _paths_trigger(paths: Iterable[str], prefixes: tuple[str, ...]) -> bool: return any(path.startswith(prefixes) or path in prefixes for path in paths) -def _run_pre_commit_hook( - _session: nox.Session, command: str, *args: str, paths: list[str] -) -> None: +def _run_pre_commit_hook(_session: nox.Session, command: str, *args: str, paths: list[str]) -> None: for batch in _chunked(paths): _run_external_subprocess( "uv", @@ -515,45 +481,31 @@ def _run_hygiene( text_paths = _text_paths(paths) yaml_paths = _suffix_paths(paths, (".yaml", ".yml")) json_paths = _suffix_paths(paths, (".json",)) - private_key_paths = [ - path for path in paths if not path.startswith(PRIVATE_KEY_EXCLUDE_PREFIXES) - ] + private_key_paths = [path for path in paths if not path.startswith(PRIVATE_KEY_EXCLUDE_PREFIXES)] reporter.run( "hygiene / trailing whitespace", - lambda: _run_pre_commit_hook( - session, "trailing-whitespace-fixer", paths=text_paths - ), + lambda: _run_pre_commit_hook(session, "trailing-whitespace-fixer", paths=text_paths), detail=f"{len(text_paths)} text files from {selection.source}", - ) if text_paths else reporter.skip( - "hygiene / trailing whitespace", "no text files selected" - ) + ) if text_paths else reporter.skip("hygiene / trailing whitespace", "no text files selected") reporter.run( "hygiene / eof newline", lambda: _run_pre_commit_hook(session, "end-of-file-fixer", paths=text_paths), detail=f"{len(text_paths)} text files from {selection.source}", - ) if text_paths else reporter.skip( - "hygiene / eof newline", "no text files selected" - ) + ) if text_paths else reporter.skip("hygiene / eof newline", "no text files selected") reporter.run( "hygiene / yaml syntax", - lambda: _run_pre_commit_hook( - session, "check-yaml", "--unsafe", paths=yaml_paths - ), + lambda: _run_pre_commit_hook(session, "check-yaml", "--unsafe", paths=yaml_paths), detail=f"{len(yaml_paths)} YAML files from {selection.source}", - ) if yaml_paths else reporter.skip( - "hygiene / yaml syntax", "no YAML files selected" - ) + ) if yaml_paths else reporter.skip("hygiene / yaml syntax", "no YAML files selected") reporter.run( "hygiene / json syntax", lambda: _run_pre_commit_hook(session, "check-json", paths=json_paths), detail=f"{len(json_paths)} JSON files from {selection.source}", - ) if json_paths else reporter.skip( - "hygiene / json syntax", "no JSON files selected" - ) + ) if json_paths else reporter.skip("hygiene / json syntax", "no JSON files selected") reporter.run( "hygiene / added large files", @@ -571,19 +523,13 @@ def _run_hygiene( "hygiene / merge conflict markers", lambda: _run_pre_commit_hook(session, "check-merge-conflict", paths=text_paths), detail=f"{len(text_paths)} text files from {selection.source}", - ) if text_paths else reporter.skip( - "hygiene / merge conflict markers", "no text files selected" - ) + ) if text_paths else reporter.skip("hygiene / merge conflict markers", "no text files selected") reporter.run( "hygiene / private key detection", - lambda: _run_pre_commit_hook( - session, "detect-private-key", paths=private_key_paths - ), + lambda: _run_pre_commit_hook(session, "detect-private-key", paths=private_key_paths), detail=f"{len(private_key_paths)} files from {selection.source}", - ) if private_key_paths else reporter.skip( - "hygiene / private key detection", "no eligible files selected" - ) + ) if private_key_paths else reporter.skip("hygiene / private key detection", "no eligible files selected") reporter.run( "hygiene / gitleaks", @@ -608,9 +554,7 @@ def _run_policy(session: nox.Session, reporter: SessionReporter, *args: str) -> "from tools.policy.conftest_tool import verify_conftest_policy; verify_conftest_policy()", ), ) - repo_args, requirement_args, skip_requirement = _split_policy_session_args( - list(args) - ) + repo_args, requirement_args, skip_requirement = _split_policy_session_args(list(args)) arg_list = list(args) adr_pin_args: list[str] = [] if "--base-rev" in arg_list: @@ -622,15 +566,11 @@ def _run_policy(session: nox.Session, reporter: SessionReporter, *args: str) -> lambda: _run_project_python(session, "tools/check_repo_policy.py", *repo_args), ) if skip_requirement: - reporter.skip( - "policy / requirement governance", "skipped by --skip-requirement" - ) + reporter.skip("policy / requirement governance", "skipped by --skip-requirement") else: reporter.run( "policy / requirement governance", - lambda: _run_project_python( - session, "tools/check_requirement_governance.py", *requirement_args - ), + lambda: _run_project_python(session, "tools/check_requirement_governance.py", *requirement_args), ) # check_semantic_coverage.py validates live files on disk, not a staged # snapshot, so it is meaningless (and misleading) under --staged. It runs in @@ -695,21 +635,15 @@ def _run_policy(session: nox.Session, reporter: SessionReporter, *args: str) -> ) reporter.run( "policy / deprecation lifecycle records", - lambda: _run_project_python( - session, "tools/check_deprecation_lifecycle.py" - ), + lambda: _run_project_python(session, "tools/check_deprecation_lifecycle.py"), ) reporter.run( "policy / concept authority governance", - lambda: _run_project_python( - session, "tools/check_concept_authority_governance.py" - ), + lambda: _run_project_python(session, "tools/check_concept_authority_governance.py"), ) reporter.run( "policy / behavioral relation claims", - lambda: _run_project_python( - session, "tools/check_behavioral_relation_claims.py" - ), + lambda: _run_project_python(session, "tools/check_behavioral_relation_claims.py"), ) reporter.run( "policy / agent guidance profile", @@ -729,9 +663,7 @@ def _run_policy(session: nox.Session, reporter: SessionReporter, *args: str) -> ) reporter.run( "policy / ADR acceptance-content pin", - lambda: _run_project_python( - session, "tools/check_adr_immutability.py", *adr_pin_args - ), + lambda: _run_project_python(session, "tools/check_adr_immutability.py", *adr_pin_args), ) @@ -764,9 +696,7 @@ def _run_contracts(session: nox.Session, reporter: SessionReporter, *args: str) index += 1 reporter.run( "contracts / schema publication manifest", - lambda: _run_project_python( - session, "tools/check_schema_publication.py", *schema_publication_args - ), + lambda: _run_project_python(session, "tools/check_schema_publication.py", *schema_publication_args), ) reporter.run( "contracts / generated schema drift", @@ -782,9 +712,7 @@ def _run_contracts(session: nox.Session, reporter: SessionReporter, *args: str) ) reporter.run( "contracts / scientific-scenario completeness", - lambda: _run_project_python( - session, "tools/check_scientific_scenario_completeness.py" - ), + lambda: _run_project_python(session, "tools/check_scientific_scenario_completeness.py"), ) reporter.run( "contracts / reproducible related-work comparison", @@ -800,15 +728,11 @@ def _run_contracts(session: nox.Session, reporter: SessionReporter, *args: str) ) reporter.run( "contracts / formal semantic-validation evidence", - lambda: _run_project_python( - session, "tools/check_formal_semantic_validation.py" - ), + lambda: _run_project_python(session, "tools/check_formal_semantic_validation.py"), ) reporter.run( "contracts / json artifact validation", - lambda: _run_project_python( - session, "tools/check_json_artifacts.py", *json_artifact_args - ), + lambda: _run_project_python(session, "tools/check_json_artifacts.py", *json_artifact_args), ) reporter.run( "contracts / ATT&CK tactic vocabulary conformance", @@ -820,26 +744,18 @@ def _run_contracts(session: nox.Session, reporter: SessionReporter, *args: str) ) reporter.run( "contracts / NIST CSF defensive vocabulary conformance", - lambda: _run_project_python( - session, "tools/check_nist_csf_defensive_vocabulary.py" - ), + lambda: _run_project_python(session, "tools/check_nist_csf_defensive_vocabulary.py"), ) reporter.run( "contracts / autonomous behavior vocabulary conformance", - lambda: _run_project_python( - session, "tools/check_autonomous_behavior_vocabularies.py" - ), + lambda: _run_project_python(session, "tools/check_autonomous_behavior_vocabularies.py"), ) -def _run_participant_opacity_proof( - session: nox.Session, reporter: SessionReporter -) -> None: +def _run_participant_opacity_proof(session: nox.Session, reporter: SessionReporter) -> None: reporter.run( "formal proof / participant opacity", - lambda: _run_project_python( - session, "tools/check_participant_opacity_proof.py" - ), + lambda: _run_project_python(session, "tools/check_participant_opacity_proof.py"), detail="Isabelle2025-2 :: offline kernel replay", ) @@ -863,9 +779,7 @@ def _run_lint(session: nox.Session, reporter: SessionReporter) -> None: ) -def _run_changed_lint( - session: nox.Session, reporter: SessionReporter, paths: list[str] -) -> None: +def _run_changed_lint(session: nox.Session, reporter: SessionReporter, paths: list[str]) -> None: prefix = "implementations/python/" project_paths = [] for path in paths: @@ -874,9 +788,7 @@ def _run_changed_lint( if project_paths: reporter.run( "lint / ruff format (changed project files)", - lambda: _run_ruff( - session, "format", "--check", *project_paths, project_relative=True - ), + lambda: _run_ruff(session, "format", "--check", *project_paths, project_relative=True), detail=f"{len(project_paths)} files", ) reporter.run( @@ -895,9 +807,7 @@ def _run_changed_lint( ) tooling_paths = [ - path - for path in paths - if (path.startswith("tools/") or path == "noxfile.py") and path.endswith(".py") + path for path in paths if (path.startswith("tools/") or path == "noxfile.py") and path.endswith(".py") ] if tooling_paths: reporter.run( @@ -931,9 +841,7 @@ def _run_tests( ) -> None: args = list(posargs) if posargs else ["-q"] parallel = not posargs - execution = ( - "xdist auto, max 8, worksteal" if parallel else "explicit selection, serial" - ) + execution = "xdist auto, max 8, worksteal" if parallel else "explicit selection, serial" reporter.run( "tests / pytest", lambda: _run_pytest( @@ -951,9 +859,7 @@ def _run_python_compatibility(session: nox.Session, reporter: SessionReporter) - expected = os.environ.get(EXPECTED_PYTHON_ENV, "") selector = os.environ.get("UV_PYTHON", "") if expected not in {"3.11", "3.12", "3.13", "3.14"}: - raise RuntimeError( - f"{EXPECTED_PYTHON_ENV} must select a supported feature release" - ) + raise RuntimeError(f"{EXPECTED_PYTHON_ENV} must select a supported feature release") if not selector: raise RuntimeError("UV_PYTHON must select the interpreter under test") expect_free_threaded = os.environ.get(EXPECT_FREE_THREADED_ENV) == "1" @@ -1005,9 +911,7 @@ def _run_python_compatibility(session: nox.Session, reporter: SessionReporter) - detail="xdist auto, max 8, worksteal", ) - with tempfile.TemporaryDirectory( - prefix="raes-python-compatibility-" - ) as temporary_dir: + with tempfile.TemporaryDirectory(prefix="raes-python-compatibility-") as temporary_dir: root = Path(temporary_dir) dist_dir = root / "dist" environment_dir = root / "installed" @@ -1028,9 +932,7 @@ def _run_python_compatibility(session: nox.Session, reporter: SessionReporter) - wheels = sorted(dist_dir.glob("raes-*.whl")) source_distributions = sorted(dist_dir.glob("raes-*.tar.gz")) if len(wheels) != 1 or len(source_distributions) != 1: - raise RuntimeError( - "compatibility build must produce exactly one wheel and one source distribution" - ) + raise RuntimeError("compatibility build must produce exactly one wheel and one source distribution") reporter.run( "python compatibility / create clean environment", @@ -1141,9 +1043,7 @@ def _enforce_line_coverage(report_path: Path) -> float: covered_lines = totals["covered_lines"] statements = totals["num_statements"] except (OSError, UnicodeError, json.JSONDecodeError, KeyError, TypeError) as exc: - raise RuntimeError( - f"could not read line coverage totals from {report_path}" - ) from exc + raise RuntimeError(f"could not read line coverage totals from {report_path}") from exc if ( not isinstance(covered_lines, int) or isinstance(covered_lines, bool) @@ -1156,15 +1056,11 @@ def _enforce_line_coverage(report_path: Path) -> float: raise RuntimeError(f"invalid line coverage totals in {report_path}") percent = 100.0 if statements == 0 else 100.0 * covered_lines / statements if percent + 1e-12 < MINIMUM_LINE_COVERAGE_PERCENT: - raise RuntimeError( - f"line coverage {percent:.3f}% is below required {MINIMUM_LINE_COVERAGE_PERCENT:.3f}%" - ) + raise RuntimeError(f"line coverage {percent:.3f}% is below required {MINIMUM_LINE_COVERAGE_PERCENT:.3f}%") return percent -def _write_and_check_coverage( - session: nox.Session, coverage_env: dict[str, str] -) -> None: +def _write_and_check_coverage(session: nox.Session, coverage_env: dict[str, str]) -> None: _run( session, "uv", @@ -1218,9 +1114,7 @@ def _finalize_parallel_coverage(session: nox.Session, coverage_dir: Path) -> Non _write_and_check_coverage(session, coverage_env) -def _run_docker_integration_tests( - session: nox.Session, reporter: SessionReporter -) -> None: +def _run_docker_integration_tests(session: nox.Session, reporter: SessionReporter) -> None: reporter.run( "tests / pytest docker integration", lambda: _run_pytest(session, "-m", "docker", "-v"), @@ -1231,17 +1125,13 @@ def _run_osv_scan(_session: nox.Session, reporter: SessionReporter) -> None: def _scan() -> None: lockfile = OSV_LOCKFILE_PATH if not lockfile.exists(): - raise RuntimeError( - f"osv-scan: tracked lockfile not found: {lockfile.relative_to(REPO_ROOT)}" - ) + raise RuntimeError(f"osv-scan: tracked lockfile not found: {lockfile.relative_to(REPO_ROOT)}") binary = ensure_osv_scanner(REPO_ROOT) exit_code = run_osv_scanner(lockfile, OSV_REPORT_PATH, binary=binary) report_rel = OSV_REPORT_PATH.relative_to(REPO_ROOT) outcome = classify_osv_exit_code(exit_code) if outcome is OSVScanOutcome.FINDINGS: - raise RuntimeError( - f"osv-scanner reported vulnerabilities (exit code {exit_code}); see {report_rel}" - ) + raise RuntimeError(f"osv-scanner reported vulnerabilities (exit code {exit_code}); see {report_rel}") if outcome is OSVScanOutcome.SCANNER_ERROR: raise RuntimeError( f"osv-scanner failed with scanner/setup error exit code {exit_code}; report at {report_rel}" @@ -1525,9 +1415,7 @@ def osv_scan(session: nox.Session) -> None: @nox.session(name="hook-pre-commit") def hook_pre_commit(session: nox.Session) -> None: reporter = SessionReporter(session, "hook-pre-commit") - changed = [ - Path(arg).as_posix() for arg in session.posargs if not arg.startswith("-") - ] + changed = [Path(arg).as_posix() for arg in session.posargs if not arg.startswith("-")] changed_tests = select_changed_python_tests(changed) try: _run_hygiene(session, reporter, posargs=changed, default_all_files=False) @@ -1536,12 +1424,8 @@ def hook_pre_commit(session: nox.Session) -> None: if _paths_trigger(changed, CONTRACT_TRIGGER_PREFIXES): _run_contracts(session, reporter) else: - reporter.skip( - "contracts / generated schema drift", "no contract-bearing changes" - ) - reporter.skip( - "contracts / json artifact validation", "no contract-bearing changes" - ) + reporter.skip("contracts / generated schema drift", "no contract-bearing changes") + reporter.skip("contracts / json artifact validation", "no contract-bearing changes") if changed_tests: reporter.run( "tests / directly changed pytest modules", @@ -1595,15 +1479,11 @@ def _run_changed_verification( base_rev = _changed_base_rev(posargs) changes = collect_git_changes(REPO_ROOT, base_rev) plan = plan_for_changes(changes) - session.log( - f"change-aware verification against {base_rev}: {plan.reason}; {len(changes)} change records" - ) + session.log(f"change-aware verification against {base_rev}: {plan.reason}; {len(changes)} change records") except (RuntimeError, ValueError) as exc: base_rev = None plan = plan_for_changes([]) - session.log( - f"change classification failed closed to the full local gate: {exc}" - ) + session.log(f"change classification failed closed to the full local gate: {exc}") base_policy_args = ["--base-rev", base_rev] if base_rev is not None else [] policy_args = _requirement_aware_policy_args(*base_policy_args) @@ -1643,9 +1523,7 @@ def verify_changed(session: nox.Session) -> None: def _required_coverage_file() -> Path: value = os.environ.get(VERIFY_COVERAGE_FILE_ENV) if not value: - raise RuntimeError( - f"{VERIFY_COVERAGE_FILE_ENV} is required for an orchestrated coverage lane" - ) + raise RuntimeError(f"{VERIFY_COVERAGE_FILE_ENV} is required for an orchestrated coverage lane") return Path(value) @@ -1722,9 +1600,7 @@ def _verification_lanes( env={ VERIFY_COVERAGE_FILE_ENV: str(coverage_dir / ".coverage.unit"), "PYTEST_ADDOPTS": f"-o cache_dir={coverage_dir / 'pytest-unit'}", - "PYTEST_XDIST_AUTO_NUM_WORKERS": str( - max(1, min(8, available_cpus // 2)) - ), + "PYTEST_XDIST_AUTO_NUM_WORKERS": str(max(1, min(8, available_cpus // 2))), }, ), VerificationLane( @@ -1828,14 +1704,10 @@ def _execute_lanes() -> None: f"{'PASS' if result.returncode == 0 else 'FAIL'} ({result.duration_s:.2f}s)" ) if result.output: - print( - result.output, end="" if result.output.endswith("\n") else "\n" - ) + print(result.output, end="" if result.output.endswith("\n") else "\n") failures = [result for result in results if result.returncode != 0] if failures: - failed = ", ".join( - f"{result.name} (exit {result.returncode})" for result in failures - ) + failed = ", ".join(f"{result.name} (exit {result.returncode})" for result in failures) raise RuntimeError(f"parallel verification lanes failed: {failed}") reporter.run(