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 implementation: ImplementationName
filename: str filename: str
url: str url: str
build: str
sha256: str | None = None sha256: str | None = None
build_options: list[str] = field(default_factory=list) build_options: list[str] = field(default_factory=list)
variant: Variant | None = None variant: Variant | None = None
@ -397,6 +398,7 @@ class CPythonFinder(Finder):
implementation=self.implementation, implementation=self.implementation,
filename=filename, filename=filename,
url=url, url=url,
build=str(release),
build_options=build_options, build_options=build_options,
variant=variant, variant=variant,
sha256=sha256, sha256=sha256,
@ -507,6 +509,7 @@ class PyPyFinder(Finder):
python_version = Version.from_str(version["python_version"]) python_version = Version.from_str(version["python_version"])
if python_version < (3, 7, 0): if python_version < (3, 7, 0):
continue continue
pypy_version = version["pypy_version"]
for file in version["files"]: for file in version["files"]:
arch = self._normalize_arch(file["arch"]) arch = self._normalize_arch(file["arch"])
platform = self._normalize_os(file["platform"]) platform = self._normalize_os(file["platform"])
@ -523,6 +526,7 @@ class PyPyFinder(Finder):
implementation=self.implementation, implementation=self.implementation,
filename=file["filename"], filename=file["filename"],
url=file["download_url"], url=file["download_url"],
build=pypy_version,
) )
# Only keep the latest pypy version of each arch/platform # Only keep the latest pypy version of each arch/platform
if (python_version, arch, platform) not in results: if (python_version, arch, platform) not in results:
@ -612,6 +616,7 @@ class PyodideFinder(Finder):
implementation=self.implementation, implementation=self.implementation,
filename=asset["name"], filename=asset["name"],
url=url, url=url,
build=pyodide_version,
) )
) )
@ -708,6 +713,7 @@ class GraalPyFinder(Finder):
implementation=self.implementation, implementation=self.implementation,
filename=asset["name"], filename=asset["name"],
url=url, url=url,
build=graalpy_version,
sha256=sha256, sha256=sha256,
) )
# Only keep the latest GraalPy version of each arch/platform # Only keep the latest GraalPy version of each arch/platform
@ -811,6 +817,7 @@ def render(downloads: list[PythonDownload]) -> None:
"url": download.url, "url": download.url,
"sha256": download.sha256, "sha256": download.sha256,
"variant": download.variant if download.variant else None, "variant": download.variant if download.variant else None,
"build": download.build,
} }
VERSIONS_FILE.parent.mkdir(parents=True, exist_ok=True) 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}; use crate::managed::{ManagedPythonInstallations, PythonMinorVersionLink};
#[cfg(windows)] #[cfg(windows)]
use crate::microsoft_store::find_microsoft_store_pythons; 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::Error as VirtualEnvError;
use crate::virtualenv::{ use crate::virtualenv::{
CondaEnvironmentKind, conda_environment_from_env, virtualenv_from_env, 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. // 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")] #[error("Interpreter discovery for `{0}` requires `{1}` but only `{2}` is allowed")]
SourceNotAllowed(PythonRequest, PythonSource, PythonPreference), SourceNotAllowed(PythonRequest, PythonSource, PythonPreference),
#[error(transparent)]
BuildVersion(#[from] crate::python_version::BuildVersionError),
} }
/// Lazily iterate over Python executables in mutable virtual environments. /// Lazily iterate over Python executables in mutable virtual environments.
@ -342,6 +346,9 @@ fn python_executables_from_installed<'a>(
installed_installations.root().user_display() installed_installations.root().user_display()
); );
let installations = installed_installations.find_matching_current_platform()?; 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 // Check that the Python version and platform satisfy the request to avoid
// unnecessary interpreter queries later // unnecessary interpreter queries later
Ok(installations Ok(installations
@ -355,6 +362,22 @@ fn python_executables_from_installed<'a>(
debug!("Skipping managed installation `{installation}`: does not satisfy requested platform `{platform}`"); debug!("Skipping managed installation `{installation}`: does not satisfy requested platform `{platform}`");
return false; 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 true
}) })
.inspect(|installation| debug!("Found managed installation `{installation}`")) .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)))); return Box::new(iter::once(Err(Error::InvalidVersionRequest(err))));
} }
} }
Box::new({ Box::new({
debug!("Searching for {request} in {sources}"); debug!("Searching for {request} in {sources}");
python_interpreters( python_interpreters(
@ -1229,7 +1253,9 @@ pub fn find_python_installations<'a>(
cache, cache,
preview, 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))) .map_ok(|tuple| Ok(PythonInstallation::from_tuple(tuple)))
}) })
} }
@ -3186,6 +3212,7 @@ mod tests {
arch: None, arch: None,
os: None, os: None,
libc: None, libc: None,
build: None,
prereleases: None prereleases: None
}) })
); );
@ -3205,6 +3232,7 @@ mod tests {
))), ))),
os: Some(Os::new(target_lexicon::OperatingSystem::Darwin(None))), os: Some(Os::new(target_lexicon::OperatingSystem::Darwin(None))),
libc: Some(Libc::None), libc: Some(Libc::None),
build: None,
prereleases: None prereleases: None
}) })
); );
@ -3221,6 +3249,7 @@ mod tests {
arch: None, arch: None,
os: None, os: None,
libc: None, libc: None,
build: None,
prereleases: None prereleases: None
}) })
); );
@ -3240,6 +3269,7 @@ mod tests {
))), ))),
os: None, os: None,
libc: None, libc: None,
build: None,
prereleases: None prereleases: None
}) })
); );

