mirror of
				https://github.com/astral-sh/uv.git
				synced 2025-11-04 13:38:59 +00:00 
			
		
		
		
	Moves all of `uv-toolchain` into `uv-interpreter`. We may split these out in the future, but the refactoring I want to do for interpreter discovery is easier if I don't have to deal with entanglement. Includes some restructuring of `uv-interpreter`. Part of #2386
		
			
				
	
	
		
			292 lines
		
	
	
	
		
			7.7 KiB
		
	
	
	
		
			Python
		
	
	
		
			Executable file
		
	
	
	
	
			
		
		
	
	
			292 lines
		
	
	
	
		
			7.7 KiB
		
	
	
	
		
			Python
		
	
	
		
			Executable file
		
	
	
	
	
#!/usr/bin/env python3.12
 | 
						|
"""
 | 
						|
Fetch Python version metadata.
 | 
						|
 | 
						|
Generates the `python-version-metadata.json` file.
 | 
						|
 | 
						|
Usage:
 | 
						|
 | 
						|
    python fetch-version-metadata.py
 | 
						|
 | 
						|
Acknowledgements:
 | 
						|
 | 
						|
    Derived from https://github.com/mitsuhiko/rye/tree/f9822267a7f00332d15be8551f89a212e7bc9017
 | 
						|
    Originally authored by Armin Ronacher under the MIT license
 | 
						|
"""
 | 
						|
 | 
						|
import argparse
 | 
						|
import hashlib
 | 
						|
import json
 | 
						|
import logging
 | 
						|
import re
 | 
						|
import urllib.error
 | 
						|
import urllib.request
 | 
						|
from itertools import chain
 | 
						|
from pathlib import Path
 | 
						|
from urllib.parse import unquote
 | 
						|
 | 
						|
SELF_DIR = Path(__file__).parent
 | 
						|
RELEASE_URL = "https://api.github.com/repos/indygreg/python-build-standalone/releases"
 | 
						|
HEADERS = {
 | 
						|
    "X-GitHub-Api-Version": "2022-11-28",
 | 
						|
}
 | 
						|
VERSIONS_FILE = SELF_DIR / "python-version-metadata.json"
 | 
						|
FLAVOR_PREFERENCES = [
 | 
						|
    "shared-pgo",
 | 
						|
    "shared-noopt",
 | 
						|
    "shared-noopt",
 | 
						|
    "static-noopt",
 | 
						|
    "gnu-pgo+lto",
 | 
						|
    "gnu-lto",
 | 
						|
    "gnu-pgo",
 | 
						|
    "pgo+lto",
 | 
						|
    "lto",
 | 
						|
    "pgo",
 | 
						|
]
 | 
						|
HIDDEN_FLAVORS = [
 | 
						|
    "debug",
 | 
						|
    "noopt",
 | 
						|
    "install_only",
 | 
						|
]
 | 
						|
SPECIAL_TRIPLES = {
 | 
						|
    "macos": "x86_64-apple-darwin",
 | 
						|
    "linux64": "x86_64-unknown-linux-gnu",
 | 
						|
    "windows-amd64": "x86_64-pc-windows",
 | 
						|
    "windows-x86": "i686-pc-windows",
 | 
						|
    "windows-amd64-shared": "x86_64-pc-windows",
 | 
						|
    "windows-x86-shared": "i686-pc-windows",
 | 
						|
    "linux64-musl": "x86_64-unknown-linux-musl",
 | 
						|
}
 | 
						|
 | 
						|
_filename_re = re.compile(
 | 
						|
    r"""(?x)
 | 
						|
    ^
 | 
						|
        cpython-(?P<ver>\d+\.\d+\.\d+?)
 | 
						|
        (?:\+\d+)?
 | 
						|
        -(?P<triple>.*?)
 | 
						|
        (?:-[\dT]+)?\.tar\.(?:gz|zst)
 | 
						|
    $
 | 
						|
"""
 | 
						|
)
 | 
						|
_suffix_re = re.compile(
 | 
						|
    r"""(?x)^(.*?)-(%s)$"""
 | 
						|
    % (
 | 
						|
        "|".join(
 | 
						|
            map(
 | 
						|
                re.escape,
 | 
						|
                sorted(FLAVOR_PREFERENCES + HIDDEN_FLAVORS, key=len, reverse=True),
 | 
						|
            )
 | 
						|
        )
 | 
						|
    )
 | 
						|
)
 | 
						|
 | 
						|
