Allow pinning managed Python versions to specific build versions (#15314)

Allows pinning the Python build version via environment variables, e.g.,
`UV_PYTHON_CPYTHON_BUILD=...`. Each variable is implementation specific,
because they use different versioning schemes.

Updates the Python download metadata to include a `build` string, so we
can filter downloads by the pin. Writes the build version to a file in
the managed install, e.g., `cpython-3.10.18-macos-aarch64-none/BUILD`,
so we can filter installed versions by the pin.

Some important follow-up here:

- Include the build version in not found errors (when pinned)
- Automatically use a remote list of Python downloads to satisfy build
versions not present in the latest embedded download metadata

Some less important follow-ups to consider:

- Allow using ranges for build version pins
This commit is contained in:
Zanie Blue 2025-08-25 16:25:05 -05:00 committed by GitHub
parent b6f1fb7d3f
commit 9b8d6989d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 5518 additions and 2537 deletions

File diff suppressed because it is too large Load diff

View file

@ -153,6 +153,7 @@ class PythonDownload:
implementation: ImplementationName
filename: str
url: str
build: str
sha256: str | None = None
build_options: list[str] = field(default_factory=list)
variant: Variant | None = None
@ -397,6 +398,7 @@ class CPythonFinder(Finder):
implementation=self.implementation,
filename=filename,
url=url,
build=str(release),
build_options=build_options,
variant=variant,
sha256=sha256,
@ -507,6 +509,7 @@ class PyPyFinder(Finder):
python_version = Version.from_str(version["python_version"])
if python_version < (3, 7, 0):
continue
pypy_version = version["pypy_version"]
for file in version["files"]:
arch = self._normalize_arch(file["arch"])
platform = self._normalize_os(file["platform"])
@ -523,6 +526,7 @@ class PyPyFinder(Finder):
implementation=self.implementation,
filename=file["filename"],
url=file["download_url"],
build=pypy_version,
)
# Only keep the latest pypy version of each arch/platform
if (python_version, arch, platform) not in results:
@ -612,6 +616,7 @@ class PyodideFinder(Finder):
implementation=self.implementation,
filename=asset["name"],
url=url,
build=pyodide_version,
)
)
@ -708,6 +713,7 @@ class GraalPyFinder(Finder):
implementation=self.implementation,
filename=asset["name"],
url=url,
build=graalpy_version,
sha256=sha256,
)
# Only keep the latest GraalPy version of each arch/platform
@ -811,6 +817,7 @@ def render(downloads: list[PythonDownload]) -> None:
"url": download.url,
"sha256": download.sha256,
"variant": download.variant if download.variant else None,
"build": download.build,
}
VERSIONS_FILE.parent.mkdir(parents=True, exist_ok=True)

View file

@ -29,6 +29,7 @@ use crate::interpreter::{StatusCodeError, UnexpectedResponseError};
use crate::managed::{ManagedPythonInstallations, PythonMinorVersionLink};
#[cfg(windows)]
use crate::microsoft_store::find_microsoft_store_pythons;
use crate::python_version::python_build_versions_from_env;
use crate::virtualenv::Error as VirtualEnvError;
use crate::virtualenv::{
CondaEnvironmentKind, conda_environment_from_env, virtualenv_from_env,
@ -263,6 +264,9 @@ pub enum Error {
// TODO(zanieb): Is this error case necessary still? We should probably drop it.
#[error("Interpreter discovery for `{0}` requires `{1}` but only `{2}` is allowed")]
SourceNotAllowed(PythonRequest, PythonSource, PythonPreference),
#[error(transparent)]
BuildVersion(#[from] crate::python_version::BuildVersionError),
}
/// Lazily iterate over Python executables in mutable virtual environments.
@ -342,6 +346,9 @@ fn python_executables_from_installed<'a>(
installed_installations.root().user_display()
);
let installations = installed_installations.find_matching_current_platform()?;
let build_versions = python_build_versions_from_env()?;
// Check that the Python version and platform satisfy the request to avoid
// unnecessary interpreter queries later
Ok(installations
@ -355,6 +362,22 @@ fn python_executables_from_installed<'a>(
debug!("Skipping managed installation `{installation}`: does not satisfy requested platform `{platform}`");
return false;
}
if let Some(requested_build) = build_versions.get(&installation.implementation()) {
let Some(installation_build) = installation.build() else {
debug!(
"Skipping managed installation `{installation}`: a build version was requested but is not recorded for this installation"
);
return false;
};
if installation_build != requested_build {
debug!(
"Skipping managed installation `{installation}`: requested build version `{requested_build}` does not match installation build version `{installation_build}`"
);
return false;
}
}
true
})
.inspect(|installation| debug!("Found managed installation `{installation}`"))
@ -1218,6 +1241,7 @@ pub fn find_python_installations<'a>(
return Box::new(iter::once(Err(Error::InvalidVersionRequest(err))));
}
}
Box::new({
debug!("Searching for {request} in {sources}");
python_interpreters(
@ -1229,7 +1253,9 @@ pub fn find_python_installations<'a>(
cache,
preview,
)
.filter_ok(|(_source, interpreter)| request.satisfied_by_interpreter(interpreter))
.filter_ok(move |(_source, interpreter)| {
request.satisfied_by_interpreter(interpreter)
})
.map_ok(|tuple| Ok(PythonInstallation::from_tuple(tuple)))
})
}
@ -3186,6 +3212,7 @@ mod tests {
arch: None,
os: None,
libc: None,
build: None,
prereleases: None
})
);
@ -3205,6 +3232,7 @@ mod tests {
))),
os: Some(Os::new(target_lexicon::OperatingSystem::Darwin(None))),
libc: Some(Libc::None),
build: None,
prereleases: None
})
);
@ -3221,6 +3249,7 @@ mod tests {
arch: None,
os: None,
libc: None,
build: None,
prereleases: None
})
);
@ -3240,6 +3269,7 @@ mod tests {
))),
os: None,
libc: None,
build: None,
prereleases: None
})
);