View File

@ -36,6 +36,7 @@ use crate::implementation::{
}; };
use crate::installation::PythonInstallationKey; use crate::installation::PythonInstallationKey;
use crate::managed::ManagedPythonInstallation; use crate::managed::ManagedPythonInstallation;
use crate::python_version::{BuildVersionError, python_build_version_from_env};
use crate::{Interpreter, PythonRequest, PythonVersion, VersionRequest}; use crate::{Interpreter, PythonRequest, PythonVersion, VersionRequest};
#[derive(Error, Debug)] #[derive(Error, Debug)]
@ -110,6 +111,8 @@ pub enum Error {
url: Box<Url>, url: Box<Url>,
python_builds_dir: PathBuf, python_builds_dir: PathBuf,
}, },
#[error(transparent)]
BuildVersion(#[from] BuildVersionError),
} }
impl Error { impl Error {
@ -144,6 +147,7 @@ pub struct ManagedPythonDownload {
key: PythonInstallationKey, key: PythonInstallationKey,
url: Cow<'static, str>, url: Cow<'static, str>,
sha256: Option<Cow<'static, str>>, sha256: Option<Cow<'static, str>>,
build: Option<&'static str>,
} }
#[derive(Debug, Clone, Default, Eq, PartialEq, Hash)] #[derive(Debug, Clone, Default, Eq, PartialEq, Hash)]
@ -153,6 +157,7 @@ pub struct PythonDownloadRequest {
pub(crate) arch: Option<ArchRequest>, pub(crate) arch: Option<ArchRequest>,
pub(crate) os: Option<Os>, pub(crate) os: Option<Os>,
pub(crate) libc: Option<Libc>, 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 /// Whether to allow pre-releases or not. If not set, defaults to true if [`Self::version`] is
/// not None, and false otherwise. /// not None, and false otherwise.
@ -255,6 +260,7 @@ impl PythonDownloadRequest {
arch, arch,
os, os,
libc, libc,
build: None,
prereleases, prereleases,
} }
} }
@ -311,6 +317,12 @@ impl PythonDownloadRequest {
self 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. /// 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 /// Returns [`None`] if the request kind is not compatible with a download, e.g., it is
@ -356,11 +368,25 @@ impl PythonDownloadRequest {
Ok(self) 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> { pub fn fill(mut self) -> Result<Self, Error> {
if self.implementation.is_none() { if self.implementation.is_none() {
self.implementation = Some(ImplementationName::CPython); self.implementation = Some(ImplementationName::CPython);
} }
self = self.fill_platform()?; self = self.fill_platform()?;
self = self.fill_build_from_env()?;
Ok(self) Ok(self)
} }
@ -434,7 +460,31 @@ impl PythonDownloadRequest {
/// Whether this request is satisfied by a Python download. /// Whether this request is satisfied by a Python download.
pub fn satisfied_by_download(&self, download: &ManagedPythonDownload) -> bool { 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. /// Whether this download request opts-in to pre-release Python versions.
@ -753,6 +803,7 @@ struct JsonPythonDownload {
url: String, url: String,
sha256: Option<String>, sha256: Option<String>,
variant: Option<String>, variant: Option<String>,
build: Option<String>,
} }
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
@ -767,7 +818,25 @@ pub enum DownloadResult {
Fetched(PathBuf), 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 { 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. /// Return the first [`ManagedPythonDownload`] matching a request, if any.
/// ///
/// If there is no stable version matching the request, a compatible pre-release version will /// 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() self.sha256.as_ref()
} }
pub fn build(&self) -> Option<&'static str> {
self.build
}
/// Download and extract a Python distribution, retrying on failure. /// Download and extract a Python distribution, retrying on failure.
#[instrument(skip(client, installation_dir, scratch_dir, reporter), fields(download = % self.key()))] #[instrument(skip(client, installation_dir, scratch_dir, reporter), fields(download = % self.key()))]
pub async fn fetch_with_retry( pub async fn fetch_with_retry(
@ -1345,6 +1418,9 @@ fn parse_json_downloads(
let url = Cow::Owned(entry.url); let url = Cow::Owned(entry.url);
let sha256 = entry.sha256.map(Cow::Owned); let sha256 = entry.sha256.map(Cow::Owned);
let build = entry
.build
.map(|s| Box::leak(s.into_boxed_str()) as &'static str);
Some(ManagedPythonDownload { Some(ManagedPythonDownload {
key: PythonInstallationKey::new_from_version( key: PythonInstallationKey::new_from_version(
@ -1355,6 +1431,7 @@ fn parse_json_downloads(
), ),
url, url,
sha256, sha256,
build,
}) })
}) })
.sorted_by(|a, b| Ord::cmp(&b.key, &a.key)) .sorted_by(|a, b| Ord::cmp(&b.key, &a.key))
@ -1513,6 +1590,10 @@ async fn read_url(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::implementation::LenientImplementationName;
use crate::installation::PythonInstallationKey;
use uv_platform::{Arch, Libc, Os, Platform};
use super::*; use super::*;
/// Parse a request with all of its fields. /// Parse a request with all of its fields.
@ -1726,4 +1807,90 @@ mod tests {
assert!(matches!(result, Err(Error::TooManyParts(_)))); 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_externally_managed()?;
installed.ensure_sysconfig_patched()?; installed.ensure_sysconfig_patched()?;
installed.ensure_canonical_executables()?; installed.ensure_canonical_executables()?;
installed.ensure_build_file()?;
let minor_version = installed.minor_version_key(); let minor_version = installed.minor_version_key();
let highest_patch = installations let highest_patch = installations

View File

@ -21,7 +21,7 @@ pub use crate::interpreter::{
}; };
pub use crate::pointer_size::PointerSize; pub use crate::pointer_size::PointerSize;
pub use crate::prefix::Prefix; 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::target::Target;
pub use crate::version_files::{ pub use crate::version_files::{
DiscoveryOptions as VersionFileDiscoveryOptions, FilePreference as VersionFilePreference, DiscoveryOptions as VersionFileDiscoveryOptions, FilePreference as VersionFilePreference,

View File

@ -320,6 +320,10 @@ pub struct ManagedPythonInstallation {
/// ///
/// Empty when self was constructed from a path. /// Empty when self was constructed from a path.
sha256: Option<Cow<'static, str>>, 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 { impl ManagedPythonInstallation {
@ -329,6 +333,7 @@ impl ManagedPythonInstallation {
key: download.key().clone(), key: download.key().clone(),
url: Some(download.url().clone()), url: Some(download.url().clone()),
sha256: download.sha256().cloned(), 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))?; 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 { Ok(Self {
path, path,
key, key,
url: None, url: None,
sha256: None, sha256: None,
build,
}) })
} }
@ -455,6 +468,11 @@ impl ManagedPythonInstallation {
self.key.platform() 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 { pub fn minor_version_key(&self) -> &PythonInstallationMinorVersionKey {
PythonInstallationMinorVersionKey::ref_cast(&self.key) PythonInstallationMinorVersionKey::ref_cast(&self.key)
} }
@ -618,6 +636,15 @@ impl ManagedPythonInstallation {
Ok(()) 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 /// Returns `true` if the path is a link to this installation's binary, e.g., as created by
/// [`create_bin_link`]. /// [`create_bin_link`].
pub fn is_bin_link(&self, path: &Path) -> bool { pub fn is_bin_link(&self, path: &Path) -> bool {

View File

@ -1,11 +1,24 @@
#[cfg(feature = "schemars")] #[cfg(feature = "schemars")]
use std::borrow::Cow; use std::borrow::Cow;
use std::collections::BTreeMap;
use std::env;
use std::ffi::OsString;
use std::fmt::{Display, Formatter}; use std::fmt::{Display, Formatter};
use std::ops::Deref; use std::ops::Deref;
use std::str::FromStr; use std::str::FromStr;
use thiserror::Error;
use uv_pep440::Version; use uv_pep440::Version;
use uv_pep508::{MarkerEnvironment, StringVersion}; 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)] #[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct PythonVersion(StringVersion); 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)] #[cfg(test)]
mod tests { mod tests {
use std::str::FromStr; 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. /// 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"; 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 /// Equivalent to the `--clear` command-line argument. If set, uv will remove any
/// existing files or directories at the target path. /// existing files or directories at the target path.
pub const UV_VENV_CLEAR: &'static str = "UV_VENV_CLEAR"; 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_externally_managed()?;
installation.ensure_sysconfig_patched()?; installation.ensure_sysconfig_patched()?;
installation.ensure_canonical_executables()?; installation.ensure_canonical_executables()?;
installation.ensure_build_file()?;
if let Err(e) = installation.ensure_dylib_patched() { if let Err(e) = installation.ensure_dylib_patched() {
e.warn_user(installation); e.warn_user(installation);
} }

View File

@ -220,28 +220,32 @@ impl TestContext {
/// and `.exe` suffixes. /// and `.exe` suffixes.
#[must_use] #[must_use]
pub fn with_filtered_python_names(mut self) -> Self { pub fn with_filtered_python_names(mut self) -> Self {
use env::consts::EXE_SUFFIX; for name in ["python", "pypy"] {
let exe_suffix = regex::escape(EXE_SUFFIX); // 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(( self.filters.push((
format!(r"python\d.\d\d{exe_suffix}"), // We use a leading path separator to help disambiguate cases where the name is not
"[PYTHON]".to_string(), // 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 self
@ -270,15 +274,34 @@ impl TestContext {
/// the virtual environment equivalent. /// the virtual environment equivalent.
#[must_use] #[must_use]
pub fn with_filtered_python_install_bin(mut self) -> Self { 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) { if cfg!(unix) {
self.filters.push(( self.filters.push((
r"[\\/]bin/python".to_string(), format!(r"[\\/]bin/python({suffix})"),
"/[INSTALL-BIN]/python".to_string(), "/[INSTALL-BIN]/python$1".to_string(),
));
self.filters.push((
format!(r"[\\/]bin/pypy({suffix})"),
"/[INSTALL-BIN]/pypy$1".to_string(),
)); ));
} else { } else {
self.filters.push(( self.filters.push((
r"[\\/]python".to_string(), format!(r"[\\/]python({suffix})"),
"/[INSTALL-BIN]/python".to_string(), "/[INSTALL-BIN]/python$1".to_string(),
));
self.filters.push((
format!(r"[\\/]pypy({suffix})"),
"/[INSTALL-BIN]/pypy$1".to_string(),
)); ));
} }
self self

View File

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

View File

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

View File

@ -4,6 +4,7 @@ use std::path::PathBuf;
use std::{env, path::Path, process::Command}; use std::{env, path::Path, process::Command};
use crate::common::{TestContext, uv_snapshot}; use crate::common::{TestContext, uv_snapshot};
use assert_cmd::assert::OutputAssertExt;
use assert_fs::{ use assert_fs::{
assert::PathAssert, assert::PathAssert,
prelude::{FileTouch, FileWriteStr, PathChild, PathCreateDir}, prelude::{FileTouch, FileWriteStr, PathChild, PathCreateDir},
@ -2062,8 +2063,9 @@ fn python_install_314() {
let context: TestContext = TestContext::new_with_versions(&[]) let context: TestContext = TestContext::new_with_versions(&[])
.with_filtered_python_keys() .with_filtered_python_keys()
.with_managed_python_dirs() .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 // Install 3.14
// For now, this provides test coverage of pre-release handling // 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] Installed Python 3.14.0a4 in [TIME]
+ cpython-3.14.0a4-[PLATFORM] + cpython-3.14.0a4-[PLATFORM]
"); ");
}
// Add name filtering for the `find` tests, we avoid it in `install` tests because it clobbers #[test]
// the version suffixes which matter in the install logs fn python_find_314() {
let filters = context let context: TestContext = TestContext::new_with_versions(&[])
.filters() .with_filtered_python_keys()
.iter() .with_managed_python_dirs()
.map(|(a, b)| ((*a).to_string(), (*b).to_string())) .with_python_download_cache()
.collect::<Vec<_>>();
let context = context
.with_filtered_python_install_bin() .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 // We should be able to find this version without opt-in, because there is no stable release
// installed // installed
@ -2130,7 +2136,7 @@ fn python_install_314() {
"); ");
// If we install a stable version, that should be preferred though // 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 success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
@ -3158,3 +3164,157 @@ fn python_install_pyodide() {
----- stderr ----- ----- 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()?; init.touch()?;
// `run` should fail // `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 success: false
exit_code: 2 exit_code: 2
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` error: No `project` table found in: `[TEMP_DIR]/pyproject.toml`
"###); ");
// `run --no-project` should not // `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" 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`` // Running `uv sync` should use the environment at `target``
uv_snapshot!(context.filters(), context.sync() uv_snapshot!(context.filters(), context.sync()
.env(EnvVars::UV_PROJECT_ENVIRONMENT, "target"), @r###" .env(EnvVars::UV_PROJECT_ENVIRONMENT, "target"), @r"
success: true success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
@ -4101,7 +4101,7 @@ fn run_linked_environment_path() -> Result<()> {
+ packaging==24.0 + packaging==24.0
+ pathspec==0.12.1 + pathspec==0.12.1
+ platformdirs==4.2.0 + platformdirs==4.2.0
"###); ");
// `sys.prefix` and `sys.executable` should be from the `target` directory // `sys.prefix` and `sys.executable` should be from the `target` directory
uv_snapshot!(context.filters(), context.run() uv_snapshot!(context.filters(), context.run()

View File

@ -422,7 +422,7 @@ fn sync_json() -> Result<()> {
uv_snapshot!(context.filters(), context.sync() uv_snapshot!(context.filters(), context.sync()
.arg("--locked") .arg("--locked")
.arg("--output-format").arg("json"), @r###" .arg("--output-format").arg("json"), @r"
success: false success: false
exit_code: 1 exit_code: 1
----- stdout ----- ----- stdout -----
@ -430,7 +430,7 @@ fn sync_json() -> Result<()> {
----- stderr ----- ----- stderr -----
Resolved 2 packages in [TIME] 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`. 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 // Test that JSON output is shown even with --quiet flag
uv_snapshot!(context.filters(), context.sync() uv_snapshot!(context.filters(), context.sync()
@ -6367,7 +6367,7 @@ fn sync_active_script_environment() -> Result<()> {
fn sync_active_script_environment_json() -> Result<()> { fn sync_active_script_environment_json() -> Result<()> {
let context = TestContext::new_with_versions(&["3.11", "3.12"]) let context = TestContext::new_with_versions(&["3.11", "3.12"])
.with_filtered_virtualenv_bin() .with_filtered_virtualenv_bin()
.with_filtered_python_names(); .with_filtered_exe_suffix();
let script = context.temp_dir.child("script.py"); let script = context.temp_dir.child("script.py");
script.write_str(indoc! { r#" script.write_str(indoc! { r#"
@ -6402,7 +6402,7 @@ fn sync_active_script_environment_json() -> Result<()> {
"environment": { "environment": {
"path": "[CACHE_DIR]/environments-v2/script-[HASH]", "path": "[CACHE_DIR]/environments-v2/script-[HASH]",
"python": { "python": {
"path": "[CACHE_DIR]/environments-v2/script-[HASH]/[BIN]/[PYTHON]", "path": "[CACHE_DIR]/environments-v2/script-[HASH]/[BIN]/python",
"version": "3.11.[X]", "version": "3.11.[X]",
"implementation": "cpython" "implementation": "cpython"
} }
@ -6448,7 +6448,7 @@ fn sync_active_script_environment_json() -> Result<()> {
"environment": { "environment": {
"path": "[TEMP_DIR]/foo", "path": "[TEMP_DIR]/foo",
"python": { "python": {
"path": "[TEMP_DIR]/foo/[BIN]/[PYTHON]", "path": "[TEMP_DIR]/foo/[BIN]/python",
"version": "3.11.[X]", "version": "3.11.[X]",
"implementation": "cpython" "implementation": "cpython"
} }
@ -6507,7 +6507,7 @@ fn sync_active_script_environment_json() -> Result<()> {
"environment": { "environment": {
"path": "[TEMP_DIR]/foo", "path": "[TEMP_DIR]/foo",
"python": { "python": {
"path": "[TEMP_DIR]/foo/[BIN]/[PYTHON]", "path": "[TEMP_DIR]/foo/[BIN]/python",
"version": "3.12.[X]", "version": "3.12.[X]",
"implementation": "cpython" "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 // 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::create_dir(context.temp_dir.join(".venv"))?;
fs_err::write(context.temp_dir.join(".venv").join("file"), b"")?; 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 success: false
exit_code: 2 exit_code: 2
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
error: Project virtual environment directory `[VENV]/` cannot be used because it is not a valid Python environment (no Python executable was found) 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... // But if it's just an incompatible virtual environment...
fs_err::remove_dir_all(context.temp_dir.join(".venv"))?; 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"")?; fs_err::write(context.temp_dir.join(".venv").join("file"), b"")?;
// We should never delete it // We should never delete it
uv_snapshot!(context.filters(), context.sync(), @r###" uv_snapshot!(context.filters(), context.sync(), @r"
success: false success: false
exit_code: 2 exit_code: 2
----- stdout ----- ----- stdout -----
@ -8186,7 +8186,7 @@ fn sync_invalid_environment() -> Result<()> {
----- stderr ----- ----- stderr -----
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] 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 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 // Even if there's no Python executable
fs_err::remove_dir_all(&bin)?; 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 Specifies the directory for caching the archives of managed Python installations before
installation. 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` ### `UV_PYTHON_DOWNLOADS`
Equivalent to the 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. 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` ### `UV_PYTHON_INSTALL_BIN`
Whether to install the Python executable into the `UV_PYTHON_BIN_DIR` directory. 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. 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` ### `UV_REQUEST_TIMEOUT`
Timeout (in seconds) for HTTP requests. Equivalent to `UV_HTTP_TIMEOUT`. Timeout (in seconds) for HTTP requests. Equivalent to `UV_HTTP_TIMEOUT`.