# Normalized mappings to match the Rust types
 | 
						|
ARCH_MAP = {
 | 
						|
    "ppc64": "powerpc64",
 | 
						|
    "ppc64le": "powerpc64le",
 | 
						|
    "i686": "x86",
 | 
						|
    "i386": "x86",
 | 
						|
}
 | 
						|
OS_MAP = {"darwin": "macos"}
 | 
						|
 | 
						|
 | 
						|
def parse_filename(filename):
 | 
						|
    match = _filename_re.match(filename)
 | 
						|
    if match is None:
 | 
						|
        return
 | 
						|
    version, triple = match.groups()
 | 
						|
    if triple.endswith("-full"):
 | 
						|
        triple = triple[:-5]
 | 
						|
    match = _suffix_re.match(triple)
 | 
						|
    if match is not None:
 | 
						|
        triple, suffix = match.groups()
 | 
						|
    else:
 | 
						|
        suffix = None
 | 
						|
    return (version, triple, suffix)
 | 
						|
 | 
						|
 | 
						|
def normalize_triple(triple):
 | 
						|
    if "-static" in triple:
 | 
						|
        logging.debug("Skipping %r: unknown triple", triple)
 | 
						|
        return
 | 
						|
    triple = SPECIAL_TRIPLES.get(triple, triple)
 | 
						|
    pieces = triple.split("-")
 | 
						|
    try:
 | 
						|
        arch = normalize_arch(pieces[0])
 | 
						|
        operating_system = normalize_os(pieces[2])
 | 
						|
        if pieces[2] == "linux":
 | 
						|
            # On linux, the triple has four segments, the last one is the libc
 | 
						|
            libc = pieces[3]
 | 
						|
        else:
 | 
						|
            libc = "none"
 | 
						|
    except IndexError:
 | 
						|
        logging.debug("Skipping %r: unknown triple", triple)
 | 
						|
        return
 | 
						|
    return "%s-%s-%s" % (arch, operating_system, libc)
 | 
						|
 | 
						|
 | 
						|
def normalize_arch(arch):
 | 
						|
    arch = ARCH_MAP.get(arch, arch)
 | 
						|
    pieces = arch.split("_")
 | 
						|
    # Strip `_vN` from `x86_64`
 | 
						|
    return "_".join(pieces[:2])
 | 
						|
 | 
						|
 | 
						|
def normalize_os(os):
 | 
						|
    return OS_MAP.get(os, os)
 | 
						|
 | 
						|
 | 
						|
def read_sha256(url):
 | 
						|
    try:
 | 
						|
        resp = urllib.request.urlopen(url + ".sha256")
 | 
						|
    except urllib.error.HTTPError:
 | 
						|
        return None
 | 
						|
    assert resp.status == 200
 | 
						|
    return resp.read().decode().strip()
 | 
						|
 | 
						|
 | 
						|
