Skip to content

Commit f2acbd1

Browse files
committed
fix #1652: Use GitHub API for standalone python checksums
Since the [20250708](https://github.com/astral-sh/python-build-standalone/releases/tag/20250708) release, the [python-build-standalone](https://github.com/astral-sh/python-build-standalone) project does not provide checksum files for individual artifacts, but rather a single `SHA256SUMS` manifest file containing checksums for all artifacts. However, since June 3, the GitHub API [directly provides checksums for release artifacts](https://github.blog/changelog/2025-06-03-releases-now-expose-digests-for-release-assets/). Because we always fetch the latest release and it's been over a month since the last cached index file, we can just save the checksums in out cached index of Python versions.
1 parent 849d3f4 commit f2acbd1

8 files changed

+20
-21
lines changed

changelog.d/1652.bugfix.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Set up standalone python fetching to use checksums directly from the GitHub API.

src/pipx/standalone_python.py

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import urllib.error
1111
from functools import partial
1212
from pathlib import Path
13-
from typing import Any, Dict, List
13+
from typing import Any
1414
from urllib.request import urlopen
1515

1616
from pipx import constants, paths
@@ -22,7 +22,7 @@
2222
# Much of the code in this module is adapted with extreme gratitude from
2323
# https://github.com/tusharsadhwani/yen/blob/main/src/yen/github.py
2424

25-
MACHINE_SUFFIX: Dict[str, Dict[str, Any]] = {
25+
MACHINE_SUFFIX: dict[str, dict[str, Any]] = {
2626
"Darwin": {
2727
"arm64": ["aarch64-apple-darwin-install_only.tar.gz"],
2828
"x86_64": ["x86_64-apple-darwin-install_only.tar.gz"],
@@ -71,7 +71,7 @@ def download_python_build_standalone(python_version: str, override: bool = False
7171
logger.warning(f"A previous attempt to install python {python_version} failed. Retrying.")
7272
shutil.rmtree(install_dir)
7373

74-
full_version, download_link = resolve_python_version(python_version)
74+
full_version, (download_link, digest) = resolve_python_version(python_version)
7575

7676
with tempfile.TemporaryDirectory() as tempdir:
7777
archive = Path(tempdir) / f"python-{full_version}.tar.gz"
@@ -81,7 +81,7 @@ def download_python_build_standalone(python_version: str, override: bool = False
8181
_download(full_version, download_link, archive)
8282

8383
# unpack the python build
84-
_unpack(full_version, download_link, archive, download_dir)
84+
_unpack(full_version, download_link, archive, download_dir, digest)
8585

8686
# the python installation we want is nested in the tarball
8787
# under a directory named 'python'. We move it to the install
@@ -104,15 +104,13 @@ def _download(full_version: str, download_link: str, archive: Path):
104104
raise PipxError(f"Unable to download python {full_version} build.") from e
105105

106106

107-
def _unpack(full_version, download_link, archive: Path, download_dir: Path):
107+
def _unpack(full_version, download_link, archive: Path, download_dir: Path, expected_checksum: str):
108108
with animate(f"Unpacking python {full_version} build", True):
109109
# Calculate checksum
110110
with open(archive, "rb") as python_zip:
111-
checksum = hashlib.sha256(python_zip.read()).hexdigest()
111+
checksum = "sha256:" + hashlib.sha256(python_zip.read()).hexdigest()
112112

113113
# Validate checksum
114-
checksum_link = download_link + ".sha256"
115-
expected_checksum = urlopen(checksum_link).read().decode().rstrip("\n")
116114
if checksum != expected_checksum:
117115
raise PipxError(
118116
f"Checksum mismatch for python {full_version} build. Expected {expected_checksum}, got {checksum}."
@@ -142,7 +140,7 @@ def get_or_update_index(use_cache: bool = True):
142140
return index
143141

144142

145-
def get_latest_python_releases() -> List[str]:
143+
def get_latest_python_releases() -> list[tuple[str, str]]:
146144
"""Returns the list of python download links from the latest github release."""
147145
try:
148146
with urlopen(GITHUB_API_URL) as response:
@@ -152,10 +150,10 @@ def get_latest_python_releases() -> List[str]:
152150
# raise
153151
raise PipxError(f"Unable to fetch python-build-standalone release data (from {GITHUB_API_URL}).") from e
154152

155-
return [asset["browser_download_url"] for asset in release_data["assets"]]
153+
return [(asset["browser_download_url"], asset["digest"]) for asset in release_data["assets"]]
156154

157155

158-
def list_pythons(use_cache: bool = True) -> Dict[str, str]:
156+
def list_pythons(use_cache: bool = True) -> dict[str, tuple[str, str]]:
159157
"""Returns available python versions for your machine and their download links."""
160158
system, machine = platform.system(), platform.machine()
161159
download_link_suffixes = MACHINE_SUFFIX[system][machine]
@@ -168,23 +166,23 @@ def list_pythons(use_cache: bool = True) -> Dict[str, str]:
168166
python_releases = get_or_update_index(use_cache)["releases"]
169167

170168
available_python_links = [
171-
link
169+
(link, digest)
172170
# Suffixes are in order of preference.
173171
for download_link_suffix in download_link_suffixes
174-
for link in python_releases
172+
for link, digest in python_releases
175173
if link.endswith(download_link_suffix)
176174
]
177175

178-
python_versions: dict[str, str] = {}
179-
for link in available_python_links:
176+
python_versions: dict[str, tuple[str, str]] = {}
177+
for link, digest in available_python_links:
180178
match = PYTHON_VERSION_REGEX.search(link)
181179
assert match is not None
182180
python_version = match[1]
183181
# Don't override already found versions, they are in order of preference
184182
if python_version in python_versions:
185183
continue
186184

187-
python_versions[python_version] = link
185+
python_versions[python_version] = link, digest
188186

189187
return {
190188
version: python_versions[version]

testdata/standalone_python_index_20250317.json

Lines changed: 0 additions & 1 deletion
This file was deleted.

testdata/standalone_python_index_20250409.json

Lines changed: 0 additions & 1 deletion
This file was deleted.

testdata/standalone_python_index_20250818.json

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

testdata/standalone_python_index_20250828.json

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

tests/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def mocked_github_api(monkeypatch, root):
3131
Fixture to replace the github index with a local copy,
3232
to prevent unit tests from exceeding github's API request limit.
3333
"""
34-
with open(root / "testdata" / "standalone_python_index_20250317.json") as f:
34+
with open(root / "testdata" / "standalone_python_index_20250818.json") as f:
3535
index = json.load(f)
3636
monkeypatch.setattr(standalone_python, "get_or_update_index", lambda _: index)
3737

tests/test_standalone_interpreter.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ def test_prune_unused_standalone_interpreters(pipx_temp_env, monkeypatch, mocked
112112
def test_upgrade_standalone_interpreter(pipx_temp_env, root, monkeypatch, capsys):
113113
monkeypatch.setattr(shutil, "which", mock_which)
114114

115-
with open(root / "testdata" / "standalone_python_index_20250317.json") as f:
115+
with open(root / "testdata" / "standalone_python_index_20250818.json") as f:
116116
new_index = json.load(f)
117117
monkeypatch.setattr(standalone_python, "get_or_update_index", lambda _: new_index)
118118

@@ -126,7 +126,7 @@ def test_upgrade_standalone_interpreter(pipx_temp_env, root, monkeypatch, capsys
126126
]
127127
)
128128

129-
with open(root / "testdata" / "standalone_python_index_20250409.json") as f:
129+
with open(root / "testdata" / "standalone_python_index_20250828.json") as f:
130130
new_index = json.load(f)
131131
monkeypatch.setattr(standalone_python, "get_or_update_index", lambda _: new_index)
132132

0 commit comments

Comments
 (0)