diff --git a/crates/uv-python/src/discovery.rs b/crates/uv-python/src/discovery.rs index 5fe1bbb85f644..7ad61279dc2c4 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)] pub enum PythonRequest { /// An appropriate default Python installation /// @@ -68,6 +68,18 @@ pub enum PythonRequest { Key(PythonDownloadRequest), } +impl PartialEq for PythonRequest { + fn eq(&self, other: &Self) -> bool { + self.to_canonical_string() == other.to_canonical_string() + } +} + +impl std::hash::Hash for PythonRequest { + fn hash(&self, state: &mut H) { + self.to_canonical_string().hash(state); + } +} + 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 04b5ef8840632..dfb9d56275490 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -14,13 +14,15 @@ use uv_distribution_types::{ ExtraBuildRequires, NameRequirementSpecification, Requirement, RequirementSource, UnresolvedRequirementSpecification, }; +use uv_fs::CWD; use uv_installer::{InstallationStrategy, SatisfiesResult, SitePackages}; use uv_normalize::PackageName; use uv_pep440::{VersionSpecifier, VersionSpecifiers}; use uv_pep508::MarkerTree; use uv_preview::Preview; use uv_python::{ - EnvironmentPreference, PythonDownloads, PythonInstallation, PythonPreference, PythonRequest, + EnvironmentPreference, Interpreter, PythonDownloads, PythonEnvironment, PythonInstallation, + PythonPreference, PythonRequest, PythonVersionFile, VersionFileDiscoveryOptions, }; use uv_requirements::{RequirementsSource, RequirementsSpecification}; use uv_settings::{PythonInstallMirrors, ResolverInstallerOptions, ToolOptions}; @@ -66,13 +68,31 @@ pub(crate) async fn install( python_downloads: PythonDownloads, installer_metadata: bool, concurrency: Concurrency, + no_config: bool, cache: Cache, printer: Printer, preview: Preview, ) -> Result { 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() + .with_no_config(no_config) + .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. @@ -344,26 +364,20 @@ pub(crate) async fn install( } }; - let existing_environment = - installed_tools - .get_environment(package_name, &cache)? - .filter(|environment| { - if environment.uses(&interpreter) { - trace!( - "Existing interpreter matches the requested interpreter for `{}`: {}", - package_name, - environment.interpreter().sys_executable().display() - ); - true - } else { - let _ = writeln!( - printer.stderr(), - "Ignoring existing environment for `{}`: the requested Python interpreter does not match the environment interpreter", - package_name.cyan(), - ); - false - } - }); + let existing_environment = installed_tools + .get_environment(package_name, &cache)? + .filter(|environment| { + existing_environment_usable( + environment, + &interpreter, + package_name, + python_request.as_ref(), + explicit_python_request, + &settings, + existing_tool_receipt.as_ref(), + printer, + ) + }); // If the requested and receipt requirements are the same... if let Some(environment) = existing_environment.as_ref().filter(|_| { @@ -394,9 +408,13 @@ pub(crate) async fn install( ) .into_inner(); - // Determine the markers and tags to use for the resolution. - let markers = resolution_markers(None, python_platform.as_ref(), &interpreter); - let tags = resolution_tags(None, python_platform.as_ref(), &interpreter)?; + // Determine the markers and tags to use for the resolution. We use the existing + // environment for markers here — above we filter the environment to `None` if + // `existing_environment_usable` is `false`, so we've determined it's valid. + let markers = + resolution_markers(None, python_platform.as_ref(), environment.interpreter()); + let tags = + resolution_tags(None, python_platform.as_ref(), environment.interpreter())?; // Check if the installed packages meet the requirements. let site_packages = SitePackages::from_environment(environment)?; @@ -640,7 +658,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, @@ -650,3 +673,54 @@ pub(crate) async fn install( Ok(ExitStatus::Success) } + +fn existing_environment_usable( + environment: &PythonEnvironment, + interpreter: &Interpreter, + package_name: &PackageName, + python_request: Option<&PythonRequest>, + explicit_python_request: bool, + settings: &ResolverInstallerSettings, + existing_tool_receipt: Option<&uv_tool::Tool>, + printer: Printer, +) -> bool { + // If the environment matches the interpreter, it's usable + if environment.uses(interpreter) { + trace!( + "Existing interpreter matches the requested interpreter for `{}`: {}", + package_name, + environment.interpreter().sys_executable().display() + ); + return true; + } + + // If there was an explicit Python request that does not match, we'll invalidate the + // environment. + if explicit_python_request { + let _ = writeln!( + printer.stderr(), + "Ignoring existing environment for `{}`: the requested Python interpreter does not match the environment interpreter", + package_name.cyan(), + ); + return false; + } + + // Otherwise, we'll invalidate the environment if all of the following are true: + // - The user requested a reinstall + // - The tool was not previously pinned to a Python version + // - There is _some_ alternative Python request + if let Some(tool_receipt) = existing_tool_receipt + && 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 = package_name.cyan(), + ); + return false; + } + + true +} diff --git a/crates/uv/src/commands/tool/run.rs b/crates/uv/src/commands/tool/run.rs index e2855708913dd..b97825b84d08c 100644 --- a/crates/uv/src/commands/tool/run.rs +++ b/crates/uv/src/commands/tool/run.rs @@ -25,12 +25,15 @@ use uv_distribution_types::{ IndexUrl, Name, NameRequirementSpecification, Requirement, RequirementSource, UnresolvedRequirement, UnresolvedRequirementSpecification, }; +use uv_fs::CWD; use uv_fs::Simplified; use uv_installer::{InstallationStrategy, SatisfiesResult, SitePackages}; use uv_normalize::PackageName; use uv_pep440::{VersionSpecifier, VersionSpecifiers}; use uv_pep508::MarkerTree; use uv_preview::Preview; +use uv_python::PythonVersionFile; +use uv_python::VersionFileDiscoveryOptions; use uv_python::{ EnvironmentPreference, PythonDownloads, PythonEnvironment, PythonInstallation, PythonPreference, PythonRequest, @@ -699,43 +702,38 @@ async fn get_or_create_environment( ) -> Result<(ToolRequirement, PythonEnvironment), ProjectError> { let reporter = PythonDownloadReporter::single(printer); - // Figure out what Python we're targeting, either explicitly like `uvx python@3`, or via the - // -p/--python flag. - let python_request = match request { - ToolRequest::Python { - request: tool_python_request, - .. - } => { - match python { - None => Some(tool_python_request.clone()), - - // The user is both invoking a python interpreter directly and also supplying the - // -p/--python flag. Cases like `uvx -p pypy python` are allowed, for two reasons: - // 1) Previously this was the only way to invoke e.g. PyPy via `uvx`, and it's nice - // to remain compatible with that. 2) A script might define an alias like `uvx - // --python $MY_PYTHON ...`, and it's nice to be able to run the interpreter - // directly while sticking to that alias. - // - // However, we want to error out if we see conflicting or redundant versions like - // `uvx -p python38 python39`. - // - // Note that a command like `uvx default` doesn't bring us here. ToolRequest::parse - // returns ToolRequest::Package rather than ToolRequest::Python in that case. See - // PythonRequest::try_from_tool_name. - Some(python_flag) => { - if tool_python_request != &PythonRequest::Default { - return Err(anyhow::anyhow!( - "Received multiple Python version requests: `{}` and `{}`", - python_flag.to_string().cyan(), - tool_python_request.to_canonical_string().cyan() - ) - .into()); - } - Some(PythonRequest::parse(python_flag)) - } - } + // Determine explicit Python version requests + let explicit_python_request = python.map(PythonRequest::parse); + let tool_python_request = match request { + ToolRequest::Python { request, .. } => Some(request.clone()), + ToolRequest::Package { .. } => None, + }; + + // Resolve Python request with version file lookup when no explicit request + let python_request = match (explicit_python_request, tool_python_request) { + // e.g., `uvx --python 3.10 python3.12` + (Some(explicit), Some(tool_request)) if tool_request != PythonRequest::Default => { + // Conflict: both --python flag and versioned tool name + return Err(anyhow::anyhow!( + "Received multiple Python version requests: `{}` and `{}`", + explicit.to_canonical_string().cyan(), + tool_request.to_canonical_string().cyan() + ) + .into()); } - ToolRequest::Package { .. } => python.map(PythonRequest::parse), + // e.g, `uvx --python 3.10 ...` + (Some(explicit), _) => Some(explicit), + // e.g., `uvx python` or `uvx ` + (None, Some(PythonRequest::Default) | None) => PythonVersionFile::discover( + &*CWD, + &VersionFileDiscoveryOptions::default() + .with_no_config(false) + .with_no_local(true), + ) + .await? + .and_then(PythonVersionFile::into_version), + // e.g., `uvx python3.12` + (None, Some(tool_request)) => Some(tool_request), }; // Discover an interpreter. diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 9dc667b7ebbf6..9993520f6e07b 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1387,6 +1387,7 @@ async fn run(mut cli: Cli) -> Result { globals.python_downloads, globals.installer_metadata, globals.concurrency, + cli.top_level.no_config, cache, printer, globals.preview, diff --git a/crates/uv/tests/it/tool_install.rs b/crates/uv/tests/it/tool_install.rs index ced7a778232c7..05968d4fdf27c 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,37 +218,158 @@ 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() - ); - - uv_snapshot!(context.filters(), Command::new("flask").arg("--version").env(EnvVars::PATH, bin_dir.as_os_str()), @r###" + // 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.11.[X] + Python 3.12.[X] Flask 3.0.2 Werkzeug 3.0.1 ----- stderr ----- - "###); + "); // Change global version - uv_snapshot!(context.filters(), context.python_pin().arg("3.12").arg("--global"), - @r" + 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 ----- - Updated `[UV_USER_CONFIG_DIR]/.python-version` from `3.11` -> `3.12` ----- stderr ----- - " - ); + `flask` is already installed + "); - // Install flask again + 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 ----- + Python 3.11.[X] + Flask 3.0.2 + Werkzeug 3.0.1 + + ----- stderr ----- + "); + + // 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 07396b596f06d..b1ef1e4b95123 100644 --- a/crates/uv/tests/it/tool_run.rs +++ b/crates/uv/tests/it/tool_run.rs @@ -2145,6 +2145,93 @@ fn tool_run_hint_version_not_available() { "); } +#[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.11.[X] + + ----- stderr ----- + Resolved in [TIME] + Audited in [TIME] + "###); +} + +#[test] +fn tool_run_python_version_overrides_global_pin() { + let context = TestContext::new_with_versions(&["3.12", "3.11"]) + .with_filtered_counts() + .with_filtered_python_sources(); + + // Set global pin to 3.11 + context + .python_pin() + .arg("3.11") + .arg("--global") + .assert() + .success(); + + // Explicitly request python3.12, should override global pin + uv_snapshot!(context.filters(), context.tool_run() + .arg("python3.12") + .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_with_explicit_default_bypasses_global_pin() { + let context = TestContext::new_with_versions(&["3.12", "3.11"]) + .with_filtered_counts() + .with_filtered_python_sources(); + + // Set global pin to 3.11 + context + .python_pin() + .arg("3.11") + .arg("--global") + .assert() + .success(); + + // Explicitly request --python default, should bypass global pin and use system default (3.12) + uv_snapshot!(context.filters(), context.tool_run() + .arg("--python") + .arg("default") + .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"])