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:
committed by
GitHub
parent
8a02f6352a
commit
f7d1215a98
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user