From 55b7ad065904d9899ab68b9b4a88952403658bcf Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Wed, 9 Jul 2025 11:32:08 -0500 Subject: [PATCH 1/2] Add hint when Python downloads are disabled --- crates/uv-python/src/installation.rs | 109 +++++++++++++++++++++------ crates/uv-python/src/lib.rs | 20 ++++- crates/uv/src/commands/python/pin.rs | 3 +- crates/uv/tests/it/lock.rs | 6 +- crates/uv/tests/it/python_find.rs | 2 + crates/uv/tests/it/python_install.rs | 6 +- crates/uv/tests/it/python_pin.rs | 46 +++++------ 7 files changed, 133 insertions(+), 59 deletions(-) diff --git a/crates/uv-python/src/installation.rs b/crates/uv-python/src/installation.rs index 35cbf9ce5ebb9..a5dbb55f23625 100644 --- a/crates/uv-python/src/installation.rs +++ b/crates/uv-python/src/installation.rs @@ -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); + }; + + 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); }; - debug!("Requested Python not found, checking for available download..."); - match Self::fetch( - request.fill()?, + // 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 { 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..."); diff --git a/crates/uv-python/src/lib.rs b/crates/uv-python/src/lib.rs index d408bc199ba6b..ea6f0db619837 100644 --- a/crates/uv-python/src/lib.rs +++ b/crates/uv-python/src/lib.rs @@ -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), #[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 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))] diff --git a/crates/uv/src/commands/python/pin.rs b/crates/uv/src/commands/python/pin.rs index f0dc06cff6906..395981751a7d8 100644 --- a/crates/uv/src/commands/python/pin.rs +++ b/crates/uv/src/commands/python/pin.rs @@ -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 } diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index cb9c72c03281e..d5757b6ef919b 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -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(()) } diff --git a/crates/uv/tests/it/python_find.rs b/crates/uv/tests/it/python_find.rs index f438e9b4d9240..b8b42d61bd4e0 100644 --- a/crates/uv/tests/it/python_find.rs +++ b/crates/uv/tests/it/python_find.rs @@ -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' "); } diff --git a/crates/uv/tests/it/python_install.rs b/crates/uv/tests/it/python_install.rs index 0fc89df2106e0..bd723e5d11404 100644 --- a/crates/uv/tests/it/python_install.rs +++ b/crates/uv/tests/it/python_install.rs @@ -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() diff --git a/crates/uv/tests/it/python_pin.rs b/crates/uv/tests/it/python_pin.rs index 4cbc98ab07218..cf8849f42264f 100644 --- a/crates/uv/tests/it/python_pin.rs +++ b/crates/uv/tests/it/python_pin.rs @@ -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(&[]); - - if cfg!(windows) { - uv_snapshot!(context.filters(), context.python_pin().arg("--resolved").arg("3.12"), @r###" - success: false - exit_code: 2 - ----- stdout ----- + 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 ----- - ----- 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 [PYTHON SOURCES] - ----- 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!({ From ccd874182d2fe064608d08025144d1eff53773b7 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Thu, 10 Jul 2025 08:13:41 -0500 Subject: [PATCH 2/2] Add test coverage --- crates/uv/tests/it/common/mod.rs | 6 ++++ crates/uv/tests/it/tool_run.rs | 48 ++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/crates/uv/tests/it/common/mod.rs b/crates/uv/tests/it/common/mod.rs index 7b13c49b5280b..90f436f6f5bf5 100644 --- a/crates/uv/tests/it/common/mod.rs +++ b/crates/uv/tests/it/common/mod.rs @@ -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 } diff --git a/crates/uv/tests/it/tool_run.rs b/crates/uv/tests/it/tool_run.rs index d4dcb216c21ff..153adeb51b051 100644 --- a/crates/uv/tests/it/tool_run.rs +++ b/crates/uv/tests/it/tool_run.rs @@ -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"])