diff --git a/crates/uv-python/src/discovery.rs b/crates/uv-python/src/discovery.rs index eaf4b4830..136d35717 100644 --- a/crates/uv-python/src/discovery.rs +++ b/crates/uv-python/src/discovery.rs @@ -41,7 +41,7 @@ use crate::{BrokenSymlink, Interpreter, PythonInstallationKey, PythonVersion}; /// A request to find a Python installation. /// /// See [`PythonRequest::from_str`]. -#[derive(Debug, Clone, PartialEq, Eq, Default, Hash)] +#[derive(Debug, Clone, Eq, Default, Hash)] pub enum PythonRequest { /// An appropriate default Python installation /// @@ -68,6 +68,12 @@ pub enum PythonRequest { Key(PythonDownloadRequest), } +impl PartialEq for PythonRequest { + fn eq(&self, other: &Self) -> bool { + self.to_canonical_string() == other.to_canonical_string() + } +} + impl<'a> serde::Deserialize<'a> for PythonRequest { fn deserialize(deserializer: D) -> Result where diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index e816e771e..94496a968 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -14,11 +14,13 @@ use uv_distribution_types::{ NameRequirementSpecification, Requirement, RequirementSource, UnresolvedRequirementSpecification, }; +use uv_fs::CWD; use uv_normalize::PackageName; use uv_pep440::{VersionSpecifier, VersionSpecifiers}; use uv_pep508::MarkerTree; use uv_python::{ EnvironmentPreference, PythonDownloads, PythonInstallation, PythonPreference, PythonRequest, + PythonVersionFile, VersionFileDiscoveryOptions, }; use uv_requirements::{RequirementsSource, RequirementsSpecification}; use uv_settings::{PythonInstallMirrors, ResolverInstallerOptions, ToolOptions}; @@ -72,7 +74,25 @@ pub(crate) async fn install( let reporter = PythonDownloadReporter::single(printer); - let python_request = python.as_deref().map(PythonRequest::parse); + let (python_request, explicit_python_request) = if let Some(request) = python.as_deref() { + (Some(PythonRequest::parse(request)), true) + } else { + // Discover a global Python version pin, if no request was made + ( + PythonVersionFile::discover( + // TODO(zanieb): We don't use the directory, should we expose another interface? + // Should `no_local` be implied by `None` here? + &*CWD, + &VersionFileDiscoveryOptions::default() + // TODO(zanieb): Propagate `no_config` from the global options to here + .with_no_config(false) + .with_no_local(true), + ) + .await? + .and_then(PythonVersionFile::into_version), + false, + ) + }; // Pre-emptively identify a Python interpreter. We need an interpreter to resolve any unnamed // requirements, even if we end up using a different interpreter for the tool install itself. @@ -363,13 +383,30 @@ pub(crate) async fn install( environment.interpreter().sys_executable().display() ); true - } else { + } else if explicit_python_request { let _ = writeln!( printer.stderr(), "Ignoring existing environment for `{from}`: the requested Python interpreter does not match the environment interpreter", from = from.name.cyan(), ); false + } else { + // Allow the existing environment if the user didn't explicitly request another + // version + if let Some(ref tool_receipt) = existing_tool_receipt { + if settings.reinstall.is_all() && tool_receipt.python().is_none() && python_request.is_some() { + let _ = writeln!( + printer.stderr(), + "Ignoring existing environment for `{from}`: the Python interpreter does not match the environment interpreter", + from = from.name.cyan(), + ); + false + } else { + true + } + } else { + true + } } }); @@ -602,7 +639,12 @@ pub(crate) async fn install( &installed_tools, options, force || invalid_tool_receipt, - python_request, + // Only persist the Python request if it was explicitly provided + if explicit_python_request { + python_request + } else { + None + }, requirements, constraints, overrides, diff --git a/crates/uv/src/commands/tool/run.rs b/crates/uv/src/commands/tool/run.rs index 2746d65ad..548e28120 100644 --- a/crates/uv/src/commands/tool/run.rs +++ b/crates/uv/src/commands/tool/run.rs @@ -24,11 +24,14 @@ use uv_distribution_types::{ IndexUrl, Name, NameRequirementSpecification, Requirement, RequirementSource, UnresolvedRequirement, UnresolvedRequirementSpecification, }; +use uv_fs::CWD; use uv_fs::Simplified; use uv_installer::{SatisfiesResult, SitePackages}; use uv_normalize::PackageName; use uv_pep440::{VersionSpecifier, VersionSpecifiers}; use uv_pep508::MarkerTree; +use uv_python::PythonVersionFile; +use uv_python::VersionFileDiscoveryOptions; use uv_python::{ EnvironmentPreference, PythonDownloads, PythonEnvironment, PythonInstallation, PythonPreference, PythonRequest, @@ -735,6 +738,23 @@ async fn get_or_create_environment( ToolRequest::Package { .. } => python.map(PythonRequest::parse), }; + // Discover a global Python version pin, if no request was made. + let python_request = if python_request.is_none() { + PythonVersionFile::discover( + // TODO(zanieb): We don't use the directory, should we expose another interface? + // Should `no_local` be implied by `None` here? + &*CWD, + &VersionFileDiscoveryOptions::default() + // TODO(zanieb): Propagate `no_config` from the global options to here + .with_no_config(false) + .with_no_local(true), + ) + .await? + .and_then(PythonVersionFile::into_version) + } else { + python_request + }; + // Discover an interpreter. let interpreter = PythonInstallation::find_or_download( python_request.as_ref(), diff --git a/crates/uv/tests/it/tool_install.rs b/crates/uv/tests/it/tool_install.rs index 88e73406f..d1a066f22 100644 --- a/crates/uv/tests/it/tool_install.rs +++ b/crates/uv/tests/it/tool_install.rs @@ -1,6 +1,7 @@ use std::process::Command; use anyhow::Result; +use assert_cmd::assert::OutputAssertExt; use assert_fs::{ assert::PathAssert, fixture::{FileTouch, FileWriteStr, PathChild}, @@ -178,15 +179,20 @@ fn tool_install() { } #[test] -fn tool_install_with_global_python() -> Result<()> { - let context = TestContext::new_with_versions(&["3.11", "3.12"]) +fn tool_install_python_from_global_version_file() { + let context = TestContext::new_with_versions(&["3.11", "3.12", "3.13"]) .with_filtered_counts() .with_filtered_exe_suffix(); let tool_dir = context.temp_dir.child("tools"); let bin_dir = context.temp_dir.child("bin"); - let uv = context.user_config_dir.child("uv"); - let versions = uv.child(".python-version"); - versions.write_str("3.11")?; + + // Pin to 3.12 + context + .python_pin() + .arg("3.12") + .arg("--global") + .assert() + .success(); // Install a tool uv_snapshot!(context.filters(), context.tool_install() @@ -212,14 +218,147 @@ fn tool_install_with_global_python() -> Result<()> { Installed 1 executable: flask "###); - tool_dir.child("flask").assert(predicate::path::is_dir()); - assert!( - bin_dir - .child(format!("flask{}", std::env::consts::EXE_SUFFIX)) - .exists() - ); + // It should use the version from the global file + uv_snapshot!(context.filters(), Command::new("flask").arg("--version").env(EnvVars::PATH, bin_dir.as_os_str()), @r" + success: true + exit_code: 0 + ----- stdout ----- + Python 3.12.[X] + Flask 3.0.2 + Werkzeug 3.0.1 - uv_snapshot!(context.filters(), Command::new("flask").arg("--version").env(EnvVars::PATH, bin_dir.as_os_str()), @r###" + ----- stderr ----- + "); + + // Change global version + context + .python_pin() + .arg("3.13") + .arg("--global") + .assert() + .success(); + + // Installing flask again should be a no-op, even though the global pin changed + uv_snapshot!(context.filters(), context.tool_install() + .arg("flask") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) + .env(EnvVars::PATH, bin_dir.as_os_str()), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + `flask` is already installed + "); + + uv_snapshot!(context.filters(), Command::new("flask").arg("--version").env(EnvVars::PATH, bin_dir.as_os_str()), @r" + success: true + exit_code: 0 + ----- stdout ----- + Python 3.12.[X] + Flask 3.0.2 + Werkzeug 3.0.1 + + ----- stderr ----- + "); + + // Using `--upgrade` forces us to check the environment + uv_snapshot!(context.filters(), context.tool_install() + .arg("flask") + .arg("--upgrade") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) + .env(EnvVars::PATH, bin_dir.as_os_str()), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved [N] packages in [TIME] + Audited [N] packages in [TIME] + Installed 1 executable: flask + "); + + // This will not change to the new global pin, since there was not a reinstall request + uv_snapshot!(context.filters(), Command::new("flask").arg("--version").env(EnvVars::PATH, bin_dir.as_os_str()), @r" + success: true + exit_code: 0 + ----- stdout ----- + Python 3.12.[X] + Flask 3.0.2 + Werkzeug 3.0.1 + + ----- stderr ----- + "); + + // Using `--reinstall` forces us to install flask again + uv_snapshot!(context.filters(), context.tool_install() + .arg("flask") + .arg("--reinstall") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) + .env(EnvVars::PATH, bin_dir.as_os_str()), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Ignoring existing environment for `flask`: the Python interpreter does not match the environment interpreter + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + blinker==1.7.0 + + click==8.1.7 + + flask==3.0.2 + + itsdangerous==2.1.2 + + jinja2==3.1.3 + + markupsafe==2.1.5 + + werkzeug==3.0.1 + Installed 1 executable: flask + "); + + // This will change to the new global pin, since there was not an explicit request recorded in + // the receipt + uv_snapshot!(context.filters(), Command::new("flask").arg("--version").env(EnvVars::PATH, bin_dir.as_os_str()), @r" + success: true + exit_code: 0 + ----- stdout ----- + Python 3.13.[X] + Flask 3.0.2 + Werkzeug 3.0.1 + + ----- stderr ----- + "); + + // If we request a specific Python version, it takes precedence over the pin + uv_snapshot!(context.filters(), context.tool_install() + .arg("flask") + .arg("--python") + .arg("3.11") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) + .env(EnvVars::PATH, bin_dir.as_os_str()), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Ignoring existing environment for `flask`: the requested Python interpreter does not match the environment interpreter + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + blinker==1.7.0 + + click==8.1.7 + + flask==3.0.2 + + itsdangerous==2.1.2 + + jinja2==3.1.3 + + markupsafe==2.1.5 + + werkzeug==3.0.1 + Installed 1 executable: flask + "); + + uv_snapshot!(context.filters(), Command::new("flask").arg("--version").env(EnvVars::PATH, bin_dir.as_os_str()), @r" success: true exit_code: 0 ----- stdout ----- @@ -228,21 +367,9 @@ fn tool_install_with_global_python() -> Result<()> { Werkzeug 3.0.1 ----- stderr ----- - "###); + "); - // Change global version - uv_snapshot!(context.filters(), context.python_pin().arg("3.12").arg("--global"), - @r" - success: true - exit_code: 0 - ----- stdout ----- - Updated `[UV_USER_CONFIG_DIR]/.python-version` from `3.11` -> `3.12` - - ----- stderr ----- - " - ); - - // Install flask again + // Use `--reinstall` to install flask again uv_snapshot!(context.filters(), context.tool_install() .arg("flask") .arg("--reinstall") @@ -268,9 +395,8 @@ fn tool_install_with_global_python() -> Result<()> { Installed 1 executable: flask "); - // Currently, when reinstalling a tool we use the original version the tool - // was installed with, not the most up-to-date global version - uv_snapshot!(context.filters(), Command::new("flask").arg("--version").env(EnvVars::PATH, bin_dir.as_os_str()), @r###" + // We should continue to use the version from the install, not the global pin + uv_snapshot!(context.filters(), Command::new("flask").arg("--version").env(EnvVars::PATH, bin_dir.as_os_str()), @r" success: true exit_code: 0 ----- stdout ----- @@ -279,9 +405,7 @@ fn tool_install_with_global_python() -> Result<()> { Werkzeug 3.0.1 ----- stderr ----- - "###); - - Ok(()) + "); } #[test] diff --git a/crates/uv/tests/it/tool_run.rs b/crates/uv/tests/it/tool_run.rs index a8bcd5a05..7522c2ef1 100644 --- a/crates/uv/tests/it/tool_run.rs +++ b/crates/uv/tests/it/tool_run.rs @@ -2049,6 +2049,33 @@ fn tool_run_python_at_version() { "###); } +#[test] +fn tool_run_python_from_global_version_file() { + let context = TestContext::new_with_versions(&["3.12", "3.11"]) + .with_filtered_counts() + .with_filtered_python_sources(); + + context + .python_pin() + .arg("3.11") + .arg("--global") + .assert() + .success(); + + uv_snapshot!(context.filters(), context.tool_run() + .arg("python") + .arg("--version"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Python 3.12.[X] + + ----- stderr ----- + Resolved in [TIME] + Audited in [TIME] + "###); +} + #[test] fn tool_run_python_from() { let context = TestContext::new_with_versions(&["3.12", "3.11"])