def sha256(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()
 | 
						|
 | 
						|
 | 
						|
def _sort_by_flavor_preference(info):
 | 
						|
    _triple, flavor, _url = info
 | 
						|
    try:
 | 
						|
        pref = FLAVOR_PREFERENCES.index(flavor)
 | 
						|
    except ValueError:
 | 
						|
        pref = len(FLAVOR_PREFERENCES) + 1
 | 
						|
    return pref
 | 
						|
 | 
						|
 | 
						|
def _sort_by_interpreter_and_version(info):
 | 
						|
    interpreter, version_tuple, _ = info
 | 
						|
    return (interpreter, version_tuple)
 | 
						|
 | 
						|
 | 
						|
def find():
 | 
						|
    """
 | 
						|
    Find available Python versions and write metadata to a file.
 | 
						|
    """
 | 
						|
    results = {}
 | 
						|
 | 
						|
    # Collect all available Python downloads
 | 
						|
    for page in range(1, 100):
 | 
						|
        logging.debug("Reading release page %s...", page)
 | 
						|
        resp = urllib.request.urlopen("%s?page=%d" % (RELEASE_URL, page))
 | 
						|
        rows = json.loads(resp.read())
 | 
						|
        if not rows:
 | 
						|
            break
 | 
						|
        for row in rows:
 | 
						|
            for asset in row["assets"]:
 | 
						|
                url = asset["browser_download_url"]
 | 
						|
                base_name = unquote(url.rsplit("/")[-1])
 | 
						|
                if base_name.endswith(".sha256"):
 | 
						|
                    continue
 | 
						|
                info = parse_filename(base_name)
 | 
						|
                if info is None:
 | 
						|
                    continue
 | 
						|
                py_ver, triple, flavor = info
 | 
						|
                if "-static" in triple or (flavor and "noopt" in flavor):
 | 
						|
                    continue
 | 
						|
                triple = normalize_triple(triple)
 | 
						|
                if triple is None:
 | 
						|
                    continue
 | 
						|
                results.setdefault(py_ver, []).append((triple, flavor, url))
 | 
						|
 | 
						|
    # Collapse CPython variants to a single URL flavor per triple
 | 
						|
    cpython_results: dict[tuple[int, int, int], dict[tuple[str, str, str], str]] = {}
 | 
						|
    for py_ver, choices in results.items():
 | 
						|
        urls = {}
 | 
						|
        for triple, flavor, url in sorted(choices, key=_sort_by_flavor_preference):
 | 
						|
            triple = tuple(triple.split("-"))
 | 
						|
            # Skip existing triples, preferring the first flavor
 | 
						|
            if triple in urls:
 | 
						|
                continue
 | 
						|
            urls[triple] = url
 | 
						|
        cpython_results[tuple(map(int, py_ver.split(".")))] = urls
 | 
						|
 | 
						|
    # Collect variants across interpreter kinds
 | 
						|
    # TODO(zanieb): Note we only support CPython downloads at this time
 | 
						|
    #               but this will include PyPy chain in the future.
 | 
						|
    final_results = {}
 | 
						|
    for interpreter, py_ver, choices in sorted(
 | 
						|
        chain(
 | 
						|
            (("cpython",) + x for x in cpython_results.items()),
 | 
						|
        ),
 | 
						|
        key=_sort_by_interpreter_and_version,
 | 
						|
        # Reverse the ordering so newer versions are first
 | 
						|
        reverse=True,
 | 
						|
    ):
 | 
						|
        # Sort by the remaining information for determinism
 | 
						|
        # This groups download metadata in triple component order
 | 
						|
        for (arch, operating_system, libc), url in sorted(choices.items()):
 | 
						|
            key = "%s-%s.%s.%s-%s-%s-%s" % (
 | 
						|
                interpreter,
 | 
						|
                *py_ver,
 | 
						|
                operating_system,
 | 
						|
                arch,
 | 
						|
                libc,
 | 
						|
            )
 | 
						|
            logging.info("Found %s", key)
 | 
						|
            sha256 = read_sha256(url)
 | 
						|
 | 
						|
            final_results[key] = {
 | 
						|
                "name": interpreter,
 | 
						|
                "arch": arch,
 | 
						|
                "os": operating_system,
 | 
						|
                "libc": libc,
 | 
						|
                "major": py_ver[0],
 | 
						|
                "minor": py_ver[1],
 | 
						|
                "patch": py_ver[2],
 | 
						|
                "url": url,
 | 
						|
                "sha256": sha256,
 | 
						|
            }
 | 
						|
 | 
						|
    VERSIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
 | 
						|
    VERSIONS_FILE.write_text(json.dumps(final_results, indent=2))
 | 
						|
 | 
						|
 | 
						|
def main():
 | 
						|
    parser = argparse.ArgumentParser(description="Fetch Python version metadata.")
 | 
						|
    parser.add_argument(
 | 
						|
        "-v",
 | 
						|
        "--verbose",
 | 
						|
        action="store_true",
 | 
						|
        help="Enable debug logging",
 | 
						|
    )
 | 
						|
    parser.add_argument(
 | 
						|
        "-q",
 | 
						|
        "--quiet",
 | 
						|
        action="store_true",
 | 
						|
        help="Disable logging",
 | 
						|
    )
 | 
						|
    args = parser.parse_args()
 | 
						|
 | 
						|
    if args.quiet:
 | 
						|
        log_level = logging.CRITICAL
 | 
						|
    elif args.verbose:
 | 
						|
        log_level = logging.DEBUG
 | 
						|
    else:
 | 
						|
        log_level = logging.INFO
 | 
						|
 | 
						|
    logging.basicConfig(
 | 
						|
        level=log_level,
 | 
						|
        format="%(asctime)s %(levelname)s %(message)s",
 | 
						|
        datefmt="%Y-%m-%d %H:%M:%S",
 | 
						|
    )
 | 
						|
 | 
						|
    find()
 | 
						|
 | 
						|
 | 
						|
if __name__ == "__main__":
 | 
						|
    main()
 |