diff --git a/.python-version b/.python-version new file mode 100644 index 00000000..e4fba218 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/backend/ruff.toml b/backend/ruff.toml index 9bb4cdf0..f69ce16c 100644 --- a/backend/ruff.toml +++ b/backend/ruff.toml @@ -7,6 +7,10 @@ # the repo wrap at the same width, and it's the width `ruff format` enforces (gated via # `ruff format --check` alongside `ruff check`). line-length = 100 +# The interpreter this code actually runs on, so ruff judges syntax and upgrade hints against the +# right one. Left unset it falls back to a default that has nothing to do with us. `/.python-version` +# is the single source of truth; tests/test_python_engine_contract.py holds this to it. +target-version = "py312" [lint] # Beyond the default set, the rules that hold a LOG LINE to what E22.5 makes of it: diff --git a/backend/tests/test_python_engine_contract.py b/backend/tests/test_python_engine_contract.py new file mode 100644 index 00000000..49a940aa --- /dev/null +++ b/backend/tests/test_python_engine_contract.py @@ -0,0 +1,66 @@ +"""The Python major is a contract, not a coincidence — the backend half (E27 S3). + +`0.93.2` was released to fix exactly this bug on the JavaScript side: four machines built or checked +the app and none of them agreed on which Node they ran, the server gate went red for 73 of its last +90 runs, and nothing told anyone. The Python side had the same shape and nobody had looked: prod +(`Dockerfile`), the test image (`Dockerfile.test`) and CI (`check.yml`) each spelled **3.12** as an +independent literal, there was no `.python-version`, no `requires-python`, and ruff had no +`target-version` — while the dev machine had no 3.12 installed at all, only 3.13 and 3.14. + +**The contract is a chain, and it is split across two test files because no single runner can see +every link.** This suite runs inside the test image, which mounts `backend/` and nothing else, so it +cannot read the repo-root Dockerfile or the workflow. It therefore holds the link it CAN see, and the +one that matters most here: + + the interpreter actually running == backend/pyrightconfig.json == backend/ruff.toml + +and `frontend/src/lib/pythonEngine.contract.test.ts` — which runs on the host, with the whole tree — +holds the rest of the chain: + + /.python-version == pyrightconfig.json == ruff.toml == Dockerfile == Dockerfile.test == check.yml + +Together they close the loop. To move the version: edit `/.python-version`, then follow the failures +in both files. Every place they name is a place that would otherwise have been forgotten. +""" + +import re +import sys +from pathlib import Path + +BACKEND = Path(__file__).resolve().parents[1] + + +def _version_in(path: Path, pattern: str) -> tuple[int, int]: + """The (major, minor) the given file declares. A rename or a moved line lands here — re-point + the test, the contract did not go away with it.""" + text = path.read_text(encoding="utf-8") + match = re.search(pattern, text, re.MULTILINE) + assert match is not None, f"{pattern!r} not found in {path.name}" + return int(match.group(1)), int(match.group(2)) + + +# pyrightconfig.json is JSONC (it carries comments), so it is read by regex rather than json.loads. +PINNED = _version_in(BACKEND / "pyrightconfig.json", r'"pythonVersion"\s*:\s*"(\d+)\.(\d+)"') + + +def test_pins_a_plausible_python_version(): + """Guards against a typo agreeing with nothing at all.""" + assert PINNED >= (3, 12), PINNED + + +def test_this_interpreter_is_the_pinned_one(): + """The assertion that pays. A green suite on another minor does not mean the shipped image is + green — it means the suite and the image have drifted and nobody noticed yet.""" + running = sys.version_info[:2] + assert running == PINNED, ( + f"these tests are running on Python {running[0]}.{running[1]}, but the app is pinned to " + f"{PINNED[0]}.{PINNED[1]} (backend/pyrightconfig.json, /.python-version). The suite runs in " + f"the test image, so this failing means backend/Dockerfile.test has drifted from the pin." + ) + + +def test_ruff_judges_the_pinned_version(): + """Unset, ruff falls back to a default that has nothing to do with us, and its syntax and + upgrade advice are then aimed at some other interpreter.""" + major, minor = _version_in(BACKEND / "ruff.toml", r'^target-version\s*=\s*"py(\d)(\d+)"') + assert (major, minor) == PINNED diff --git a/frontend/src/lib/pythonEngine.contract.test.ts b/frontend/src/lib/pythonEngine.contract.test.ts new file mode 100644 index 00000000..a0eb6829 --- /dev/null +++ b/frontend/src/lib/pythonEngine.contract.test.ts @@ -0,0 +1,86 @@ +import { readFileSync } from "node:fs"; +import { expect, it } from "vitest"; + +// The Python major is a contract, not a coincidence — the host half. The twin of +// `nodeEngine.contract.test.ts`, and written the day after that one was: `0.93.2` shipped because +// four machines disagreed about Node, and the Python side turned out to have the identical shape +// with nobody watching. Prod (`Dockerfile`), the test image (`backend/Dockerfile.test`) and CI +// (`check.yml`) each spelled 3.12 as its own literal, there was no `.python-version`, and the dev +// machine had only 3.13 and 3.14 installed — so every ad-hoc host script ran two majors ahead of +// production. +// +// It lives in the FRONTEND suite for one boring reason: this is the only runner that can see the +// whole repository. The backend suite runs inside the test image, which mounts `backend/` alone, so +// it cannot read the repo-root Dockerfile or the workflow. It holds the other half of the chain — +// including "the interpreter running these tests IS the pinned one", which only it can assert: +// +// backend/tests/test_python_engine_contract.py : running interpreter == pyrightconfig == ruff.toml +// here : .python-version == pyrightconfig == ruff.toml +// == Dockerfile == Dockerfile.test == check.yml +// +// To move the version: edit `.python-version`, then follow the failures in both files. +const readRoot = (rel: string) => readFileSync(new URL(`../../../${rel}`, import.meta.url), "utf8"); + +function versionIn(source: string, pattern: RegExp, where: string): string { + const m = pattern.exec(source); + // A rename or a moved line lands here. Re-point the test — the contract did not go away with it. + expect(m, `${pattern} not found in ${where}`).not.toBeNull(); + return `${m![1]}.${m![2]}`; +} + +const pinned = versionIn(readRoot(".python-version"), /^(\d+)\.(\d+)\s*$/m, ".python-version"); + +it("pins a plausible Python version", () => { + // Guards against a typo'd `.python-version` silently agreeing with nothing. + expect(Number(pinned.split(".")[1])).toBeGreaterThanOrEqual(12); + expect(pinned.split(".")[0]).toBe("3"); +}); + +it("runs the shipped backend on the pinned version", () => { + // The image our users' instance actually runs. + expect(versionIn(readRoot("Dockerfile"), /^FROM python:(\d+)\.(\d+)-slim/m, "Dockerfile")).toBe( + pinned, + ); +}); + +it("tests on the pinned version", () => { + // The image the whole pytest suite runs in — including the backend half of this contract, which + // asserts its own `sys.version_info` against the pin. + expect( + versionIn( + readRoot("backend/Dockerfile.test"), + /^FROM python:(\d+)\.(\d+)-slim/m, + "Dockerfile.test", + ), + ).toBe(pinned); +}); + +it("runs the server gate on the pinned version", () => { + // The backend job's container. Its NODE is deliberately outside the Node contract (it only runs + // the checkout action), but its PYTHON runs our code and our tests, so it is inside this one. + expect( + versionIn( + readRoot(".gitea/workflows/check.yml"), + /^\s*container: nikolaik\/python-nodejs:python(\d+)\.(\d+)-nodejs\d+$/m, + "check.yml", + ), + ).toBe(pinned); +}); + +it("type-checks against the pinned version", () => { + // A type checker aimed at another version green-lights APIs the shipped interpreter does not have + // — the same defect as the image split, moved to check time. + expect( + versionIn( + readRoot("backend/pyrightconfig.json"), + /"pythonVersion"\s*:\s*"(\d+)\.(\d+)"/, + "pyrightconfig.json", + ), + ).toBe(pinned); +}); + +it("lints against the pinned version", () => { + expect( + versionIn(readRoot("backend/ruff.toml"), /^target-version = "py(\d)(\d+)"$/m, "ruff.toml"), + ).toBe(pinned); +});