Files
cibuildwheel/bin/update_virtualenv.py
T
Henry SchreinerCopilotcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>henryiii
cd38ee1548 feat: add SHA256 verification for direct downloads (#2873)
* feat: add SHA256 verification for direct downloads

Store SHA256 hashes when running update scripts and verify them
when downloading files at build time. This improves security by
detecting unexpected changes to downloaded artifacts.

Platforms covered: macOS (CPython, PyPy, GraalPy), iOS, Android,
virtualenv, and python-build-standalone. Windows (nuget) and
Linux (Docker) are excluded.

SHA256 sources per platform:
- macOS/iOS/Android CPython (python.org): sha256_sum from API
- GraalPy: .sha256 sidecar assets from GitHub releases
- python-build-standalone: SHA256SUMS file in release
- PyPy, BeeWare iOS, Maven (Chaquopy): stream-download and compute

Changes:
- cibuildwheel/util/file.py: add sha256 param to download()
- cibuildwheel/platforms/{macos,ios,android}.py: add sha256 to
  PythonConfiguration and pass to download()
- cibuildwheel/venv.py: read sha256 from toml and pass to download()
- cibuildwheel/util/python_build_standalone.py: add sha256 to
  PythonBuildStandaloneAsset and pass to download()
- cibuildwheel/resources/build-platforms.toml: add sha256 fields
- cibuildwheel/resources/virtualenv.toml: add sha256 field
- cibuildwheel/resources/python-build-standalone-releases.json: add sha256
- bin/update_pythons.py: compute/store sha256 per source strategy
- bin/update_virtualenv.py: compute sha256 by streaming download
- bin/update_python_build_standalone.py: parse SHA256SUMS file

Closes #908

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Assisted-by: copilot-cli:claude-sonnet-4.6

* fix: populate sha256 in resource files and fix Windows PythonConfiguration

- Add sha256 field to Windows PythonConfiguration (PyPy/GraalPy have
  direct download URLs on Windows too)
- Pass sha256 to install_pypy() and install_graalpy() in windows.py
- Fix update_pythons.py: handle empty sha256 from CPython API (older
  versions) by streaming download to compute it; fix condition to
  check 'not sha256' rather than 'not in dict'
- Fix update_virtualenv.py: compute sha256 even when version unchanged
  but sha256 is empty (first-time population)
- Fix update_python_build_standalone.py: resolve file path relative to
  the script itself (not the installed package) so writes go to source
  checkout, not the uv cache
- Populate actual sha256 values by running all three update scripts

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Assisted-by: copilot-cli:claude-sonnet-4.6

* fix: also include pyodide

Assisted-by: CopilotCLI:gpt-5.3-codex
Signed-off-by: Henry Schreiner <henryfs@princeton.edu>

* fix: PR review comments for cache verification and docs wording

Co-authored-by: henryiii <4616906+henryiii@users.noreply.github.com>
Signed-off-by: Henry Schreiner <henryfs@princeton.edu>

* fix: require sha256 for download configs

Require sha256 for URL-backed Python and virtualenv download configs. Update the GraalPy updater to refresh macOS x86_64 entries by selecting the latest release that still has a matching asset, and fill the two missing GraalPy checksums in build-platforms.toml.

Assisted-by: CopilotCLI:gpt-5.4

* ci: remove unit test for bin item

Signed-off-by: Henry Schreiner <henryfs@princeton.edu>

* refactor: combine sha256 unit tests into test_sha256.py

Merge pyodide_test.py and python_build_standalone_test.py into a
single unit_test/test_sha256.py since both test sha256-related
behaviour.

Assisted-by: opencode:glm-5

---------

Signed-off-by: Henry Schreiner <henryfs@princeton.edu>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: henryiii <4616906+henryiii@users.noreply.github.com>
2026-05-29 15:02:33 -04:00

153 lines
4.2 KiB
Python
Executable File

#!/usr/bin/env -S uv run --script
# /// script
# dependencies = [
# "click",
# "packaging",
# "requests",
# "rich",
# "cibuildwheel",
# ]
#
# [tool.uv.sources]
# cibuildwheel = { path = ".." }
# ///
import dataclasses
import difflib
import hashlib
import logging
import tomllib
from pathlib import Path
from typing import Final
import click
import requests
import rich
from packaging.version import Version
from rich.logging import RichHandler
from rich.syntax import Syntax
from cibuildwheel.extra import github_api_request
log = logging.getLogger("cibw")
# Looking up the dir instead of using utils.resources_dir
# since we want to write to it.
DIR: Final[Path] = Path(__file__).parent.parent.resolve()
RESOURCES_DIR: Final[Path] = DIR / "cibuildwheel/resources"
GET_VIRTUALENV_GITHUB: Final[str] = "https://github.com/pypa/get-virtualenv"
GET_VIRTUALENV_URL_TEMPLATE: Final[str] = (
f"{GET_VIRTUALENV_GITHUB}/blob/{{version}}/public/virtualenv.pyz?raw=true"
)
@dataclasses.dataclass(frozen=True, order=True)
class VersionTuple:
name: str
download_url: str
version: Version
def get_latest_virtualenv_release() -> VersionTuple:
response = github_api_request("repos/pypa/get-virtualenv/releases/latest")
tag_name = response["tag_name"]
asset = next(
(asset for asset in response["assets"] if asset["name"] == "virtualenv.pyz"),
None,
)
if not asset:
msg = "No asset named 'virtualenv.pyz' found in the latest release of get-virtualenv."
raise RuntimeError(msg)
return VersionTuple(
version=Version(tag_name), name=tag_name, download_url=asset["browser_download_url"]
)
@click.command()
@click.option("--force", is_flag=True)
@click.option(
"--level", default="INFO", type=click.Choice(["WARNING", "INFO", "DEBUG"], case_sensitive=False)
)
def update_virtualenv(force: bool, level: str) -> None:
logging.basicConfig(
level="INFO",
format="%(message)s",
datefmt="[%X]",
handlers=[RichHandler(rich_tracebacks=True, markup=True)],
)
log.setLevel(level)
toml_file_path = RESOURCES_DIR / "virtualenv.toml"
original_toml = toml_file_path.read_text()
with toml_file_path.open("rb") as f:
configurations = tomllib.load(f)
default = configurations.pop("default")
local_version = str(default["version"])
latest_release = get_latest_virtualenv_release()
if latest_release.version > Version(local_version):
version = latest_release.name
url = latest_release.download_url
sha256 = "" # recomputed below
else:
version = local_version
url = default["url"]
sha256 = default["sha256"]
# Compute sha256 if not already stored (new version or first-time population)
if not sha256:
log.info("Computing sha256 for %s...", url)
response = requests.get(url, stream=True)
response.raise_for_status()
hasher = hashlib.sha256()
for chunk in response.iter_content(65536):
hasher.update(chunk)
sha256 = hasher.hexdigest()
configurations["default"] = {
"version": version,
"url": url,
"sha256": sha256,
}
result_toml = "".join(
f'{key} = {{ version = "{value["version"]}", url = "{value["url"]}", sha256 = "{value["sha256"]}" }}\n'
for key, value in configurations.items()
)
rich.print() # spacer
if original_toml == result_toml:
rich.print("[green]Check complete, virtualenv version unchanged.")
return
rich.print("virtualenv version updated.")
rich.print("Changes:")
rich.print()
toml_relpath = toml_file_path.relative_to(DIR).as_posix()
diff_lines = difflib.unified_diff(
original_toml.splitlines(keepends=True),
result_toml.splitlines(keepends=True),
fromfile=toml_relpath,
tofile=toml_relpath,
)
rich.print(Syntax("".join(diff_lines), "diff", theme="ansi_light"))
rich.print()
if force:
toml_file_path.write_text(result_toml)
rich.print("[green]TOML file updated.")
else:
rich.print("[yellow]File left unchanged. Use --force flag to update.")
if __name__ == "__main__":
update_virtualenv()