Add hint when Python downloads are disabled (#14522)

Follow-up to https://github.com/astral-sh/uv/pull/14509 to provide the
_reason_ downloads are disabled and surface it as a hint rather than a
debug log.

e.g.,

```
❯ cargo run -q -- run --no-managed-python -p 3.13.4 python
error: No interpreter found for Python 3.13.4 in virtual environments or search path

hint: A managed Python download is available for Python 3.13.4, but the Python preference is set to 'only system'
```
This commit is contained in:
Zanie Blue 2025-07-10 12:06:24 -05:00 committed by GitHub
parent 1dff18897a
commit 02345a5a7d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 187 additions and 59 deletions

View file

@ -107,18 +107,9 @@ impl PythonInstallation {
Err(err) => err,
};
let downloads_enabled = preference.allows_managed()
&& python_downloads.is_automatic()
&& client_builder.connectivity.is_online();
if !downloads_enabled {
debug!("Python downloads are disabled. Skipping check for available downloads...");
return Err(err);
}
match err {
// If Python is missing, we should attempt a download
Error::MissingPython(_) => {}
Error::MissingPython(..) => {}
// If we raised a non-critical error, we should attempt a download
Error::Discovery(ref err) if !err.is_critical() => {}
// Otherwise, this is fatal
@ -126,40 +117,109 @@ impl PythonInstallation {
}
// If we can't convert the request to a download, throw the original error
let Some(request) = PythonDownloadRequest::from_request(request) else {
let Some(download_request) = PythonDownloadRequest::from_request(request) else {
return Err(err);
};
debug!("Requested Python not found, checking for available download...");
match Self::fetch(
request.fill()?,
let downloads_enabled = preference.allows_managed()
&& python_downloads.is_automatic()
&& client_builder.connectivity.is_online();
let download = download_request.clone().fill().map(|request| {
ManagedPythonDownload::from_request(&request, python_downloads_json_url)
});
// Regardless of whether downloads are enabled, we want to determine if the download is
// available to power error messages. However, if downloads aren't enabled, we don't want to
// report any errors related to them.
let download = match download {
Ok(Ok(download)) => Some(download),
// If the download cannot be found, return the _original_ discovery error
Ok(Err(downloads::Error::NoDownloadFound(_))) => {
if downloads_enabled {
debug!("No downloads are available for {request}");
return Err(err);
}
None
}
Err(err) | Ok(Err(err)) => {
if downloads_enabled {
// We failed to determine the platform information
return Err(err.into());
}
None
}
};
let Some(download) = download else {
// N.B. We should only be in this case when downloads are disabled; when downloads are
// enabled, we should fail eagerly when something goes wrong with the download.
debug_assert!(!downloads_enabled);
return Err(err);
};
// If the download is available, but not usable, we attach a hint to the original error.
if !downloads_enabled {
let for_request = match request {
PythonRequest::Default | PythonRequest::Any => String::new(),
_ => format!(" for {request}"),
};
match python_downloads {
PythonDownloads::Automatic => {}
PythonDownloads::Manual => {
return Err(err.with_missing_python_hint(format!(
"A managed Python download is available{for_request}, but Python downloads are set to 'manual', use `uv python install {}` to install the required version",
request.to_canonical_string(),
)));
}
PythonDownloads::Never => {
return Err(err.with_missing_python_hint(format!(
"A managed Python download is available{for_request}, but Python downloads are set to 'never'"
)));
}
}
match preference {
PythonPreference::OnlySystem => {
return Err(err.with_missing_python_hint(format!(
"A managed Python download is available{for_request}, but the Python preference is set to 'only system'"
)));
}
PythonPreference::Managed
| PythonPreference::OnlyManaged
| PythonPreference::System => {}
}
if !client_builder.connectivity.is_online() {
return Err(err.with_missing_python_hint(format!(
"A managed Python download is available{for_request}, but uv is set to offline mode"
)));
}
return Err(err);
}
Self::fetch(
download,
client_builder,
cache,
reporter,
python_install_mirror,
pypy_install_mirror,
python_downloads_json_url,
preview,
)
.await
{
Ok(installation) => Ok(installation),
// Throw the original error if we couldn't find a download
Err(Error::Download(downloads::Error::NoDownloadFound(_))) => Err(err),
// But if the download failed, throw that error
Err(err) => Err(err),
}
}
/// Download and install the requested installation.
pub async fn fetch(
request: PythonDownloadRequest,
download: &'static ManagedPythonDownload,
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: PreviewMode,
) -> Result<Self, Error> {
let installations = ManagedPythonInstallations::from_settings(None)?.init()?;
@ -167,7 +227,6 @@ impl PythonInstallation {
let scratch_dir = installations.scratch();
let _lock = installations.lock().await?;
let download = ManagedPythonDownload::from_request(&request, python_downloads_json_url)?;
let client = client_builder.build();
info!("Fetching requested Python...");

View file

@ -1,4 +1,5 @@
//! Find requested Python interpreters and query interpreters for information.
use owo_colors::OwoColorize;
use thiserror::Error;
#[cfg(test)]
@ -93,8 +94,8 @@ pub enum Error {
#[error(transparent)]
KeyError(#[from] installation::PythonInstallationKeyError),
#[error(transparent)]
MissingPython(#[from] PythonNotFound),
#[error("{}{}", .0, if let Some(hint) = .1 { format!("\n\n{}{} {hint}", "hint".bold().cyan(), ":".bold()) } else { String::new() })]
MissingPython(PythonNotFound, Option<String>),
#[error(transparent)]
MissingEnvironment(#[from] environment::EnvironmentNotFound),
@ -103,6 +104,21 @@ pub enum Error {
InvalidEnvironment(#[from] environment::InvalidEnvironment),
}
impl Error {
pub(crate) fn with_missing_python_hint(self, hint: String) -> Self {
match self {
Error::MissingPython(err, _) => Error::MissingPython(err, Some(hint)),
_ => self,
}
}
}
impl From<PythonNotFound> for Error {
fn from(err: PythonNotFound) -> Self {
Error::MissingPython(err, None)
}
}
// The mock interpreters are not valid on Windows so we don't have unit test coverage there
// TODO(zanieb): We should write a mock interpreter script that works on Windows
#[cfg(all(test, unix))]

View file

@ -129,7 +129,8 @@ pub(crate) async fn pin(
{
Ok(python) => Some(python),
// If no matching Python version is found, don't fail unless `resolved` was requested
Err(uv_python::Error::MissingPython(err)) if !resolved => {
Err(uv_python::Error::MissingPython(err, ..)) if !resolved => {
// N.B. We omit the hint and just show the inner error message
warn_user_once!("{err}");
None
}

View file

@ -195,6 +195,12 @@ impl TestContext {
"managed installations, search path, or registry".to_string(),
"[PYTHON SOURCES]".to_string(),
));
self.filters.push((
"registry or search path".to_string(),
"[PYTHON SOURCES]".to_string(),
));
self.filters
.push(("search path".to_string(), "[PYTHON SOURCES]".to_string()));
self
}

View file

@ -4318,14 +4318,16 @@ fn lock_requires_python() -> Result<()> {
// Install from the lockfile.
// Note we need to disable Python fetches or we'll just download 3.12
uv_snapshot!(context_unsupported.filters(), context_unsupported.sync().arg("--frozen").arg("--no-python-downloads"), @r###"
uv_snapshot!(context_unsupported.filters(), context_unsupported.sync().arg("--frozen").arg("--no-python-downloads"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: No interpreter found for Python >=3.12 in [PYTHON SOURCES]
"###);
hint: A managed Python download is available for Python >=3.12, but Python downloads are set to 'never'
");
Ok(())
}

View file

@ -873,6 +873,8 @@ fn python_find_script_python_not_found() {
----- stderr -----
No interpreter found in [PYTHON SOURCES]
hint: A managed Python download is available, but Python downloads are set to 'never'
");
}

View file

@ -196,14 +196,16 @@ fn python_install_automatic() {
uv_snapshot!(context.filters(), context.run()
.env_remove("VIRTUAL_ENV")
.arg("--no-python-downloads")
.arg("python").arg("-c").arg("import sys; print(sys.version_info[:2])"), @r###"
.arg("python").arg("-c").arg("import sys; print(sys.version_info[:2])"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: No interpreter found in [PYTHON SOURCES]
"###);
hint: A managed Python download is available, but Python downloads are set to 'never'
");
// Otherwise, we should fetch the latest Python version
uv_snapshot!(context.filters(), context.run()

View file

@ -164,7 +164,7 @@ fn python_pin() {
// (skip on Windows because the snapshot is different and the behavior is not platform dependent)
#[cfg(unix)]
{
uv_snapshot!(context.filters(), context.python_pin().arg("pypy"), @r###"
uv_snapshot!(context.filters(), context.python_pin().arg("pypy"), @r"
success: true
exit_code: 0
----- stdout -----
@ -172,7 +172,7 @@ fn python_pin() {
----- stderr -----
warning: No interpreter found for PyPy in managed installations or search path
"###);
");
let python_version = context.read(PYTHON_VERSION_FILENAME);
assert_snapshot!(python_version, @r###"
@ -361,7 +361,7 @@ fn python_pin_global_creates_parent_dirs() {
fn python_pin_no_python() {
let context: TestContext = TestContext::new_with_versions(&[]);
uv_snapshot!(context.filters(), context.python_pin().arg("3.12"), @r###"
uv_snapshot!(context.filters(), context.python_pin().arg("3.12"), @r"
success: true
exit_code: 0
----- stdout -----
@ -369,7 +369,7 @@ fn python_pin_no_python() {
----- stderr -----
warning: No interpreter found for Python 3.12 in managed installations or search path
"###);
");
}
#[test]
@ -448,7 +448,7 @@ fn python_pin_compatible_with_requires_python() -> Result<()> {
"###);
// Request a version that is compatible and uses a Python variant
uv_snapshot!(context.filters(), context.python_pin().arg("3.13t"), @r###"
uv_snapshot!(context.filters(), context.python_pin().arg("3.13t"), @r"
success: true
exit_code: 0
----- stdout -----
@ -456,7 +456,7 @@ fn python_pin_compatible_with_requires_python() -> Result<()> {
----- stderr -----
warning: No interpreter found for Python 3.13t in [PYTHON SOURCES]
"###);
");
// Request a implementation version that is compatible
uv_snapshot!(context.filters(), context.python_pin().arg("cpython@3.11"), @r###"
@ -587,27 +587,17 @@ fn warning_pinned_python_version_not_installed() -> Result<()> {
/// We do need a Python interpreter for `--resolved` pins
#[test]
fn python_pin_resolve_no_python() {
let context: TestContext = TestContext::new_with_versions(&[]);
let context: TestContext = TestContext::new_with_versions(&[]).with_filtered_python_sources();
uv_snapshot!(context.filters(), context.python_pin().arg("--resolved").arg("3.12"), @r"
success: false
exit_code: 2
----- stdout -----
if cfg!(windows) {
uv_snapshot!(context.filters(), context.python_pin().arg("--resolved").arg("3.12"), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: No interpreter found for Python 3.12 in [PYTHON SOURCES]
----- stderr -----
error: No interpreter found for Python 3.12 in managed installations, search path, or registry
"###);
} else {
uv_snapshot!(context.filters(), context.python_pin().arg("--resolved").arg("3.12"), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: No interpreter found for Python 3.12 in managed installations or search path
"###);
}
hint: A managed Python download is available for Python 3.12, but Python downloads are set to 'never'
");
}
#[test]
@ -741,14 +731,16 @@ fn python_pin_resolve() {
// Request an implementation that is not installed
// (skip on Windows because the snapshot is different and the behavior is not platform dependent)
#[cfg(unix)]
uv_snapshot!(context.filters(), context.python_pin().arg("--resolved").arg("pypy"), @r###"
uv_snapshot!(context.filters(), context.python_pin().arg("--resolved").arg("pypy"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: No interpreter found for PyPy in managed installations or search path
"###);
hint: A managed Python download is available for PyPy, but Python downloads are set to 'never'
");
let python_version = context.read(PYTHON_VERSION_FILENAME);
insta::with_settings!({

View file

@ -2049,6 +2049,54 @@ fn tool_run_python_at_version() {
"###);
}
#[test]
fn tool_run_hint_version_not_available() {
let context = TestContext::new_with_versions(&[])
.with_filtered_counts()
.with_filtered_python_sources();
uv_snapshot!(context.filters(), context.tool_run()
.arg("python@3.12")
.env(EnvVars::UV_PYTHON_DOWNLOADS, "never"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: No interpreter found for Python 3.12 in [PYTHON SOURCES]
hint: A managed Python download is available for Python 3.12, but Python downloads are set to 'never'
");
uv_snapshot!(context.filters(), context.tool_run()
.arg("python@3.12")
.env(EnvVars::UV_PYTHON_DOWNLOADS, "auto")
.env(EnvVars::UV_OFFLINE, "true"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: No interpreter found for Python 3.12 in [PYTHON SOURCES]
hint: A managed Python download is available for Python 3.12, but uv is set to offline mode
");
uv_snapshot!(context.filters(), context.tool_run()
.arg("python@3.12")
.env(EnvVars::UV_PYTHON_DOWNLOADS, "auto")
.env(EnvVars::UV_NO_MANAGED_PYTHON, "true"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: No interpreter found for Python 3.12 in [PYTHON SOURCES]
hint: A managed Python download is available for Python 3.12, but the Python preference is set to 'only system'
");
}
#[test]
fn tool_run_python_from() {
let context = TestContext::new_with_versions(&["3.12", "3.11"])