Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1652.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Set up standalone python fetching to use checksums directly from the GitHub API.
30 changes: 14 additions & 16 deletions src/pipx/standalone_python.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import urllib.error
from functools import partial
from pathlib import Path
from typing import Any, Dict, List
from typing import Any
from urllib.request import urlopen

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

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

full_version, download_link = resolve_python_version(python_version)
full_version, (download_link, digest) = resolve_python_version(python_version)

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

# unpack the python build
_unpack(full_version, download_link, archive, download_dir)
_unpack(full_version, download_link, archive, download_dir, digest)

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


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

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


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

return [asset["browser_download_url"] for asset in release_data["assets"]]
return [(asset["browser_download_url"], asset["digest"]) for asset in release_data["assets"]]


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

available_python_links = [
link
(link, digest)
# Suffixes are in order of preference.
for download_link_suffix in download_link_suffixes
for link in python_releases
for link, digest in python_releases
if link.endswith(download_link_suffix)
]

python_versions: dict[str, str] = {}
for link in available_python_links:
python_versions: dict[str, tuple[str, str]] = {}
for link, digest in available_python_links:
match = PYTHON_VERSION_REGEX.search(link)
assert match is not None
python_version = match[1]
# Don't override already found versions, they are in order of preference
if python_version in python_versions:
continue

python_versions[python_version] = link
python_versions[python_version] = link, digest

return {
version: python_versions[version]
Expand Down
1 change: 0 additions & 1 deletion testdata/standalone_python_index_20250317.json

This file was deleted.

1 change: 0 additions & 1 deletion testdata/standalone_python_index_20250409.json

This file was deleted.

1 change: 1 addition & 0 deletions testdata/standalone_python_index_20250818.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions testdata/standalone_python_index_20250828.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def mocked_github_api(monkeypatch, root):
Fixture to replace the github index with a local copy,
to prevent unit tests from exceeding github's API request limit.
"""
with open(root / "testdata" / "standalone_python_index_20250317.json") as f:
with open(root / "testdata" / "standalone_python_index_20250818.json") as f:
index = json.load(f)
monkeypatch.setattr(standalone_python, "get_or_update_index", lambda _: index)

Expand Down
4 changes: 2 additions & 2 deletions tests/test_standalone_interpreter.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ def test_prune_unused_standalone_interpreters(pipx_temp_env, monkeypatch, mocked
def test_upgrade_standalone_interpreter(pipx_temp_env, root, monkeypatch, capsys):
monkeypatch.setattr(shutil, "which", mock_which)

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

Expand All @@ -126,7 +126,7 @@ def test_upgrade_standalone_interpreter(pipx_temp_env, root, monkeypatch, capsys
]
)

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

Expand Down
Loading