build: pin one Python version across the app, the images and the gate
0.93.2 shipped a week's worth of confusion about which Node this repo
runs on; the Python side had the identical 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
so it judged our syntax against some other interpreter. The dev machine
had no 3.12 installed at all — only 3.13 and 3.14 — so every ad-hoc host
script ran two majors ahead of production.
`.python-version` is now the single source of truth, and the contract is
held by tests rather than by prose. It is split in two because no single
runner can see every link: the pytest suite runs inside the test image,
which mounts backend/ alone and cannot read the repo-root Dockerfile or
the workflow.
test_python_engine_contract.py : running interpreter == pyrightconfig
== ruff.toml
pythonEngine.contract.test.ts : .python-version == pyrightconfig ==
ruff.toml == Dockerfile ==
Dockerfile.test == check.yml
Together they close the loop, and the backend half carries the assertion
that pays — that the interpreter running the suite is the pinned one. A
green suite on another minor does not mean the shipped image is green;
it means they have drifted and nobody has noticed yet.
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
3.12
|
||||||
@@ -7,6 +7,10 @@
|
|||||||
# the repo wrap at the same width, and it's the width `ruff format` enforces (gated via
|
# the repo wrap at the same width, and it's the width `ruff format` enforces (gated via
|
||||||
# `ruff format --check` alongside `ruff check`).
|
# `ruff format --check` alongside `ruff check`).
|
||||||
line-length = 100
|
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]
|
[lint]
|
||||||
# Beyond the default set, the rules that hold a LOG LINE to what E22.5 makes of it:
|
# Beyond the default set, the rules that hold a LOG LINE to what E22.5 makes of it:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user