From 08a4010a167353f311bef14ebecf612c8f708d8c Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Tue, 20 Oct 2026 22:39:25 +0200 Subject: [PATCH] dep_freshness: Python's latest is python.org's newest release, not the interpreter toolchain:python's "latest" was platform.python_version(), so the verdict depended on the machine running the gate: setup-python "3.x" gave kuhyx/spellcode 3.15.0 on one runner and 3.14.8 on another, making its transitive:pyodide allowlist entry dead (exit 2) on one and required (exit 1) on the other. Now registries/toolchain.python_latest() reads python.org's release API (is_published, pre_release=false), keeps only exact "Python X.Y.Z" names (the feed also lists "Python install manager 26.4"), and takes the newest stable. The never_cached() carve-out is gone: it existed because a cached *interpreter* version from an older runner image (3.13.15) was served to a 3.14.7 job in screen-locker's CI. A registry answer is safe to cache, so python now goes through the normal TTL cache, prefetch, and offline fallback (expired cache -> DEGRADED; no cache -> unavailable), never the local interpreter. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A4VrLZn9YjhPuoxTj54WKk --- .github/workflows/dependency-freshness.yml | 9 +- dep_freshness/_tables.py | 11 +- dep_freshness/registries/toolchain.py | 34 ++++-- dep_freshness/resolve.py | 23 +--- dep_freshness/templates/workflow.yml | 9 +- dep_freshness/tests/test_python_toolchain.py | 110 ++++++++++++++++++ dep_freshness/tests/test_resolve.py | 30 ----- .../tests/test_toolchain_registry.py | 5 - 8 files changed, 155 insertions(+), 76 deletions(-) create mode 100644 dep_freshness/tests/test_python_toolchain.py diff --git a/.github/workflows/dependency-freshness.yml b/.github/workflows/dependency-freshness.yml index 23a0a73..eb83ff9 100644 --- a/.github/workflows/dependency-freshness.yml +++ b/.github/workflows/dependency-freshness.yml @@ -37,11 +37,10 @@ jobs: repository: kuhyx/utils path: .utils - # "3.x", never a pinned minor: the Python target IS the interpreter the - # gate runs under (registries/toolchain.python_installed), so a runner - # held a release behind this machine reports a repo's `requires-python` - # as excluding "latest" while the same check passes locally. flax-docs-mcp - # hit exactly that -- `>=3.14` green here, red on a 3.13 runner. + # Any recent Python runs the gate. The Python TARGET is python.org's + # newest release (registries/toolchain.python_latest), not this + # interpreter: when it was, "3.x" resolved to 3.15.0 on one runner and + # 3.14.8 on the next, and spellcode's allowlist flipped dead/required. - name: Set up Python uses: actions/setup-python@v5 with: diff --git a/dep_freshness/_tables.py b/dep_freshness/_tables.py index 9e0fea0..48786c8 100644 --- a/dep_freshness/_tables.py +++ b/dep_freshness/_tables.py @@ -104,9 +104,14 @@ MAX_WORKERS: Final = 8 # against LTS, so the gate targets LTS. One-line switch to "current". NODE_CHANNEL: Final = "lts" -# The interpreter is pacman-managed here. A gate satisfiable only by fighting -# the distro gets disabled, so Python is compared to what is INSTALLED. -PYTHON_SOURCE: Final = "installed" +# Python is a registry answer like every other toolchain (kuhy, 2026-10-20). +# It used to be the interpreter running the gate, which made the verdict +# depend on the machine: `setup-python` "3.x" gave spellcode 3.15.0 on one +# runner and 3.14.8 on the next. See registries/toolchain.py for the source. +PYTHON_RELEASES: Final = ( + "https://www.python.org/api/v2/downloads/release/" + "?is_published=true&pre_release=false" +) # --- Manifest discovery ----------------------------------------------------- diff --git a/dep_freshness/registries/toolchain.py b/dep_freshness/registries/toolchain.py index bcf211e..db35c59 100644 --- a/dep_freshness/registries/toolchain.py +++ b/dep_freshness/registries/toolchain.py @@ -1,19 +1,24 @@ -"""Latest stable Flutter/Dart, Node and Python, for the toolchain checks (Q3). +"""Latest stable Flutter/Dart, Node, Python and Gradle, for the toolchain checks (Q3). -Python is deliberately compared against the INSTALLED interpreter rather than -python.org: on Arch it is pacman-managed, and a gate that can only be satisfied -by fighting the distro gets switched off. +Python comes from python.org's release API, NOT the interpreter running the +gate. That used to be the target, and it made the verdict a property of the +machine: `setup-python` "3.x" resolved to 3.15.0 on one runner and 3.14.8 on +another, so spellcode's `transitive:` allowlist entry was dead on one and +required on the other (2026-10-20). python.org is first-party, filters +pre-releases server-side, and answers in one request; endoflife.date is a +third party, and cpython's git tags would mean walking every rc tag. """ from __future__ import annotations -import platform +import re from dep_freshness._tables import ( FLUTTER_RELEASES, GRADLE_RELEASES, NODE_CHANNEL, NODE_RELEASES, + PYTHON_RELEASES, ) from dep_freshness.registries.http import get_json from dep_freshness.versions import newest_stable @@ -47,9 +52,22 @@ def node_latest(channel: str = NODE_CHANNEL) -> str | None: return newest_stable(candidates) -def python_installed() -> str: - """The interpreter this gate is running under — the Python target.""" - return platform.python_version() +# Exactly "Python X.Y.Z": the feed also lists "Python install manager 26.4" +# (the Windows launcher), which read as a version would be Python 26. +_CPYTHON_RELEASE = re.compile(r"Python (\d+\.\d+\.\d+)") + + +def python_latest() -> str | None: + """Newest final CPython release on python.org.""" + payload = get_json(PYTHON_RELEASES) + if not payload: + return None + candidates = [] + for release in payload: + match = _CPYTHON_RELEASE.fullmatch(release.get("name") or "") + if match and not release.get("pre_release"): + candidates.append(match.group(1)) + return newest_stable(candidates) def gradle_latest() -> str | None: diff --git a/dep_freshness/resolve.py b/dep_freshness/resolve.py index 5ec74c6..1a44580 100644 --- a/dep_freshness/resolve.py +++ b/dep_freshness/resolve.py @@ -66,24 +66,12 @@ def _toolchain_latest(name: str) -> str | None: if name == "node": return tc.node_latest() if name == "python": - return tc.python_installed() + return tc.python_latest() if name == "gradle": return tc.gradle_latest() return None -def never_cached(ecosystem: str, name: str) -> bool: - """True for answers that describe THIS process, not a remote registry. - - `toolchain:python` is `platform.python_version()`: free to compute, and - different on every machine. Caching it made screen-locker's CI red on - 2026-08-28 -- the `actions/cache` restore-key handed a job running 3.14.7 - a 3.13.15 answer written by an older runner image, and the gate declared - the repo's own `requires-python = ">=3.14"` to exclude "latest". - """ - return ecosystem == TOOLCHAIN and name == "python" - - def _fetch(ecosystem: str, package: str) -> str | None: if ecosystem == TOOLCHAIN: return _toolchain_latest(package) @@ -105,11 +93,8 @@ class Resolver: wanted = { (d.ecosystem, d.name) for d in deps - if not never_cached(d.ecosystem, d.name) - and ( - not (entry := self.cache.get(d.ecosystem, d.name)) - or not entry.fresh(d.ecosystem) - ) + if not (entry := self.cache.get(d.ecosystem, d.name)) + or not entry.fresh(d.ecosystem) } if not wanted: return @@ -130,8 +115,6 @@ class Resolver: self.cache.save() def latest(self, dep: Dep) -> Answer: - if never_cached(dep.ecosystem, dep.name): - return Answer(_fetch(dep.ecosystem, dep.name)) entry = self.cache.get(dep.ecosystem, dep.name) if entry and entry.fresh(dep.ecosystem): return Answer(entry.version) diff --git a/dep_freshness/templates/workflow.yml b/dep_freshness/templates/workflow.yml index bc145ee..29b0c62 100644 --- a/dep_freshness/templates/workflow.yml +++ b/dep_freshness/templates/workflow.yml @@ -37,11 +37,10 @@ jobs: repository: kuhyx/utils path: .utils - # "3.x", never a pinned minor: the Python target IS the interpreter the - # gate runs under (registries/toolchain.python_installed), so a runner - # held a release behind this machine reports a repo's `requires-python` - # as excluding "latest" while the same check passes locally. flax-docs-mcp - # hit exactly that -- `>=3.14` green here, red on a 3.13 runner. + # Any recent Python runs the gate. The Python TARGET is python.org's + # newest release (registries/toolchain.python_latest), not this + # interpreter: when it was, "3.x" resolved to 3.15.0 on one runner and + # 3.14.8 on the next, and spellcode's allowlist flipped dead/required. - name: Set up Python uses: actions/setup-python@v5 with: diff --git a/dep_freshness/tests/test_python_toolchain.py b/dep_freshness/tests/test_python_toolchain.py new file mode 100644 index 0000000..612c0de --- /dev/null +++ b/dep_freshness/tests/test_python_toolchain.py @@ -0,0 +1,110 @@ +"""`toolchain:python` is a registry answer, never the interpreter running the gate. + +kuhyx/spellcode went red on one runner and green on another: `setup-python` +with "3.x" handed one job 3.15.0 and the next 3.14.8, so a `transitive:` +allowlist entry was dead (exit 2) on one machine and required (exit 1) on the +other. The target must not depend on where the gate happens to run. +""" + +from __future__ import annotations + +import platform +from pathlib import Path + +import pytest + +from dep_freshness.models import Dep +from dep_freshness.registries import http +from dep_freshness.registries import toolchain as tc +from dep_freshness.resolve import Resolver, _toolchain_latest + +# Shaped like python.org's /api/v2/downloads/release/ rows, trimmed. +RELEASES = [ + {"name": "Python 2.7.18", "pre_release": False}, + {"name": "Python 3.14.8", "pre_release": False}, + {"name": "Python 3.15.0", "pre_release": False}, + # The Windows install manager is listed as a "release" with version 100; + # read naively it is Python 26.4. + {"name": "Python install manager 26.4", "pre_release": False}, + {"name": "Python 3.16.0a1", "pre_release": True}, + {"name": "Python 3.16.0", "pre_release": True}, # flagged, so not final +] + + +@pytest.fixture +def feed(monkeypatch): + def install(payload): + monkeypatch.setattr(tc, "get_json", lambda *_a, **_k: payload) + + return install + + +def python_dep() -> Dep: + return Dep( + ecosystem="toolchain", name="python", constraint="3.14.8", + path=Path(".python-version"), line=1, + ) + + +@pytest.fixture +def no_interpreter(monkeypatch): + """Any read of the running interpreter's version is a failure.""" + monkeypatch.setattr( + platform, + "python_version", + lambda: pytest.fail("the target must not come from this interpreter"), + ) + + +@pytest.mark.parametrize("interpreter", ["3.14.8", "3.15.0"]) +def test_latest_is_the_same_whatever_interpreter_runs_the_gate( + feed, monkeypatch, interpreter +): + feed(RELEASES) + monkeypatch.setattr(platform, "python_version", lambda: interpreter) + assert _toolchain_latest("python") == "3.15.0" + + +def test_the_registry_answer_skips_pre_releases_and_the_install_manager(feed): + feed(RELEASES) + assert tc.python_latest() == "3.15.0" + + +def test_a_missing_feed_is_unknown(feed): + feed(None) + assert tc.python_latest() is None + + +def test_rows_without_a_name_are_ignored(feed): + feed([{"pre_release": False}, {"name": "Python 3.14.8"}]) + assert tc.python_latest() == "3.14.8" + + +@pytest.fixture +def resolver(tmp_path): + from dep_freshness.cache import Cache + + return Resolver(Cache(tmp_path / "registry.json")) + + +def test_the_answer_is_cached_like_any_registry(resolver, feed, no_interpreter): + feed(RELEASES) + resolver.prefetch([python_dep()]) + entry = resolver.cache.get("toolchain", "python") + assert entry is not None and entry.version == "3.15.0" + + +def test_offline_serves_the_cached_answer_as_degraded(resolver, no_interpreter): + resolver.cache.put("toolchain", "python", "3.15.0") + resolver.cache._data["toolchain/python"]["fetched_at"] = 0.0 # long expired + http.force_offline() + answer = resolver.latest(python_dep()) + assert answer.version == "3.15.0" + assert answer.stale_cache_days is not None + + +def test_offline_with_no_cache_is_unavailable(resolver, no_interpreter): + http.force_offline() + answer = resolver.latest(python_dep()) + assert answer.version is None + assert answer.unavailable diff --git a/dep_freshness/tests/test_resolve.py b/dep_freshness/tests/test_resolve.py index 0d79a47..556626e 100644 --- a/dep_freshness/tests/test_resolve.py +++ b/dep_freshness/tests/test_resolve.py @@ -85,30 +85,6 @@ def test_refresh_discards_the_existing_cache(cache_dir, monkeypatch): assert resolver.latest(dep()).version == "1.6.0" -def test_the_python_target_ignores_the_cache_entirely(resolver, monkeypatch): - """screen-locker went red on a cached interpreter from another machine. - - `actions/cache` restored a `dep-freshness-` entry written by an older - runner image, so a job that `setup-python` had put on 3.14.7 was told the - "current toolchain" was 3.13.15 -- and the repo's own - `requires-python = ">=3.14"` was reported as excluding latest. The answer - describes this process and costs nothing, so it is never cached. - """ - resolver.cache.put("toolchain", "python", "3.13.15") - monkeypatch.setattr(tc, "python_installed", lambda: "3.14.7") - assert resolver.latest(dep("toolchain", "python")).version == "3.14.7" - - -def test_prefetch_does_not_warm_the_python_target(resolver, monkeypatch): - monkeypatch.setattr( - http, "get_json", lambda *a, **k: pytest.fail("no lookup is needed") - ) - monkeypatch.setattr( - tc, "python_installed", lambda: pytest.fail("prefetch must skip python") - ) - resolver.prefetch([dep("toolchain", "python")]) - - def test_git_tags_get_a_longer_ttl_than_registries(): assert ttl_for("gittag") > ttl_for("pub") @@ -153,12 +129,6 @@ def test_toolchain_routing(monkeypatch, name, expected): assert _toolchain_latest(name) == expected -def test_python_targets_the_installed_interpreter(): - """Never python.org: on Arch the interpreter is pacman-managed.""" - import platform - assert _toolchain_latest("python") == platform.python_version() - - def test_an_unknown_toolchain_has_no_target(): assert _toolchain_latest("go") is None diff --git a/dep_freshness/tests/test_toolchain_registry.py b/dep_freshness/tests/test_toolchain_registry.py index 3768958..d472180 100644 --- a/dep_freshness/tests/test_toolchain_registry.py +++ b/dep_freshness/tests/test_toolchain_registry.py @@ -60,11 +60,6 @@ def test_node_with_no_feed_is_unknown(feed): assert tc.node_latest() is None -def test_python_reports_the_running_interpreter(): - import platform - assert tc.python_installed() == platform.python_version() - - LS_REMOTE = ( "aaa\trefs/tags/crdt_sync_dart-v0.10.0\n" "bbb\trefs/tags/crdt_sync_dart-v0.11.0\n"