mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:27:16 +01:00
dep_freshness: Python's latest is python.org's newest release, not the interpreter
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file length / file-length (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file length / file-length (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A4VrLZn9YjhPuoxTj54WKk
This commit is contained in:
parent
9733a29a65
commit
08a4010a16
9
.github/workflows/dependency-freshness.yml
vendored
9
.github/workflows/dependency-freshness.yml
vendored
@ -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:
|
||||
|
||||
@ -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 -----------------------------------------------------
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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))
|
||||
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)
|
||||
|
||||
@ -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:
|
||||
|
||||
110
dep_freshness/tests/test_python_toolchain.py
Normal file
110
dep_freshness/tests/test_python_toolchain.py
Normal file
@ -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
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user