View file

@ -36,6 +36,7 @@ use crate::implementation::{
};
use crate::installation::PythonInstallationKey;
use crate::managed::ManagedPythonInstallation;
use crate::python_version::{BuildVersionError, python_build_version_from_env};
use crate::{Interpreter, PythonRequest, PythonVersion, VersionRequest};
#[derive(Error, Debug)]
@ -110,6 +111,8 @@ pub enum Error {
url: Box<Url>,
python_builds_dir: PathBuf,
},
#[error(transparent)]
BuildVersion(#[from] BuildVersionError),
}
impl Error {
@ -144,6 +147,7 @@ pub struct ManagedPythonDownload {
key: PythonInstallationKey,
url: Cow<'static, str>,
sha256: Option<Cow<'static, str>>,
build: Option<&'static str>,
}
#[derive(Debug, Clone, Default, Eq, PartialEq, Hash)]
@ -153,6 +157,7 @@ pub struct PythonDownloadRequest {
pub(crate) arch: Option<ArchRequest>,
pub(crate) os: Option<Os>,
pub(crate) libc: Option<Libc>,
pub(crate) build: Option<String>,
/// Whether to allow pre-releases or not. If not set, defaults to true if [`Self::version`] is
/// not None, and false otherwise.
@ -255,6 +260,7 @@ impl PythonDownloadRequest {
arch,
os,
libc,
build: None,
prereleases,
}
}
@ -311,6 +317,12 @@ impl PythonDownloadRequest {
self
}
#[must_use]
pub fn with_build(mut self, build: String) -> Self {
self.build = Some(build);
self
}
/// Construct a new [`PythonDownloadRequest`] from a [`PythonRequest`] if possible.
///
/// Returns [`None`] if the request kind is not compatible with a download, e.g., it is
@ -356,11 +368,25 @@ impl PythonDownloadRequest {
Ok(self)
}
/// Fill the build field from the environment variable relevant for the [`ImplementationName`].
pub fn fill_build_from_env(mut self) -> Result<Self, Error> {
if self.build.is_some() {
return Ok(self);
}
let Some(implementation) = self.implementation else {
return Ok(self);
};
self.build = python_build_version_from_env(implementation)?;
Ok(self)
}
pub fn fill(mut self) -> Result<Self, Error> {
if self.implementation.is_none() {
self.implementation = Some(ImplementationName::CPython);
}
self = self.fill_platform()?;
self = self.fill_build_from_env()?;
Ok(self)
}
@ -434,7 +460,31 @@ impl PythonDownloadRequest {
/// Whether this request is satisfied by a Python download.
pub fn satisfied_by_download(&self, download: &ManagedPythonDownload) -> bool {
self.satisfied_by_key(download.key())
// First check the key
if !self.satisfied_by_key(download.key()) {
return false;
}
// Then check the build if specified
if let Some(ref requested_build) = self.build {
let Some(download_build) = download.build() else {
debug!(
"Skipping download `{}`: a build version was requested but is not available for this download",
download
);
return false;
};
if download_build != requested_build {
debug!(
"Skipping download `{}`: requested build version `{}` does not match download build version `{}`",
download, requested_build, download_build
);
return false;
}
}
true
}
/// Whether this download request opts-in to pre-release Python versions.
@ -753,6 +803,7 @@ struct JsonPythonDownload {
url: String,
sha256: Option<String>,
variant: Option<String>,
build: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
@ -767,7 +818,25 @@ pub enum DownloadResult {
Fetched(PathBuf),
}
/// A wrapper type to display a `ManagedPythonDownload` with its build information.
pub struct ManagedPythonDownloadWithBuild<'a>(&'a ManagedPythonDownload);
impl Display for ManagedPythonDownloadWithBuild<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(build) = self.0.build {
write!(f, "{}+{}", self.0.key, build)
} else {
write!(f, "{}", self.0.key)
}
}
}
impl ManagedPythonDownload {
/// Return a display type that includes the build information.
pub fn to_display_with_build(&self) -> ManagedPythonDownloadWithBuild<'_> {
ManagedPythonDownloadWithBuild(self)
}
/// Return the first [`ManagedPythonDownload`] matching a request, if any.
///
/// If there is no stable version matching the request, a compatible pre-release version will
@ -852,6 +921,10 @@ impl ManagedPythonDownload {
self.sha256.as_ref()
}
pub fn build(&self) -> Option<&'static str> {
self.build
}
/// Download and extract a Python distribution, retrying on failure.
#[instrument(skip(client, installation_dir, scratch_dir, reporter), fields(download = % self.key()))]
pub async fn fetch_with_retry(
@ -1345,6 +1418,9 @@ fn parse_json_downloads(
let url = Cow::Owned(entry.url);
let sha256 = entry.sha256.map(Cow::Owned);
let build = entry
.build
.map(|s| Box::leak(s.into_boxed_str()) as &'static str);
Some(ManagedPythonDownload {
key: PythonInstallationKey::new_from_version(
@ -1355,6 +1431,7 @@ fn parse_json_downloads(
),
url,
sha256,
build,
})
})
.sorted_by(|a, b| Ord::cmp(&b.key, &a.key))
@ -1513,6 +1590,10 @@ async fn read_url(
#[cfg(test)]
mod tests {
use crate::implementation::LenientImplementationName;
use crate::installation::PythonInstallationKey;
use uv_platform::{Arch, Libc, Os, Platform};
use super::*;
/// Parse a request with all of its fields.
@ -1726,4 +1807,90 @@ mod tests {
assert!(matches!(result, Err(Error::TooManyParts(_))));
}
/// Test that build filtering works correctly
#[test]
fn test_python_download_request_build_filtering() {
let request = PythonDownloadRequest::default()
.with_version(VersionRequest::from_str("3.12").unwrap())
.with_implementation(ImplementationName::CPython)
.with_build("20240814".to_string());
let downloads: Vec<_> = ManagedPythonDownload::iter_all(None)
.unwrap()
.filter(|d| request.satisfied_by_download(d))
.collect();
assert!(
!downloads.is_empty(),
"Should find at least one matching download"
);
for download in downloads {
assert_eq!(download.build(), Some("20240814"));
}
}
/// Test that an invalid build results in no matches
#[test]
fn test_python_download_request_invalid_build() {
// Create a request with a non-existent build
let request = PythonDownloadRequest::default()
.with_version(VersionRequest::from_str("3.12").unwrap())
.with_implementation(ImplementationName::CPython)
.with_build("99999999".to_string());
// Should find no matching downloads
let downloads: Vec<_> = ManagedPythonDownload::iter_all(None)
.unwrap()
.filter(|d| request.satisfied_by_download(d))
.collect();
assert_eq!(downloads.len(), 0);
}
/// Test build display
#[test]
fn test_managed_python_download_build_display() {
// Create a test download with a build
let key = PythonInstallationKey::new(
LenientImplementationName::Known(crate::implementation::ImplementationName::CPython),
3,
12,
0,
None,
Platform::new(
Os::from_str("linux").unwrap(),
Arch::from_str("x86_64").unwrap(),
Libc::from_str("gnu").unwrap(),
),
crate::PythonVariant::default(),
);
let download_with_build = ManagedPythonDownload {
key,
url: Cow::Borrowed("https://example.com/python.tar.gz"),
sha256: Some(Cow::Borrowed("abc123")),
build: Some("20240101"),
};
// Test display with build
assert_eq!(
download_with_build.to_display_with_build().to_string(),
"cpython-3.12.0-linux-x86_64-gnu+20240101"
);
// Test download without build
let download_without_build = ManagedPythonDownload {
key: download_with_build.key.clone(),
url: Cow::Borrowed("https://example.com/python.tar.gz"),
sha256: Some(Cow::Borrowed("abc123")),
build: None,
};
// Test display without build
assert_eq!(
download_without_build.to_display_with_build().to_string(),
"cpython-3.12.0-linux-x86_64-gnu"
);
}
}

View file

@ -252,6 +252,7 @@ impl PythonInstallation {
installed.ensure_externally_managed()?;
installed.ensure_sysconfig_patched()?;
installed.ensure_canonical_executables()?;
installed.ensure_build_file()?;
let minor_version = installed.minor_version_key();
let highest_patch = installations

View file

@ -21,7 +21,7 @@ pub use crate::interpreter::{
};
pub use crate::pointer_size::PointerSize;
pub use crate::prefix::Prefix;
pub use crate::python_version::PythonVersion;
pub use crate::python_version::{BuildVersionError, PythonVersion};
pub use crate::target::Target;
pub use crate::version_files::{
DiscoveryOptions as VersionFileDiscoveryOptions, FilePreference as VersionFilePreference,

View file

@ -320,6 +320,10 @@ pub struct ManagedPythonInstallation {
///
/// Empty when self was constructed from a path.
sha256: Option<Cow<'static, str>>,
/// The build version of the Python installation.
///
/// Empty when self was constructed from a path without a BUILD file.
build: Option<Cow<'static, str>>,
}
impl ManagedPythonInstallation {
@ -329,6 +333,7 @@ impl ManagedPythonInstallation {
key: download.key().clone(),
url: Some(download.url().clone()),
sha256: download.sha256().cloned(),
build: download.build().map(Cow::Borrowed),
}
}
@ -342,11 +347,19 @@ impl ManagedPythonInstallation {
let path = std::path::absolute(&path).map_err(|err| Error::AbsolutePath(path, err))?;
// Try to read the BUILD file if it exists
let build = match fs::read_to_string(path.join("BUILD")) {
Ok(content) => Some(Cow::Owned(content.trim().to_string())),
Err(err) if err.kind() == io::ErrorKind::NotFound => None,
Err(err) => return Err(err.into()),
};
Ok(Self {
path,
key,
url: None,
sha256: None,
build,
})
}
@ -455,6 +468,11 @@ impl ManagedPythonInstallation {
self.key.platform()
}
/// The build version of this installation, if available.
pub fn build(&self) -> Option<&str> {
self.build.as_deref()
}
pub fn minor_version_key(&self) -> &PythonInstallationMinorVersionKey {
PythonInstallationMinorVersionKey::ref_cast(&self.key)
}
@ -618,6 +636,15 @@ impl ManagedPythonInstallation {
Ok(())
}
/// Ensure the build version is written to a BUILD file in the installation directory.
pub fn ensure_build_file(&self) -> Result<(), Error> {
if let Some(ref build) = self.build {
let build_file = self.path.join("BUILD");
fs::write(&build_file, build.as_ref())?;
}
Ok(())
}
/// Returns `true` if the path is a link to this installation's binary, e.g., as created by
/// [`create_bin_link`].
pub fn is_bin_link(&self, path: &Path) -> bool {

View file

@ -1,11 +1,24 @@
#[cfg(feature = "schemars")]
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::env;
use std::ffi::OsString;
use std::fmt::{Display, Formatter};
use std::ops::Deref;
use std::str::FromStr;
use thiserror::Error;
use uv_pep440::Version;
use uv_pep508::{MarkerEnvironment, StringVersion};
use uv_static::EnvVars;
use crate::implementation::ImplementationName;
#[derive(Error, Debug)]
pub enum BuildVersionError {
#[error("`{0}` is not valid unicode: {1:?}")]
NotUnicode(&'static str, OsString),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct PythonVersion(StringVersion);
@ -206,6 +219,51 @@ impl PythonVersion {
}
}
/// Get the environment variable name for the build constraint for a given implementation.
pub(crate) fn python_build_version_variable(implementation: ImplementationName) -> &'static str {
match implementation {
ImplementationName::CPython => EnvVars::UV_PYTHON_CPYTHON_BUILD,
ImplementationName::PyPy => EnvVars::UV_PYTHON_PYPY_BUILD,
ImplementationName::GraalPy => EnvVars::UV_PYTHON_GRAALPY_BUILD,
ImplementationName::Pyodide => EnvVars::UV_PYTHON_PYODIDE_BUILD,
}
}
/// Get the build version number from the environment variable for a given implementation.
pub(crate) fn python_build_version_from_env(
implementation: ImplementationName,
) -> Result<Option<String>, BuildVersionError> {
let variable = python_build_version_variable(implementation);
let Some(build_os) = env::var_os(variable) else {
return Ok(None);
};
let build = build_os
.into_string()
.map_err(|raw| BuildVersionError::NotUnicode(variable, raw))?;
let trimmed = build.trim();
if trimmed.is_empty() {
return Ok(None);
}
Ok(Some(trimmed.to_string()))
}
/// Get the build version numbers for all Python implementations.
pub(crate) fn python_build_versions_from_env()
-> Result<BTreeMap<ImplementationName, String>, BuildVersionError> {
let mut versions = BTreeMap::new();
for implementation in ImplementationName::iter_all() {
let Some(build) = python_build_version_from_env(implementation)? else {
continue;
};
versions.insert(implementation, build);
}
Ok(versions)
}
#[cfg(test)]
mod tests {
use std::str::FromStr;