Skip to content
Open
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
4 changes: 4 additions & 0 deletions docs/history/hatch.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:***
Expand Down
4 changes: 3 additions & 1 deletion docs/plugins/environment/virtual.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<NAME>` environment variable where `<NAME>` 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_<NAME>` environment variable where `<NAME>` 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

Expand Down
2 changes: 2 additions & 0 deletions src/hatch/config/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_"
Expand Down
27 changes: 23 additions & 4 deletions src/hatch/python/resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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}"
Expand Down
40 changes: 40 additions & 0 deletions tests/python/test_resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down