From e9bf6d97c2aa1e4c4a3d03b1bedb44a225ce31e9 Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 10 Sep 2026 02:40:54 -0400 Subject: [PATCH] fix(pm): select the Python identity advertised by its asset The resolver combined the locked patch with a newer release tag. That combination did not name the archive advertised by the release. Return the complete identity from an exact matching filename instead. Keep the locked minor and reject free-threaded or mismatched-tag assets. The URL builder supplies its existing target mapping to the resolver. Bionic remains a manual source. No version pins change in this commit. Verified: 47 tests passed. The real package caller reconstructs each accepted asset name, with negative controls for lookalikes and tag skew. A fresh upstream metadata snapshot agrees with all six PBS target URLs. No interpreter artifacts were downloaded or installed. --- pm/package.py | 2 +- pm/packages.py | 10 ++--- pm/update.py | 30 ++++---------- tests/pm/test_python_release_identity.py | 52 ++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 29 deletions(-) create mode 100644 tests/pm/test_python_release_identity.py diff --git a/pm/package.py b/pm/package.py index d57f7b759f..69cea6b3fb 100644 --- a/pm/package.py +++ b/pm/package.py @@ -93,7 +93,7 @@ class Package: update driver intersects across targets (per version_style) and only ever pins a version every relevant target can serve. ``locked`` is the current lockfile version when the resolver needs it (python - keeps its 3.11 line and bumps only the +).""" + keeps its minor line and takes patch/build identity from the asset).""" return [] diff --git a/pm/packages.py b/pm/packages.py index 7e28f0ca81..ee736ef5cb 100644 --- a/pm/packages.py +++ b/pm/packages.py @@ -30,7 +30,7 @@ from pm.update import ( martin_riedl_versions, node_latest_versions, npm_dist_tags, - pbs_build_tags, + pbs_versions, ) _RUST_TRIPLE = { @@ -282,15 +282,13 @@ class Python(_BionicDebArm, BinaryPackage, DebPackage): ) def latest_versions(self, target: str, locked=None) -> list[str]: - # Stay on the locked python minor line (3.14); bump only the - # +. A major/minor bump is a deliberate decision, never - # an auto-update. The bionic row is a manual termux-main pin -- no - # python-build-standalone build exists for it, so leave it locked. + # Keep the locked minor line. The advertised asset owns patch and build. + # Bionic remains a manual pin from a separate supplier. if target == "linux-arm64-bionic" or not locked or "+" not in locked: return [] pyver = locked.partition("+")[0] minor = ".".join(pyver.split(".")[:2]) - return [f"{pyver}+{tag}" for tag in pbs_build_tags(minor, target)] + return pbs_versions(minor, _RUST_TRIPLE[target]) def _uv_lock_digest(path: Path) -> bytes: diff --git a/pm/update.py b/pm/update.py index aaeb738043..26320f2adc 100644 --- a/pm/update.py +++ b/pm/update.py @@ -407,21 +407,8 @@ def btbn_versions(target: str) -> list[str]: return list(btbn_index().get(target, {})) -def pbs_build_tags(minor: str, target: str) -> list[str]: - """python-build-standalone release tags (20260814, ...) newest-first that - actually ship the requested cpython install_only asset for - `target`. The release list carries assets + digests, so one fetch per - page is enough — scan newest-first and stop at the first tag with the - asset (pbs builds every supported line per release).""" - triple = { - "win32-x64": "x86_64-pc-windows-msvc", - "win32-arm64": "aarch64-pc-windows-msvc", - "linux-x64": "x86_64-unknown-linux-gnu", - "linux-arm64": "aarch64-unknown-linux-gnu", - "darwin-x64": "x86_64-apple-darwin", - "darwin-arm64": "aarch64-apple-darwin", - }[target] - wanted = f"cpython-{minor}." +def pbs_versions(minor: str, triple: str) -> list[str]: + """Return the exact interpreter identity advertised by the newest matching release.""" for page in range(1, 3): data = _get_json(f"https://api.github.com/repos/astral-sh/python-build-standalone/releases?per_page=30&page={page}") if not data: @@ -430,14 +417,13 @@ def pbs_build_tags(minor: str, target: str) -> list[str]: if release.get("draft") or release.get("prerelease"): continue tag = release.get("tag_name", "") - if not re.fullmatch(r"\d{8}", tag): + if not re.fullmatch(r"[0-9]{8}", tag): continue - if any( - a["name"].startswith(wanted) - and a["name"].endswith(f"-{triple}-install_only.tar.gz") - for a in release.get("assets", []) - ): - return [tag] + pattern = rf"cpython-({re.escape(minor)}\.[0-9]+\+{tag})-{re.escape(triple)}-install_only\.tar\.gz" + for asset in release.get("assets", []): + match = re.fullmatch(pattern, asset.get("name", "")) + if match: + return [match.group(1)] if len(data) < 30: break return [] diff --git a/tests/pm/test_python_release_identity.py b/tests/pm/test_python_release_identity.py new file mode 100644 index 0000000000..796fbf846f --- /dev/null +++ b/tests/pm/test_python_release_identity.py @@ -0,0 +1,52 @@ +"""Automatic Python candidates reconstruct only filenames the release advertises.""" +from urllib.parse import unquote, urlparse + +import pytest + +from pm import packages, update + + +@pytest.mark.parametrize('target,triple', [ + ('linux-x64', 'x86_64-unknown-linux-gnu'), + ('win32-arm64', 'aarch64-pc-windows-msvc'), + ('darwin-arm64', 'aarch64-apple-darwin'), +]) +def test_python_candidate_preserves_the_advertised_patch_and_build(monkeypatch, target, triple): + tag = '20990102' + identity = f'3.14.8+{tag}' + advertised = f'cpython-{identity}-{triple}-install_only.tar.gz' + release = {'tag_name': tag, 'assets': [{'name': name} for name in [ + f'cpython-3.15.0+{tag}-{triple}-install_only.tar.gz', + f'cpython-{identity}-{triple}-freethreaded-install_only.tar.gz', + f'cpython-{identity}-freethreaded-{triple}-install_only.tar.gz', + advertised, + ]]} + monkeypatch.setattr(update, '_get_json', lambda _url: [release]) + package = packages.Python() + result = package.latest_versions(target, locked='3.14.7+20981231') + assert result == [identity] + download = urlparse(package.fetch_url(result[0], target)) + assert unquote(download.path.rsplit('/', 1)[1]) == advertised + assert download.path.split('/')[-2] == tag + + +def test_python_candidates_reject_nonmatching_assets_and_leave_manual_targets_alone(monkeypatch): + tag = '20990102' + triple = 'x86_64-unknown-linux-gnu' + rejected = [ + f'cpython-3.14.8+20981231-{triple}-install_only.tar.gz', + f'cpython-3.15.0+{tag}-{triple}-install_only.tar.gz', + f'cpython-3.14.8+{tag}-{triple}-freethreaded-install_only.tar.gz', + f'cpython-3.14.8+{tag}-freethreaded-{triple}-install_only.tar.gz', + f'cpython-3.14.8+{tag}-{triple}-install_only.tar.gz.extra', + f'cpython-3.14.8+{tag}-{triple}-install_only.tar.gz\n', + ] + calls = [] + for name in rejected: + monkeypatch.setattr(update, '_get_json', lambda url, name=name: calls.append(url) or [ + {'tag_name': tag, 'assets': [{'name': name}]}, + ]) + assert packages.Python().latest_versions('linux-x64', locked='3.14.7+20981231') == [] + calls.clear() + assert packages.Python().latest_versions('linux-arm64-bionic', locked='3.14.7+20981231') == [] + assert calls == []