diff --git a/docs/history/hatch.md b/docs/history/hatch.md index 2d3f96ba1..d5cdef45a 100644 --- a/docs/history/hatch.md +++ b/docs/history/hatch.md @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## Unreleased +***Added:*** + +- Add environment variables for mirroring managed CPython and PyPy distribution downloads. + ## [1.16.5](https://github.com/pypa/hatch/releases/tag/hatch-v1.16.5) - 2026-02-26 ## {: #hatch-v1.16.5 } ***Fixed:*** diff --git a/docs/plugins/environment/virtual.md b/docs/plugins/environment/virtual.md index 0e36fcb49..9adf7e9a9 100644 --- a/docs/plugins/environment/virtual.md +++ b/docs/plugins/environment/virtual.md @@ -56,7 +56,9 @@ If no version has been chosen, then each resolver will try to find a version tha The following options are recognized for internal Python resolution. !!! tip - You can set custom sources for distributions by setting the `HATCH_PYTHON_SOURCE_` environment variable where `` is the uppercased version of the distribution name with periods replaced by underscores e.g. `HATCH_PYTHON_SOURCE_PYPY3_10`. + You can set custom sources for distributions by setting the `HATCH_PYTHON_CUSTOM_SOURCE_` environment variable where `` is the uppercased version of the distribution name with periods replaced by underscores e.g. `HATCH_PYTHON_CUSTOM_SOURCE_PYPY3_10`. + + To mirror the built-in downloads without configuring every distribution individually, set `HATCH_PYTHON_INSTALL_MIRROR` for CPython distributions or `HATCH_PYPY_INSTALL_MIRROR` for PyPy distributions. The mirror URL replaces only the known upstream base URL and keeps the normal archive path suffix. Point these variables only at mirrors you trust. ### CPython diff --git a/src/hatch/config/constants.py b/src/hatch/config/constants.py index 8b1d68c8b..5c419b4ac 100644 --- a/src/hatch/config/constants.py +++ b/src/hatch/config/constants.py @@ -31,6 +31,8 @@ class PublishEnvVars: class PythonEnvVars: + CPYTHON_INSTALL_MIRROR = "HATCH_PYTHON_INSTALL_MIRROR" + PYPY_INSTALL_MIRROR = "HATCH_PYPY_INSTALL_MIRROR" CUSTOM_SOURCE_PREFIX = "HATCH_PYTHON_CUSTOM_SOURCE_" CUSTOM_PATH_PREFIX = "HATCH_PYTHON_CUSTOM_PATH_" CUSTOM_VERSION_PREFIX = "HATCH_PYTHON_CUSTOM_VERSION_" diff --git a/src/hatch/python/resolve.py b/src/hatch/python/resolve.py index 1cf832827..2f59a6d4b 100644 --- a/src/hatch/python/resolve.py +++ b/src/hatch/python/resolve.py @@ -19,6 +19,8 @@ # Use an artificially high epoch to ensure that custom distributions are always considered newer CUSTOM_DISTRIBUTION_VERSION_EPOCH = 100 +CPYTHON_STANDALONE_BASE_URL = "https://github.com/astral-sh/python-build-standalone/releases/download" +PYPY_BASE_URL = "https://downloads.python.org/pypy" def custom_env_var(prefix: str, name: str) -> str: @@ -37,6 +39,17 @@ def get_custom_path(name: str) -> str | None: return os.environ.get(custom_env_var(PythonEnvVars.CUSTOM_PATH_PREFIX, name)) +def apply_mirror(source: str, base_url: str, env_var: str) -> str: + if not source.startswith(f"{base_url}/"): + return source + + mirror = os.environ.get(env_var) + if not mirror: + return source + + return f"{mirror.rstrip('/')}/{source.removeprefix(base_url).lstrip('/')}" + + class Distribution(ABC): def __init__(self, name: str, source: str) -> None: self.__name = name @@ -134,8 +147,7 @@ def version(self) -> Version: if (custom_version := get_custom_version(self.name)) is not None: return Version(f"{CUSTOM_DISTRIBUTION_VERSION_EPOCH}!{custom_version}") - *_, remaining = self.source.partition("/pypy/") - _, version, *_ = remaining.split("-") + _, version, *_ = self.archive_name.split("-") return Version(f"0!{version[1:]}") @cached_property @@ -188,7 +200,14 @@ def get_distribution(name: str, source: str = "", variant_cpu: str = "", variant raise PythonDistributionResolutionError(message) source = keys[key] - return _get_distribution_class(source)(name, source) + distribution_class = _get_distribution_class(source) + + if distribution_class is CPythonStandaloneDistribution: + source = apply_mirror(source, CPYTHON_STANDALONE_BASE_URL, PythonEnvVars.CPYTHON_INSTALL_MIRROR) + elif distribution_class is PyPyOfficialDistribution: + source = apply_mirror(source, PYPY_BASE_URL, PythonEnvVars.PYPY_INSTALL_MIRROR) + + return distribution_class(name, source) def get_compatible_distributions() -> dict[str, Distribution]: @@ -272,7 +291,7 @@ def _get_default_variant_gil() -> str: def _get_distribution_class(source: str) -> type[Distribution]: if "/python-build-standalone/releases/download/" in source: return CPythonStandaloneDistribution - if source.startswith("https://downloads.python.org/pypy/"): + if source.startswith(f"{PYPY_BASE_URL}/"): return PyPyOfficialDistribution message = f"Unknown distribution source: {source}" diff --git a/tests/python/test_resolve.py b/tests/python/test_resolve.py index b94a6513c..1478d36bd 100644 --- a/tests/python/test_resolve.py +++ b/tests/python/test_resolve.py @@ -65,6 +65,46 @@ def test_pypy_custom(self): assert ".".join(map(str, version.release)) == "9000.42" +class TestDistributionMirrors: + def test_cpython_standalone(self): + mirror = "https://mirror.example.com/python-build-standalone" + with EnvVars({PythonEnvVars.CPYTHON_INSTALL_MIRROR: mirror}): + dist = get_distribution("3.11") + + assert dist.source.startswith(f"{mirror}/") + assert "github.com/astral-sh/python-build-standalone" not in dist.source + assert dist.archive_name.startswith("cpython-3.11.") + assert dist.version.epoch == 0 + assert dist.version.base_version.startswith("3.11.") + + def test_pypy(self): + mirror = "file:///mirror/pypy" + with EnvVars({PythonEnvVars.PYPY_INSTALL_MIRROR: mirror}): + dist = get_distribution("pypy3.10") + + assert dist.source.startswith(f"{mirror}/") + assert not dist.source.startswith("https://downloads.python.org/pypy/") + assert dist.archive_name.startswith("pypy3.10-v") + assert dist.version.epoch == 0 + + def test_explicit_source_is_not_mirrored(self): + source = "https://github.com/indygreg/python-build-standalone/releases/download/20230507/cpython-3.11.3%2B20230507-aarch64-unknown-linux-gnu-install_only.tar.gz" + with EnvVars({PythonEnvVars.CPYTHON_INSTALL_MIRROR: "https://mirror.example.com/python-build-standalone"}): + dist = get_distribution("3.11", source) + + assert dist.source == source + + def test_custom_source_takes_precedence(self): + name = "3.11" + custom_source = "https://example.com/custom/cpython-3.11.3%2B20230507-aarch64-unknown-linux-gnu.tar.gz" + with EnvVars({ + PythonEnvVars.CPYTHON_INSTALL_MIRROR: "https://mirror.example.com/python-build-standalone", + custom_env_var(PythonEnvVars.CUSTOM_SOURCE_PREFIX, name): custom_source, + }): + dist = get_distribution(name) + assert dist.source == custom_source + + class TestDistributionPaths: def test_cpython_standalone_custom(self): name = "3.11"