Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 84 additions & 25 deletions crates/uv-python/src/installation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,67 +107,126 @@ 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
_ => return Err(err),
}

// 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<Self, Error> {
let installations = ManagedPythonInstallations::from_settings(None)?.init()?;
let installations_dir = installations.root();
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...");
Expand Down
20 changes: 18 additions & 2 deletions crates/uv-python/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
//! Find requested Python interpreters and query interpreters for information.
use owo_colors::OwoColorize;
use thiserror::Error;

#[cfg(test)]
Expand Down Expand Up @@ -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),
Expand All @@ -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))]
Expand Down
3 changes: 2 additions & 1 deletion crates/uv/src/commands/python/pin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
6 changes: 4 additions & 2 deletions crates/uv/tests/it/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
Expand Down
2 changes: 2 additions & 0 deletions crates/uv/tests/it/python_find.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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'
");
}

Expand Down
6 changes: 4 additions & 2 deletions crates/uv/tests/it/python_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
46 changes: 19 additions & 27 deletions crates/uv/tests/it/python_pin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,15 +164,15 @@ 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 -----
Updated `.python-version` from `[PYTHON-3.11]` -> `pypy`

----- 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###"
Expand Down Expand Up @@ -361,15 +361,15 @@ 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 -----
Pinned `.python-version` to `3.12`

----- stderr -----
warning: No interpreter found for Python 3.12 in managed installations or search path
"###);
");
}

#[test]
Expand Down Expand Up @@ -448,15 +448,15 @@ 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 -----
Updated `.python-version` from `3.11` -> `3.13t`

----- 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("[email protected]"), @r###"
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to also add tests for the PythonDownloads::Manual and PythonPreference::OnlySystem hints?

");

let python_version = context.read(PYTHON_VERSION_FILENAME);
insta::with_settings!({
Expand Down
Loading