diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87f382b7..20633865 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] @@ -96,6 +96,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 00000000..80766b97 --- /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 0dfc895f..a7208fcf 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 b1378c2f..c996f499 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/adrs/adr-014-nox-as-canonical-verification-graph.md b/docs/decisions/adrs/adr-014-nox-as-canonical-verification-graph.md index 044f8f7b..d10cdec1 100644 --- a/docs/decisions/adrs/adr-014-nox-as-canonical-verification-graph.md +++ b/docs/decisions/adrs/adr-014-nox-as-canonical-verification-graph.md @@ -57,6 +57,8 @@ a session with explicit substages, run sequentially through a - `contracts` — generated-schema drift, JSON-artifact validation - `tests` — pytest with coverage - `fuzz` — pytest with `-m fuzz` +- `python-compatibility` — exact-interpreter tests plus clean distribution + build, installation, import, and CLI checks for one supported CPython release - `docs` — sphinx-build (added by AUT-805) - `verify` — composes hygiene + policy + lint + contracts + tests + docs - `hook-pre-commit` — staged-file hygiene + policy + scoped lint + @@ -65,10 +67,12 @@ a session with explicit substages, run sequentially through a - `hook-pre-push` — full hygiene + policy + lint + contracts + tests + fuzz -`verify` is the canonical "passed locally" state. CI invokes +`verify` is the canonical single-interpreter "passed locally" state. CI invokes `nox -s verify`; the pre-push hook invokes `nox -s hook-pre-push`; the -pre-commit hook invokes `nox -s hook-pre-commit`. All three resolve -their work through the same per-gate helpers (`_run_hygiene`, +pre-commit hook invokes `nox -s hook-pre-commit`. CI additionally invokes +`python-compatibility` once for every supported CPython feature release because +a single local interpreter cannot establish the distribution's cross-version +support claim. All invocations resolve through the same per-gate helpers (`_run_hygiene`, `_run_policy`, `_run_lint`, `_run_contracts`, `_run_tests`, `_run_fuzz`, `_run_docs`). @@ -91,10 +95,14 @@ when pre-commit and CI maintained their own command lists. ### 3. CI consumes the same graph `.github/workflows/ci.yml` runs `uv tool run --from 'nox[uv]==…' nox --s verify` for the blocking gate, plus `nox -s fuzz` as a separate job -(fuzz is excluded from `verify` because it is property-based and slow, -not because it is optional). SonarCloud consumes the coverage XML that -the `tests` substage produces; it is not a parallel test-runner. +-s verify` for the canonical single-interpreter gate, plus `nox -s fuzz` as a +separate job (fuzz is excluded from `verify` because it is property-based and +slow, not because it is optional). It also runs `nox -s python-compatibility` +as a required-check matrix over every standard CPython feature release named +by package metadata. Those jobs assert the selected runtime, use the frozen +dependency graph, run the hermetic suite, and verify a clean installed +distribution. SonarCloud consumes the coverage XML that the `tests` substage +produces; it is not a parallel test-runner. The `.ground-control.yaml` workflow block declares each command nox exposes: @@ -216,3 +224,4 @@ local hooks, CI, and ground-control automation. | Date | Commit/PR | Summary | |---|---|---| | 2026-07-31 | #963 | Replaced the serial `verify` composition with six isolated, CPU-budgeted concurrent nox lanes, primed shared policy tooling before cold-cache lanes, batched JSON artifacts by shared schema with bounded concurrency, combined unit and integration coverage deterministically, scoped pre-commit to staged changes and directly changed tests without duplicating the mandatory full pre-push/completion regression, and separated network-dependent external-link validation into dedicated docs CI. Ground Control's completion half omits policy because its mechanically enforced policy half runs immediately afterward; direct `verify` and CI retain policy. | +| 2026-08-14 | #1134 | Added a required CI matrix that runs the canonical `python-compatibility` session for each supported CPython feature release while retaining `verify` as the reproducible single-interpreter local gate. | diff --git a/docs/decisions/adrs/adr-index.yaml b/docs/decisions/adrs/adr-index.yaml index 9b47fc56..18251e5f 100644 --- a/docs/decisions/adrs/adr-index.yaml +++ b/docs/decisions/adrs/adr-index.yaml @@ -67,11 +67,14 @@ adrs: summary: "Superseded §6's no-SDL-episode-syntax non-goal for the joint episode+budget design (SEM-222/DSL-120/ACT-623, #122): DSL-120/ACT-623 add an authored episode-structure surface (init, turn structure, terminal/truncation conditions, reset policy) as intent compiling to the existing ParticipantEpisode* contracts; runtime identity, state/reason/action separation, and the schema-first boundary are unchanged." - id: ADR-014 path: docs/decisions/adrs/adr-014-nox-as-canonical-verification-graph.md - pin: 52b3bf9dfbf128658b0edc88e164114529e3e225db0360ea99e005ebc97f86bf + pin: bd8a22d378a04d61eb57ea855b99848035e9aba563339dc22a078cbd99c40b63 amendments: - date: 2026-07-31 ref: "#963" summary: Parallelized deterministic verification and contract batches, scoped pre-commit without duplicating full regression, and moved external links to dedicated docs CI. + - date: 2026-08-14 + ref: "#1134" + summary: Added a required cross-interpreter CI matrix while retaining verify as the reproducible single-interpreter local gate. - id: ADR-015 path: docs/decisions/adrs/adr-015-sdl-processor-layering-and-source-file-size-cap.md pin: bc5038164e535c4d506962e27cee07e720f5c8a77cb17d018dedb3dba927936f 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 00000000..436fb2c7 --- /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 cfb044f3..a39cb00d 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 aec2515e..57ba9cbd 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 5f4ba06c..54653911 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 725a443e..9bdbaafa 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 9fab6f27..f651b687 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 96e2dcc2..3693a769 100644 --- a/implementations/python/tests/test_repo_policy_tools.py +++ b/implementations/python/tests/test_repo_policy_tools.py @@ -316,6 +316,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_coverage_configuration_measures_branches_and_repository_python() -> None: config = tomllib.loads((REPO_ROOT / "implementations" / "python" / "pyproject.toml").read_text(encoding="utf-8")) run = config["tool"]["coverage"]["run"] @@ -587,6 +724,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 34e92002..033e4e44 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 6c20da72..18e9471c 100644 --- a/noxfile.py +++ b/noxfile.py @@ -95,6 +95,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 @@ -277,6 +279,13 @@ def _run_pytest( _write_and_check_coverage(session, coverage_env) +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] = [] @@ -289,12 +298,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) @@ -333,7 +343,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 @@ -341,6 +351,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) @@ -844,6 +855,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", @@ -1150,6 +1313,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") diff --git a/tools/policy/historical_identity_records.json b/tools/policy/historical_identity_records.json index 78a6e501..120af436 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",