#!/usr/bin/env python3 # /// script # requires-python = ">=3.11" # dependencies = [ # "zstandard==0.22.0", # ] # /// # # Download required Python versions and install to `bin` # Uses prebuilt Python distributions from indygreg/python-build-standalone # # This script can be run without Python installed via `install.sh` # # Requirements # # pip install zstandard==0.22.0 # # Usage # # python scripts/bootstrap/install.py # # Or # # pipx run scripts/bootstrap/install.py # # The Python versions are installed from `.python_versions`. # Python versions are linked in-order such that the _last_ defined version will be the default. # # Version metadata can be updated with `fetch-version-metadata.py` import concurrent.futures import hashlib import json import os import platform import shutil import sys import sysconfig import tarfile import tempfile import urllib.parse import urllib.request from pathlib import Path try: import zstandard except ImportError: print("ERROR: zstandard is required; install with `pip install zstandard==0.22.0`") sys.exit(1) # Setup some file paths THIS_DIR = Path(__file__).parent ROOT_DIR = THIS_DIR.parent.parent if bin_dir := os.environ.get("UV_BOOTSTRAP_DIR"): BIN_DIR = Path(bin_dir) else: BIN_DIR = ROOT_DIR / "bin" INSTALL_DIR = BIN_DIR / "versions" VERSIONS_FILE = ROOT_DIR / ".python-versions" VERSIONS_METADATA_FILE = THIS_DIR / "versions.json" # Map system information to those in the versions metadata ARCH_MAP = {"aarch64": "arm64", "amd64": "x86_64"} PLATFORM_MAP = {"win32": "windows"} PLATFORM = sys.platform ARCH = platform.machine().lower() INTERPRETER = "cpython" def decompress_file(archive_path: Path, output_path: Path): if str(archive_path).endswith(".tar.zst"): dctx = zstandard.ZstdDecompressor() with tempfile.TemporaryFile(suffix=".tar") as ofh: with archive_path.open("rb") as ifh: dctx.copy_stream(ifh, ofh) ofh.seek(0) with tarfile.open(fileobj=ofh) as z: z.extractall(output_path) else: raise ValueError(f"Unknown archive type {archive_path.suffix}") def sha256_file(path: Path): h = hashlib.sha256() with open(path, "rb") as file: while True: # Reading is buffered, so we can read smaller chunks. chunk = file.read(h.block_size) if not chunk: break h.update(chunk) return h.hexdigest() versions_metadata = json.loads(VERSIONS_METADATA_FILE.read_text()) versions = VERSIONS_FILE.read_text().splitlines() def get_key(version): if platform.system() == "Linux": libc = sysconfig.get_config_var("SOABI").split("-")[-1] else: libc = "none" key = f"{INTERPRETER}-{version}-{PLATFORM_MAP.get(PLATFORM, PLATFORM)}-{ARCH_MAP.get(ARCH, ARCH)}-{libc}" return key def download(version): key = get_key(version) install_dir = INSTALL_DIR / f"{INTERPRETER}@{version}" print(f"Downloading {key}") url = versions_metadata[key]["url"] if not url: print(f"No matching download for {key}") sys.exit(1) filename = url.split("/")[-1] print(f"Downloading {urllib.parse.unquote(filename)}") download_path = THIS_DIR / filename with urllib.request.urlopen(url) as response: with download_path.open("wb") as download_file: shutil.copyfileobj(response, download_file) sha = versions_metadata[key]["sha256"] if not sha: print(f"WARNING: no checksum for {key}") else: print("Verifying checksum...", end="") if sha256_file(download_path) != sha: print(" FAILED!") sys.exit(1) print(" OK") if install_dir.exists(): shutil.rmtree(install_dir) print("Extracting to", install_dir) install_dir.parent.mkdir(parents=True, exist_ok=True) # n.b. do not use `.with_suffix` as it will replace the patch Python version extract_dir = Path(str(install_dir) + ".tmp") decompress_file(THIS_DIR / filename, extract_dir) (extract_dir / "python").rename(install_dir) (THIS_DIR / filename).unlink() extract_dir.rmdir() return install_dir def install(version, install_dir): key = get_key(version) if PLATFORM == "win32": executable = install_dir / "install" / "python.exe" else: # Use relative paths for links so if the bin is moved they don't break executable = ( "." / install_dir.relative_to(BIN_DIR) / "install" / "bin" / "python3" ) major = versions_metadata[key]["major"] minor = versions_metadata[key]["minor"] # Link as all version tuples, later versions in the file will take precedence BIN_DIR.mkdir(parents=True, exist_ok=True) targets = [ (BIN_DIR / f"python{version}"), (BIN_DIR / f"python{major}.{minor}"), (BIN_DIR / f"python{major}"), (BIN_DIR / "python"), ] for target in targets: target.unlink(missing_ok=True) if PLATFORM == "win32": target.hardlink_to(executable) else: target.symlink_to(executable) print(f"Installed executables for python{version}") if __name__ == "__main__": if INSTALL_DIR.exists(): print("Removing existing installations...") shutil.rmtree(INSTALL_DIR) # Download in parallel with concurrent.futures.ProcessPoolExecutor(max_workers=len(versions)) as executor: futures = [ (version, executor.submit(download, version)) for version in versions ] # Install sequentially so overrides are respected for version, future in futures: install_dir = future.result() install(version, install_dir) print("Done!")