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;

View File

@ -332,6 +332,26 @@ impl EnvVars {
/// Distributions can be read from a local directory by using the `file://` URL scheme.
pub const UV_PYPY_INSTALL_MIRROR: &'static str = "UV_PYPY_INSTALL_MIRROR";
/// Pin managed CPython versions to a specific build version.
///
/// For CPython, this should be the build date (e.g., "20250814").
pub const UV_PYTHON_CPYTHON_BUILD: &'static str = "UV_PYTHON_CPYTHON_BUILD";
/// Pin managed PyPy versions to a specific build version.
///
/// For PyPy, this should be the PyPy version (e.g., "7.3.20").
pub const UV_PYTHON_PYPY_BUILD: &'static str = "UV_PYTHON_PYPY_BUILD";
/// Pin managed GraalPy versions to a specific build version.
///
/// For GraalPy, this should be the GraalPy version (e.g., "24.2.2").
pub const UV_PYTHON_GRAALPY_BUILD: &'static str = "UV_PYTHON_GRAALPY_BUILD";
/// Pin managed Pyodide versions to a specific build version.
///
/// For Pyodide, this should be the Pyodide version (e.g., "0.28.1").
pub const UV_PYTHON_PYODIDE_BUILD: &'static str = "UV_PYTHON_PYODIDE_BUILD";
/// Equivalent to the `--clear` command-line argument. If set, uv will remove any
/// existing files or directories at the target path.
pub const UV_VENV_CLEAR: &'static str = "UV_VENV_CLEAR";

View File

@ -483,6 +483,7 @@ pub(crate) async fn install(
installation.ensure_externally_managed()?;
installation.ensure_sysconfig_patched()?;
installation.ensure_canonical_executables()?;
installation.ensure_build_file()?;
if let Err(e) = installation.ensure_dylib_patched() {
e.warn_user(installation);
}

View File

@ -220,28 +220,32 @@ impl TestContext {
/// and `.exe` suffixes.
#[must_use]
pub fn with_filtered_python_names(mut self) -> Self {
use env::consts::EXE_SUFFIX;
let exe_suffix = regex::escape(EXE_SUFFIX);
for name in ["python", "pypy"] {
// Note we strip version numbers from the executable names because, e.g., on Windows
// `python.exe` is the equivalent to a Unix `python3.12`.`
let suffix = if cfg!(windows) {
// On Windows, we'll require a `.exe` suffix for disambiguation
// We'll also strip version numbers if present, which is not common for `python.exe`
// but can occur for, e.g., `pypy3.12.exe`
let exe_suffix = regex::escape(env::consts::EXE_SUFFIX);
format!(r"(\d\.\d+|\d)?{exe_suffix}")
} else {
// On Unix, we'll strip version numbers
if name == "python" {
// We can't require them in this case since `/python` is common
r"(\d\.\d+|\d)?".to_string()
} else {
// However, for other names we'll require them to avoid over-matching
r"(\d\.\d+|\d)".to_string()
}
};
self.filters.push((
format!(r"python\d.\d\d{exe_suffix}"),
"[PYTHON]".to_string(),
// We use a leading path separator to help disambiguate cases where the name is not
// used in a path.
format!(r"[\\/]{name}{suffix}"),
format!("/[{}]", name.to_uppercase()),
));
self.filters
.push((format!(r"python\d{exe_suffix}"), "[PYTHON]".to_string()));
if cfg!(windows) {
// On Windows, we want to filter out all `python.exe` instances
self.filters
.push((format!(r"python{exe_suffix}"), "[PYTHON]".to_string()));
// Including ones where we'd already stripped the `.exe` in another filter
self.filters
.push((r"[\\/]python".to_string(), "/[PYTHON]".to_string()));
} else {
// On Unix, it's a little trickier — we don't want to clobber use of `python` in the
// middle of something else, e.g., `cpython`. For this reason, we require a leading `/`.
self.filters
.push((format!(r"/python{exe_suffix}"), "/[PYTHON]".to_string()));
}
self
@ -270,15 +274,34 @@ impl TestContext {
/// the virtual environment equivalent.
#[must_use]
pub fn with_filtered_python_install_bin(mut self) -> Self {
// We don't want to eagerly match paths that aren't actually Python executables, so we
// do our best to detect that case
let suffix = if cfg!(windows) {
let exe_suffix = regex::escape(env::consts::EXE_SUFFIX);
// On Windows, we usually don't have a version attached but we might, e.g., for pypy3.12
format!(r"(\d\.\d+|\d)?{exe_suffix}")
} else {
// On Unix, we'll require a version to be attached to avoid over-matching
r"\d\.\d+|\d".to_string()
};
if cfg!(unix) {
self.filters.push((
r"[\\/]bin/python".to_string(),
"/[INSTALL-BIN]/python".to_string(),
format!(r"[\\/]bin/python({suffix})"),
"/[INSTALL-BIN]/python$1".to_string(),
));
self.filters.push((
format!(r"[\\/]bin/pypy({suffix})"),
"/[INSTALL-BIN]/pypy$1".to_string(),
));
} else {
self.filters.push((
r"[\\/]python".to_string(),
"/[INSTALL-BIN]/python".to_string(),
format!(r"[\\/]python({suffix})"),
"/[INSTALL-BIN]/python$1".to_string(),
));
self.filters.push((
format!(r"[\\/]pypy({suffix})"),
"/[INSTALL-BIN]/pypy$1".to_string(),
));
}
self

View File

@ -56,14 +56,14 @@ fn missing_venv() -> Result<()> {
assert!(predicates::path::missing().eval(&context.venv));
// If not "active", we hint to create one
uv_snapshot!(context.filters(), context.pip_sync().arg("requirements.txt").env_remove(EnvVars::VIRTUAL_ENV), @r###"
uv_snapshot!(context.filters(), context.pip_sync().arg("requirements.txt").env_remove(EnvVars::VIRTUAL_ENV), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: No virtual environment found; run `uv venv` to create an environment, or pass `--system` to install into a non-virtual environment
"###);
");
assert!(predicates::path::missing().eval(&context.venv));
@ -5342,7 +5342,7 @@ fn target_no_build_isolation() -> Result<()> {
requirements_in.write_str("flit_core")?;
uv_snapshot!(context.filters(), context.pip_sync()
.arg("requirements.in"), @r###"
.arg("requirements.in"), @r"
success: true
exit_code: 0
----- stdout -----
@ -5352,7 +5352,7 @@ fn target_no_build_isolation() -> Result<()> {
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ flit-core==3.9.0
"###);
");
// Install `iniconfig` to the target directory.
let requirements_in = context.temp_dir.child("requirements.in");

View File

@ -412,13 +412,13 @@ fn python_find_venv() {
.with_filtered_virtualenv_bin();
// Create a virtual environment
uv_snapshot!(context.filters(), context.venv().arg("--python").arg("3.12").arg("-q"), @r###"
uv_snapshot!(context.filters(), context.venv().arg("--python").arg("3.12").arg("-q"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
"###);
");
// We should find it first
// TODO(zanieb): On Windows, this has in a different display path for virtual environments which
@ -449,28 +449,28 @@ fn python_find_venv() {
child_dir.create_dir_all().unwrap();
// Unless the system flag is passed
uv_snapshot!(context.filters(), context.python_find().arg("--system"), @r###"
uv_snapshot!(context.filters(), context.python_find().arg("--system"), @r"
success: true
exit_code: 0
----- stdout -----
[PYTHON-3.11]
----- stderr -----
"###);
");
// Or, `UV_SYSTEM_PYTHON` is set
uv_snapshot!(context.filters(), context.python_find().env(EnvVars::UV_SYSTEM_PYTHON, "1"), @r###"
uv_snapshot!(context.filters(), context.python_find().env(EnvVars::UV_SYSTEM_PYTHON, "1"), @r"
success: true
exit_code: 0
----- stdout -----
[PYTHON-3.11]
----- stderr -----
"###);
");
// Unless, `--no-system` is included
// TODO(zanieb): Report this as a bug upstream — this should be allowed.
uv_snapshot!(context.filters(), context.python_find().arg("--no-system").env(EnvVars::UV_SYSTEM_PYTHON, "1"), @r###"
uv_snapshot!(context.filters(), context.python_find().arg("--no-system").env(EnvVars::UV_SYSTEM_PYTHON, "1"), @r"
success: false
exit_code: 2
----- stdout -----
@ -481,7 +481,7 @@ fn python_find_venv() {
Usage: uv python find --cache-dir [CACHE_DIR] [REQUEST]
For more information, try '--help'.
"###);
");
// We should find virtual environments from a child directory
#[cfg(not(windows))]
@ -495,13 +495,13 @@ fn python_find_venv() {
");
// A virtual environment in the child directory takes precedence over the parent
uv_snapshot!(context.filters(), context.venv().arg("--python").arg("3.11").arg("-q").current_dir(&child_dir), @r###"
uv_snapshot!(context.filters(), context.venv().arg("--python").arg("3.11").arg("-q").current_dir(&child_dir), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
"###);
");
#[cfg(not(windows))]
uv_snapshot!(context.filters(), context.python_find().current_dir(&child_dir).env_remove(EnvVars::VIRTUAL_ENV), @r"
@ -706,26 +706,26 @@ fn python_find_venv_invalid() {
");
// Unless the virtual environment is not active
uv_snapshot!(context.filters(), context.python_find(), @r###"
uv_snapshot!(context.filters(), context.python_find(), @r"
success: true
exit_code: 0
----- stdout -----
[PYTHON-3.12]
----- stderr -----
"###);
");
// If there's not a `pyvenv.cfg` file, it's also non-fatal, we ignore the environment
fs_err::remove_file(context.venv.join("pyvenv.cfg")).unwrap();
uv_snapshot!(context.filters(), context.python_find().env(EnvVars::VIRTUAL_ENV, context.venv.as_os_str()), @r###"
uv_snapshot!(context.filters(), context.python_find().env(EnvVars::VIRTUAL_ENV, context.venv.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
[PYTHON-3.12]
----- stderr -----
"###);
");
}
#[test]
@ -857,14 +857,14 @@ fn python_find_script() {
.with_filtered_python_names()
.with_filtered_exe_suffix();
uv_snapshot!(context.filters(), context.init().arg("--script").arg("foo.py"), @r###"
uv_snapshot!(context.filters(), context.init().arg("--script").arg("foo.py"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Initialized script at `foo.py`
"###);
");
uv_snapshot!(context.filters(), context.sync().arg("--script").arg("foo.py"), @r"
success: true

View File

@ -4,6 +4,7 @@ use std::path::PathBuf;
use std::{env, path::Path, process::Command};
use crate::common::{TestContext, uv_snapshot};
use assert_cmd::assert::OutputAssertExt;
use assert_fs::{
assert::PathAssert,
prelude::{FileTouch, FileWriteStr, PathChild, PathCreateDir},
@ -2062,8 +2063,9 @@ fn python_install_314() {
let context: TestContext = TestContext::new_with_versions(&[])
.with_filtered_python_keys()
.with_managed_python_dirs()
.with_filtered_exe_suffix()
.with_python_download_cache();
.with_python_download_cache()
.with_filtered_python_install_bin()
.with_filtered_exe_suffix();
// Install 3.14
// For now, this provides test coverage of pre-release handling
@ -2087,17 +2089,21 @@ fn python_install_314() {
Installed Python 3.14.0a4 in [TIME]
+ cpython-3.14.0a4-[PLATFORM]
");
}
// Add name filtering for the `find` tests, we avoid it in `install` tests because it clobbers
// the version suffixes which matter in the install logs
let filters = context
.filters()
.iter()
.map(|(a, b)| ((*a).to_string(), (*b).to_string()))
.collect::<Vec<_>>();
let context = context
#[test]
fn python_find_314() {
let context: TestContext = TestContext::new_with_versions(&[])
.with_filtered_python_keys()
.with_managed_python_dirs()
.with_python_download_cache()
.with_filtered_python_install_bin()
.with_filtered_python_names();
.with_filtered_python_names()
.with_filtered_exe_suffix();
// See [`python_install_314`] coverage of these.
context.python_install().arg("3.14").assert().success();
context.python_install().arg("3.14.0a4").assert().success();
// We should be able to find this version without opt-in, because there is no stable release
// installed
@ -2130,7 +2136,7 @@ fn python_install_314() {
");
// If we install a stable version, that should be preferred though
uv_snapshot!(filters, context.python_install().arg("3.13"), @r"
uv_snapshot!(context.filters(), context.python_install().arg("3.13"), @r"
success: true
exit_code: 0
----- stdout -----
@ -3158,3 +3164,157 @@ fn python_install_pyodide() {
----- stderr -----
");
}
#[test]
fn python_install_build_version() {
use uv_python::managed::platform_key_from_env;
let context: TestContext = TestContext::new_with_versions(&[])
.with_filtered_python_keys()
.with_managed_python_dirs()
.with_python_download_cache()
.with_filtered_python_sources()
.with_filtered_python_install_bin()
.with_filtered_python_names()
.with_filtered_exe_suffix();
uv_snapshot!(context.filters(), context.python_install()
.arg("3.12")
.env(EnvVars::UV_PYTHON_CPYTHON_BUILD, "20240814"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.12.5 in [TIME]
+ cpython-3.12.5-[PLATFORM] (python3.12)
");
// A BUILD file should be present with the version
let cpython_dir = context.temp_dir.child("managed").child(format!(
"cpython-3.12.5-{}",
platform_key_from_env().unwrap()
));
let build_file_path = cpython_dir.join("BUILD");
let build_content = fs_err::read_to_string(&build_file_path).unwrap();
assert_eq!(build_content, "20240814");
// We should find the build
uv_snapshot!(context.filters(), context.python_find()
.arg("3.12")
.env(EnvVars::UV_PYTHON_CPYTHON_BUILD, "20240814"), @r"
success: true
exit_code: 0
----- stdout -----
[TEMP_DIR]/managed/cpython-3.12.5-[PLATFORM]/[INSTALL-BIN]/[PYTHON]
----- stderr -----
");
// If the build number does not match, we should ignore the installation
uv_snapshot!(context.filters(), context.python_find()
.arg("3.12")
.env(EnvVars::UV_PYTHON_CPYTHON_BUILD, "99999999"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: No interpreter found for Python 3.12 in [PYTHON SOURCES]
");
// If there's no install for a build number, we should fail
uv_snapshot!(context.filters(), context.python_install()
.arg("3.12")
.env(EnvVars::UV_PYTHON_CPYTHON_BUILD, "99999999"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: No download found for request: cpython-3.12-[PLATFORM]
");
// Requesting a specific patch version without a matching build number should fail
uv_snapshot!(context.filters(), context.python_install()
.arg("3.12.10")
.env(EnvVars::UV_PYTHON_CPYTHON_BUILD, "20250814"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: No download found for request: cpython-3.12.10-[PLATFORM]
");
}
#[test]
fn python_install_build_version_pypy() {
use uv_python::managed::platform_key_from_env;
let context: TestContext = TestContext::new_with_versions(&[])
.with_filtered_python_keys()
.with_filtered_python_sources()
.with_managed_python_dirs()
.with_python_download_cache()
.with_filtered_python_install_bin()
.with_filtered_python_names()
.with_filtered_exe_suffix();
uv_snapshot!(context.filters(), context.python_install()
.arg("pypy3.10")
.env(EnvVars::UV_PYTHON_PYPY_BUILD, "7.3.19"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.10.16 in [TIME]
+ pypy-3.10.16-[PLATFORM] (python3.10)
");
// A BUILD file should be present with the version
let pypy_dir = context
.temp_dir
.child("managed")
.child(format!("pypy-3.10.16-{}", platform_key_from_env().unwrap()));
let build_file_path = pypy_dir.join("BUILD");
let build_content = fs_err::read_to_string(&build_file_path).unwrap();
assert_eq!(build_content, "7.3.19");
// We should find the build
uv_snapshot!(context.filters(), context.python_find()
.arg("pypy3.10")
.env(EnvVars::UV_PYTHON_PYPY_BUILD, "7.3.19"), @r"
success: true
exit_code: 0
----- stdout -----
[TEMP_DIR]/managed/pypy-3.10.16-[PLATFORM]/[INSTALL-BIN]/[PYPY]
----- stderr -----
");
// If the build number does not match, we should ignore the installation
uv_snapshot!(context.filters(), context.python_find()
.arg("pypy3.10")
.env(EnvVars::UV_PYTHON_PYPY_BUILD, "99.99.99"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: No interpreter found for PyPy 3.10 in [PYTHON SOURCES]
");
// If there's no install for a build number, we should fail
uv_snapshot!(context.filters(), context.python_install()
.arg("pypy3.10")
.env(EnvVars::UV_PYTHON_PYPY_BUILD, "99.99.99"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: No download found for request: pypy-3.10-[PLATFORM]
");
}

View File

@ -3332,14 +3332,14 @@ fn run_project_toml_error() -> Result<()> {
init.touch()?;
// `run` should fail
uv_snapshot!(context.filters(), context.run().arg("python").arg("-c").arg("import sys; print(sys.executable)"), @r###"
uv_snapshot!(context.filters(), context.run().arg("python").arg("-c").arg("import sys; print(sys.executable)"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: No `project` table found in: `[TEMP_DIR]/pyproject.toml`
"###);
");
// `run --no-project` should not
uv_snapshot!(context.filters(), context.run().arg("--no-project").arg("python").arg("-c").arg("import sys; print(sys.executable)"), @r"
@ -4086,7 +4086,7 @@ fn run_linked_environment_path() -> Result<()> {
// Running `uv sync` should use the environment at `target``
uv_snapshot!(context.filters(), context.sync()
.env(EnvVars::UV_PROJECT_ENVIRONMENT, "target"), @r###"
.env(EnvVars::UV_PROJECT_ENVIRONMENT, "target"), @r"
success: true
exit_code: 0
----- stdout -----
@ -4101,7 +4101,7 @@ fn run_linked_environment_path() -> Result<()> {
+ packaging==24.0
+ pathspec==0.12.1
+ platformdirs==4.2.0
"###);
");
// `sys.prefix` and `sys.executable` should be from the `target` directory
uv_snapshot!(context.filters(), context.run()

View File

@ -422,7 +422,7 @@ fn sync_json() -> Result<()> {
uv_snapshot!(context.filters(), context.sync()
.arg("--locked")
.arg("--output-format").arg("json"), @r###"
.arg("--output-format").arg("json"), @r"
success: false
exit_code: 1
----- stdout -----
@ -430,7 +430,7 @@ fn sync_json() -> Result<()> {
----- stderr -----
Resolved 2 packages in [TIME]
The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. To update the lockfile, run `uv lock`.
"###);
");
// Test that JSON output is shown even with --quiet flag
uv_snapshot!(context.filters(), context.sync()
@ -6367,7 +6367,7 @@ fn sync_active_script_environment() -> Result<()> {
fn sync_active_script_environment_json() -> Result<()> {
let context = TestContext::new_with_versions(&["3.11", "3.12"])
.with_filtered_virtualenv_bin()
.with_filtered_python_names();
.with_filtered_exe_suffix();
let script = context.temp_dir.child("script.py");
script.write_str(indoc! { r#"
@ -6402,7 +6402,7 @@ fn sync_active_script_environment_json() -> Result<()> {
"environment": {
"path": "[CACHE_DIR]/environments-v2/script-[HASH]",
"python": {
"path": "[CACHE_DIR]/environments-v2/script-[HASH]/[BIN]/[PYTHON]",
"path": "[CACHE_DIR]/environments-v2/script-[HASH]/[BIN]/python",
"version": "3.11.[X]",
"implementation": "cpython"
}
@ -6448,7 +6448,7 @@ fn sync_active_script_environment_json() -> Result<()> {
"environment": {
"path": "[TEMP_DIR]/foo",
"python": {
"path": "[TEMP_DIR]/foo/[BIN]/[PYTHON]",
"path": "[TEMP_DIR]/foo/[BIN]/python",
"version": "3.11.[X]",
"implementation": "cpython"
}
@ -6507,7 +6507,7 @@ fn sync_active_script_environment_json() -> Result<()> {
"environment": {
"path": "[TEMP_DIR]/foo",
"python": {
"path": "[TEMP_DIR]/foo/[BIN]/[PYTHON]",
"path": "[TEMP_DIR]/foo/[BIN]/python",
"version": "3.12.[X]",
"implementation": "cpython"
}
@ -8076,14 +8076,14 @@ fn sync_invalid_environment() -> Result<()> {
// If the directory already exists and is not a virtual environment we should fail with an error
fs_err::create_dir(context.temp_dir.join(".venv"))?;
fs_err::write(context.temp_dir.join(".venv").join("file"), b"")?;
uv_snapshot!(context.filters(), context.sync(), @r###"
uv_snapshot!(context.filters(), context.sync(), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Project virtual environment directory `[VENV]/` cannot be used because it is not a valid Python environment (no Python executable was found)
"###);
");
// But if it's just an incompatible virtual environment...
fs_err::remove_dir_all(context.temp_dir.join(".venv"))?;
@ -8178,7 +8178,7 @@ fn sync_invalid_environment() -> Result<()> {
fs_err::write(context.temp_dir.join(".venv").join("file"), b"")?;
// We should never delete it
uv_snapshot!(context.filters(), context.sync(), @r###"
uv_snapshot!(context.filters(), context.sync(), @r"
success: false
exit_code: 2
----- stdout -----
@ -8186,7 +8186,7 @@ fn sync_invalid_environment() -> Result<()> {
----- stderr -----
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
error: Project virtual environment directory `[VENV]/` cannot be used because it is not a compatible environment but cannot be recreated because it is not a virtual environment
"###);
");
// Even if there's no Python executable
fs_err::remove_dir_all(&bin)?;

View File

@ -410,6 +410,12 @@ Specifies the directory to place links to installed, managed Python executables.
Specifies the directory for caching the archives of managed Python installations before
installation.
### `UV_PYTHON_CPYTHON_BUILD`
Pin managed CPython versions to a specific build version.
For CPython, this should be the build date (e.g., "20250814").
### `UV_PYTHON_DOWNLOADS`
Equivalent to the
@ -425,6 +431,12 @@ This will allow for setting each property of the Python installation, mostly the
Note that currently, only local paths are supported.
### `UV_PYTHON_GRAALPY_BUILD`
Pin managed GraalPy versions to a specific build version.
For GraalPy, this should be the GraalPy version (e.g., "24.2.2").
### `UV_PYTHON_INSTALL_BIN`
Whether to install the Python executable into the `UV_PYTHON_BIN_DIR` directory.
@ -451,6 +463,18 @@ Whether to install the Python executable into the Windows registry.
Whether uv should prefer system or managed Python versions.
### `UV_PYTHON_PYODIDE_BUILD`
Pin managed Pyodide versions to a specific build version.
For Pyodide, this should be the Pyodide version (e.g., "0.28.1").
### `UV_PYTHON_PYPY_BUILD`
Pin managed PyPy versions to a specific build version.
For PyPy, this should be the PyPy version (e.g., "7.3.20").
### `UV_REQUEST_TIMEOUT`
Timeout (in seconds) for HTTP requests. Equivalent to `UV_HTTP_TIMEOUT`.