Make uv pip compile attempt to download a specified --python-version if it can. (#17249)

## Summary

Addresses #16709. Now specifying a simple version with `--python` or
specifying a version using `--python-version` will result in the
specified version getting downloaded with a fallback to the previous
behaviour if the download fails for some transient reason or if
downloads are disabled.

The behaviour of how `--python` gets treated as `--python-version`, if a
"simple version" is specified, is kept. This means that `--python 3.7`
turns into a soft requirement. This seems at odds with how other similar
parts of UV work, but there seem to be quite a few tests which test for
this specific behaviour and I think this is best saved for a separate
issue.

## Test Plan

I added a test case which would previously fall back to the default
interpreter and warn about it.

---------

Co-authored-by: Zanie Blue <contact@zanie.dev>
This commit is contained in:
Tomasz Kramkowski
2026-01-16 14:01:56 +00:00
committed by GitHub
parent 8a02f6352a
commit f7d1215a98
6 changed files with 420 additions and 70 deletions
+126 -42
View File
@@ -1,5 +1,7 @@
use itertools::{Either, Itertools};
use owo_colors::AnsiColors;
use regex::Regex;
use reqwest_retry::policies::ExponentialBackoff;
use rustc_hash::{FxBuildHasher, FxHashSet};
use same_file::is_same_file;
use std::borrow::Cow;
@@ -10,6 +12,7 @@ use std::{path::Path, path::PathBuf, str::FromStr};
use thiserror::Error;
use tracing::{debug, instrument, trace};
use uv_cache::Cache;
use uv_client::BaseClient;
use uv_fs::Simplified;
use uv_fs::which::is_executable;
use uv_pep440::{
@@ -18,10 +21,11 @@ use uv_pep440::{
};
use uv_preview::Preview;
use uv_static::EnvVars;
use uv_warnings::anstream;
use uv_warnings::warn_user_once;
use which::{which, which_all};
use crate::downloads::{PlatformRequest, PythonDownloadRequest};
use crate::downloads::{ManagedPythonDownloadList, PlatformRequest, PythonDownloadRequest};
use crate::implementation::ImplementationName;
use crate::installation::PythonInstallation;
use crate::interpreter::Error as InterpreterError;
@@ -1443,34 +1447,31 @@ pub(crate) fn find_python_installation(
/// without comparing the patch version number. If that cannot be found, we fall back to
/// the first available version.
///
/// At all points, if the specified version cannot be found, we will attempt to
/// download it if downloads are enabled.
///
/// See [`find_python_installation`] for more details on installation discovery.
#[instrument(skip_all, fields(request))]
pub(crate) fn find_best_python_installation(
pub(crate) async fn find_best_python_installation(
request: &PythonRequest,
environments: EnvironmentPreference,
preference: PythonPreference,
downloads_enabled: bool,
download_list: &ManagedPythonDownloadList,
client: &BaseClient,
retry_policy: &ExponentialBackoff,
cache: &Cache,
reporter: Option<&dyn crate::downloads::Reporter>,
python_install_mirror: Option<&str>,
pypy_install_mirror: Option<&str>,
preview: Preview,
) -> Result<FindPythonResult, Error> {
debug!("Starting Python discovery for {}", request);
) -> Result<PythonInstallation, crate::Error> {
debug!("Starting Python discovery for {request}");
let original_request = request;
// First, check for an exact match (or the first available version if no Python version was provided)
debug!("Looking for exact match for request {request}");
let result = find_python_installation(request, environments, preference, cache, preview);
match result {
Ok(Ok(installation)) => {
warn_on_unsupported_python(installation.interpreter());
return Ok(Ok(installation));
}
// Continue if we can't find a matching Python and ignore non-critical discovery errors
Ok(Err(_)) => {}
Err(ref err) if !err.is_critical() => {}
_ => return result,
}
let mut previous_fetch_failed = false;
// If that fails, and a specific patch version was requested try again allowing a
// different patch version
if let Some(request) = match request {
let request_without_patch = match request {
PythonRequest::Version(version) => {
if version.has_patch() {
Some(PythonRequest::Version(version.clone().without_patch()))
@@ -1482,36 +1483,119 @@ pub(crate) fn find_best_python_installation(
PythonRequest::ImplementationVersion(*implementation, version.clone().without_patch()),
),
_ => None,
} {
debug!("Looking for relaxed patch version {request}");
let result = find_python_installation(&request, environments, preference, cache, preview);
match result {
};
for (attempt, request) in iter::once(original_request)
.chain(request_without_patch.iter())
.chain(iter::once(&PythonRequest::Default))
.enumerate()
{
debug!(
"Looking for {request}{}",
if request != original_request {
format!(" attempt {attempt} (fallback after failing to find: {original_request})")
} else {
String::new()
}
);
let result = find_python_installation(request, environments, preference, cache, preview);
let error = match result {
Ok(Ok(installation)) => {
warn_on_unsupported_python(installation.interpreter());
return Ok(Ok(installation));
return Ok(installation);
}
// Continue if we can't find a matching Python and ignore non-critical discovery errors
Ok(Err(_)) => {}
Err(ref err) if !err.is_critical() => {}
_ => return result,
}
}
Ok(Err(error)) => error.into(),
Err(error) if !error.is_critical() => error.into(),
Err(error) => return Err(error.into()),
};
// If a Python version was requested but cannot be fulfilled, just take any version
debug!("Looking for a default Python installation");
let request = PythonRequest::Default;
Ok(
find_python_installation(&request, environments, preference, cache, preview)?.map_err(
|err| {
// Use a more general error in this case since we looked for multiple versions
PythonNotFound {
request,
// Attempt to download the version if downloads are enabled
if downloads_enabled
&& !previous_fetch_failed
&& let Some(download_request) = PythonDownloadRequest::from_request(request)
{
let download = download_request
.clone()
.fill()
.map(|request| download_list.find(&request));
let result = match download {
Ok(Ok(download)) => PythonInstallation::fetch(
download,
client,
retry_policy,
cache,
reporter,
python_install_mirror,
pypy_install_mirror,
preview,
)
.await
.map(Some),
Ok(Err(crate::downloads::Error::NoDownloadFound(_))) => Ok(None),
Ok(Err(error)) => Err(error.into()),
Err(error) => Err(error.into()),
};
if let Ok(Some(installation)) = result {
return Ok(installation);
}
// Emit a warning instead of failing since we may find a suitable
// interpreter on the system after relaxing the request further.
// Additionally, uv did not previously attempt downloads in this
// code path and we want to minimize the fatal cases for
// backwards compatibility.
// Errors encountered here are either network errors or quirky
// configuration problems.
if let Err(error) = result {
// This is a hack to get `write_error_chain` to format things the way we want.
#[derive(Debug, thiserror::Error)]
#[error(
"A managed Python download is available for {0}, but an error occurred when attempting to download it."
)]
struct WrappedError<'a>(&'a PythonRequest, #[source] crate::Error);
// If the request was for the default or any version, propagate
// the error as nothing else we are about to do will help the
// situation.
if matches!(request, PythonRequest::Default | PythonRequest::Any) {
return Err(error);
}
let mut error_chain = String::new();
// Writing to a string can't fail with errors (panics on allocation failure)
uv_warnings::write_error_chain(
&WrappedError(request, error),
&mut error_chain,
"warning",
AnsiColors::Yellow,
)
.unwrap();
anstream::eprint!("{}", error_chain);
previous_fetch_failed = true;
}
}
// If this was a request for the Default or Any version, this means that
// either that's what we were called with, or we're on the last
// iteration.
//
// The most recent find error therefore becomes a fatal one.
if matches!(request, PythonRequest::Default | PythonRequest::Any) {
return Err(match error {
crate::Error::MissingPython(err, _) => PythonNotFound {
// Use a more general error in this case since we looked for multiple versions
request: original_request.clone(),
python_preference: err.python_preference,
environment_preference: err.environment_preference,
}
},
),
)
.into(),
other => other,
});
}
}
unreachable!("The loop should have terminated when it reached PythonRequest::Default");
}
/// Display a warning if the Python version of the [`Interpreter`] is unsupported by uv.
+32 -7
View File
@@ -73,19 +73,44 @@ impl PythonInstallation {
Ok(installation)
}
/// Find an installed [`PythonInstallation`] that satisfies a requested version, if the request cannot
/// be satisfied, fallback to the best available Python installation.
pub fn find_best(
/// Find or download a [`PythonInstallation`] that satisfies a requested version, if the request
/// cannot be satisfied, fallback to the best available Python installation.
pub async fn find_best(
request: &PythonRequest,
environments: EnvironmentPreference,
preference: PythonPreference,
download_list: &ManagedPythonDownloadList,
python_downloads: PythonDownloads,
client_builder: &BaseClientBuilder<'_>,
cache: &Cache,
reporter: Option<&dyn Reporter>,
python_install_mirror: Option<&str>,
pypy_install_mirror: Option<&str>,
python_downloads_json_url: Option<&str>,
preview: Preview,
) -> Result<Self, Error> {
let installation =
find_best_python_installation(request, environments, preference, cache, preview)??;
installation.warn_if_outdated_prerelease(request, download_list);
let retry_policy = client_builder.retry_policy();
let client = client_builder.clone().retries(0).build();
let download_list =
ManagedPythonDownloadList::new(&client, python_downloads_json_url).await?;
let downloads_enabled = preference.allows_managed()
&& python_downloads.is_automatic()
&& client_builder.connectivity.is_online();
let installation = find_best_python_installation(
request,
environments,
preference,
downloads_enabled,
&download_list,
&client,
&retry_policy,
cache,
reporter,
python_install_mirror,
pypy_install_mirror,
preview,
)
.await?;
installation.warn_if_outdated_prerelease(request, &download_list);
Ok(installation)
}
+41 -10
View File
@@ -135,6 +135,7 @@ mod tests {
use indoc::{formatdoc, indoc};
use temp_env::with_vars;
use test_log::test;
use uv_client::BaseClientBuilder;
use uv_preview::Preview;
use uv_static::EnvVars;
@@ -142,8 +143,9 @@ mod tests {
use crate::{
PythonNotFound, PythonRequest, PythonSource, PythonVersion,
implementation::ImplementationName, installation::PythonInstallation,
managed::ManagedPythonInstallations, virtualenv::virtualenv_python_executable,
downloads::ManagedPythonDownloadList, implementation::ImplementationName,
installation::PythonInstallation, managed::ManagedPythonInstallations,
virtualenv::virtualenv_python_executable,
};
use crate::{
PythonPreference,
@@ -989,20 +991,49 @@ mod tests {
Ok(())
}
fn find_best_python_installation_no_download(
request: &PythonRequest,
environments: EnvironmentPreference,
preference: PythonPreference,
cache: &Cache,
preview: Preview,
) -> Result<PythonInstallation, crate::Error> {
let client_builder = BaseClientBuilder::default();
let download_list = ManagedPythonDownloadList::new_only_embedded()?;
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("Failed to build runtime")
.block_on(find_best_python_installation(
request,
environments,
preference,
false,
&download_list,
&client_builder.clone().retries(0).build(),
&client_builder.retry_policy(),
cache,
None,
None,
None,
preview,
))
}
#[test]
fn find_best_python_version_patch_exact() -> Result<()> {
let mut context = TestContext::new()?;
context.add_python_versions(&["3.10.1", "3.11.2", "3.11.4", "3.11.3", "3.12.5"])?;
let python = context.run(|| {
find_best_python_installation(
find_best_python_installation_no_download(
&PythonRequest::parse("3.11.3"),
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
Preview::default(),
)
})??;
})?;
assert!(
matches!(
@@ -1029,14 +1060,14 @@ mod tests {
context.add_python_versions(&["3.10.1", "3.11.2", "3.11.4", "3.11.3", "3.12.5"])?;
let python = context.run(|| {
find_best_python_installation(
find_best_python_installation_no_download(
&PythonRequest::parse("3.11.11"),
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
Preview::default(),
)
})??;
})?;
assert!(
matches!(
@@ -1066,14 +1097,14 @@ mod tests {
let python =
context.run_with_vars(&[(EnvVars::VIRTUAL_ENV, Some(venv.as_os_str()))], || {
find_best_python_installation(
find_best_python_installation_no_download(
&PythonRequest::parse("3.10"),
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
Preview::default(),
)
})??;
})?;
assert!(
matches!(
python,
@@ -1097,14 +1128,14 @@ mod tests {
let python =
context.run_with_vars(&[(EnvVars::VIRTUAL_ENV, Some(venv.as_os_str()))], || {
find_best_python_installation(
find_best_python_installation_no_download(
&PythonRequest::parse("3.10.2"),
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
Preview::default(),
)
})??;
})?;
assert!(
matches!(
python,