Respect global Python version pins in `uv tool run` and `uv tool install`

This commit is contained in:
Zanie Blue 2025-10-08 09:00:43 -05:00
parent 39e2e3e74b
commit bc87b2151d
6 changed files with 390 additions and 94 deletions

View File

@ -41,7 +41,7 @@ use crate::{BrokenSymlink, Interpreter, PythonInstallationKey, PythonVersion};
/// A request to find a Python installation. /// A request to find a Python installation.
/// ///
/// See [`PythonRequest::from_str`]. /// See [`PythonRequest::from_str`].
#[derive(Debug, Clone, PartialEq, Eq, Default, Hash)] #[derive(Debug, Clone, Eq, Default)]
pub enum PythonRequest { pub enum PythonRequest {
/// An appropriate default Python installation /// An appropriate default Python installation
/// ///
@ -68,6 +68,18 @@ pub enum PythonRequest {
Key(PythonDownloadRequest), 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<H: std::hash::Hasher>(&self, state: &mut H) {
self.to_canonical_string().hash(state);
}
}
impl<'a> serde::Deserialize<'a> for PythonRequest { impl<'a> serde::Deserialize<'a> for PythonRequest {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where where

View File

@ -14,13 +14,15 @@ use uv_distribution_types::{
ExtraBuildRequires, NameRequirementSpecification, Requirement, RequirementSource, ExtraBuildRequires, NameRequirementSpecification, Requirement, RequirementSource,
UnresolvedRequirementSpecification, UnresolvedRequirementSpecification,
}; };
use uv_fs::CWD;
use uv_installer::{InstallationStrategy, SatisfiesResult, SitePackages}; use uv_installer::{InstallationStrategy, SatisfiesResult, SitePackages};
use uv_normalize::PackageName; use uv_normalize::PackageName;
use uv_pep440::{VersionSpecifier, VersionSpecifiers}; use uv_pep440::{VersionSpecifier, VersionSpecifiers};
use uv_pep508::MarkerTree; use uv_pep508::MarkerTree;
use uv_preview::Preview; use uv_preview::Preview;
use uv_python::{ use uv_python::{
EnvironmentPreference, PythonDownloads, PythonInstallation, PythonPreference, PythonRequest, EnvironmentPreference, Interpreter, PythonDownloads, PythonEnvironment, PythonInstallation,
PythonPreference, PythonRequest, PythonVersionFile, VersionFileDiscoveryOptions,
}; };
use uv_requirements::{RequirementsSource, RequirementsSpecification}; use uv_requirements::{RequirementsSource, RequirementsSpecification};
use uv_settings::{PythonInstallMirrors, ResolverInstallerOptions, ToolOptions}; use uv_settings::{PythonInstallMirrors, ResolverInstallerOptions, ToolOptions};
@ -66,13 +68,31 @@ pub(crate) async fn install(
python_downloads: PythonDownloads, python_downloads: PythonDownloads,
installer_metadata: bool, installer_metadata: bool,
concurrency: Concurrency, concurrency: Concurrency,
no_config: bool,
cache: Cache, cache: Cache,
printer: Printer, printer: Printer,
preview: Preview, preview: Preview,
) -> Result<ExitStatus> { ) -> Result<ExitStatus> {
let reporter = PythonDownloadReporter::single(printer); 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 // 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. // 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 = let existing_environment = installed_tools
installed_tools .get_environment(package_name, &cache)?
.get_environment(package_name, &cache)? .filter(|environment| {
.filter(|environment| { existing_environment_usable(
if environment.uses(&interpreter) { environment,
trace!( &interpreter,
"Existing interpreter matches the requested interpreter for `{}`: {}", package_name,
package_name, python_request.as_ref(),
environment.interpreter().sys_executable().display() explicit_python_request,
); &settings,
true existing_tool_receipt.as_ref(),
} else { printer,
let _ = writeln!( )
printer.stderr(), });
"Ignoring existing environment for `{}`: the requested Python interpreter does not match the environment interpreter",
package_name.cyan(),
);
false
}
});
// If the requested and receipt requirements are the same... // If the requested and receipt requirements are the same...
if let Some(environment) = existing_environment.as_ref().filter(|_| { if let Some(environment) = existing_environment.as_ref().filter(|_| {
@ -394,9 +408,13 @@ pub(crate) async fn install(
) )
.into_inner(); .into_inner();
// Determine the markers and tags to use for the resolution. // Determine the markers and tags to use for the resolution. We use the existing
let markers = resolution_markers(None, python_platform.as_ref(), &interpreter); // environment for markers here — above we filter the environment to `None` if
let tags = resolution_tags(None, python_platform.as_ref(), &interpreter)?; // `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. // Check if the installed packages meet the requirements.
let site_packages = SitePackages::from_environment(environment)?; let site_packages = SitePackages::from_environment(environment)?;
@ -640,7 +658,12 @@ pub(crate) async fn install(
&installed_tools, &installed_tools,
&options, &options,
force || invalid_tool_receipt, 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, requirements,
constraints, constraints,
overrides, overrides,
@ -650,3 +673,54 @@ pub(crate) async fn install(
Ok(ExitStatus::Success) 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
}

View File

@ -25,12 +25,15 @@ use uv_distribution_types::{
IndexUrl, Name, NameRequirementSpecification, Requirement, RequirementSource, IndexUrl, Name, NameRequirementSpecification, Requirement, RequirementSource,
UnresolvedRequirement, UnresolvedRequirementSpecification, UnresolvedRequirement, UnresolvedRequirementSpecification,
}; };
use uv_fs::CWD;
use uv_fs::Simplified; use uv_fs::Simplified;
use uv_installer::{InstallationStrategy, SatisfiesResult, SitePackages}; use uv_installer::{InstallationStrategy, SatisfiesResult, SitePackages};
use uv_normalize::PackageName; use uv_normalize::PackageName;
use uv_pep440::{VersionSpecifier, VersionSpecifiers}; use uv_pep440::{VersionSpecifier, VersionSpecifiers};
use uv_pep508::MarkerTree; use uv_pep508::MarkerTree;
use uv_preview::Preview; use uv_preview::Preview;
use uv_python::PythonVersionFile;
use uv_python::VersionFileDiscoveryOptions;
use uv_python::{ use uv_python::{
EnvironmentPreference, PythonDownloads, PythonEnvironment, PythonInstallation, EnvironmentPreference, PythonDownloads, PythonEnvironment, PythonInstallation,
PythonPreference, PythonRequest, PythonPreference, PythonRequest,
@ -699,43 +702,38 @@ async fn get_or_create_environment(
) -> Result<(ToolRequirement, PythonEnvironment), ProjectError> { ) -> Result<(ToolRequirement, PythonEnvironment), ProjectError> {
let reporter = PythonDownloadReporter::single(printer); let reporter = PythonDownloadReporter::single(printer);
// Figure out what Python we're targeting, either explicitly like `uvx python@3`, or via the // Determine explicit Python version requests
// -p/--python flag. let explicit_python_request = python.map(PythonRequest::parse);
let python_request = match request { let tool_python_request = match request {
ToolRequest::Python { ToolRequest::Python { request, .. } => Some(request.clone()),
request: tool_python_request, ToolRequest::Package { .. } => None,
.. };
} => {
match python {
None => Some(tool_python_request.clone()),
// The user is both invoking a python interpreter directly and also supplying the // Resolve Python request with version file lookup when no explicit request
// -p/--python flag. Cases like `uvx -p pypy python` are allowed, for two reasons: let python_request = match (explicit_python_request, tool_python_request) {
// 1) Previously this was the only way to invoke e.g. PyPy via `uvx`, and it's nice // e.g., `uvx --python 3.10 python3.12`
// to remain compatible with that. 2) A script might define an alias like `uvx (Some(explicit), Some(tool_request)) if tool_request != PythonRequest::Default => {
// --python $MY_PYTHON ...`, and it's nice to be able to run the interpreter // Conflict: both --python flag and versioned tool name
// directly while sticking to that alias. return Err(anyhow::anyhow!(
// "Received multiple Python version requests: `{}` and `{}`",
// However, we want to error out if we see conflicting or redundant versions like explicit.to_canonical_string().cyan(),
// `uvx -p python38 python39`. tool_request.to_canonical_string().cyan()
// )
// Note that a command like `uvx default` doesn't bring us here. ToolRequest::parse .into());
// 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))
}
}
} }
ToolRequest::Package { .. } => python.map(PythonRequest::parse), // e.g, `uvx --python 3.10 ...`
(Some(explicit), _) => Some(explicit),
// e.g., `uvx python` or `uvx <tool>`
(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. // Discover an interpreter.

View File

@ -1387,6 +1387,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
globals.python_downloads, globals.python_downloads,
globals.installer_metadata, globals.installer_metadata,
globals.concurrency, globals.concurrency,
cli.top_level.no_config,
cache, cache,
printer, printer,
globals.preview, globals.preview,

View File

@ -1,6 +1,7 @@
use std::process::Command; use std::process::Command;
use anyhow::Result; use anyhow::Result;
use assert_cmd::assert::OutputAssertExt;
use assert_fs::{ use assert_fs::{
assert::PathAssert, assert::PathAssert,
fixture::{FileTouch, FileWriteStr, PathChild}, fixture::{FileTouch, FileWriteStr, PathChild},
@ -178,15 +179,20 @@ fn tool_install() {
} }
#[test] #[test]
fn tool_install_with_global_python() -> Result<()> { fn tool_install_python_from_global_version_file() {
let context = TestContext::new_with_versions(&["3.11", "3.12"]) let context = TestContext::new_with_versions(&["3.11", "3.12", "3.13"])
.with_filtered_counts() .with_filtered_counts()
.with_filtered_exe_suffix(); .with_filtered_exe_suffix();
let tool_dir = context.temp_dir.child("tools"); let tool_dir = context.temp_dir.child("tools");
let bin_dir = context.temp_dir.child("bin"); let bin_dir = context.temp_dir.child("bin");
let uv = context.user_config_dir.child("uv");
let versions = uv.child(".python-version"); // Pin to 3.12
versions.write_str("3.11")?; context
.python_pin()
.arg("3.12")
.arg("--global")
.assert()
.success();
// Install a tool // Install a tool
uv_snapshot!(context.filters(), context.tool_install() uv_snapshot!(context.filters(), context.tool_install()
@ -212,14 +218,147 @@ fn tool_install_with_global_python() -> Result<()> {
Installed 1 executable: flask Installed 1 executable: flask
"###); "###);
tool_dir.child("flask").assert(predicate::path::is_dir()); // It should use the version from the global file
assert!( uv_snapshot!(context.filters(), Command::new("flask").arg("--version").env(EnvVars::PATH, bin_dir.as_os_str()), @r"
bin_dir success: true
.child(format!("flask{}", std::env::consts::EXE_SUFFIX)) exit_code: 0
.exists() ----- 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 success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
@ -228,21 +367,9 @@ fn tool_install_with_global_python() -> Result<()> {
Werkzeug 3.0.1 Werkzeug 3.0.1
----- stderr ----- ----- stderr -----
"###); ");
// Change global version // Use `--reinstall` to install flask again
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
uv_snapshot!(context.filters(), context.tool_install() uv_snapshot!(context.filters(), context.tool_install()
.arg("flask") .arg("flask")
.arg("--reinstall") .arg("--reinstall")
@ -268,9 +395,8 @@ fn tool_install_with_global_python() -> Result<()> {
Installed 1 executable: flask Installed 1 executable: flask
"); ");
// Currently, when reinstalling a tool we use the original version the tool // We should continue to use the version from the install, not the global pin
// 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"
uv_snapshot!(context.filters(), Command::new("flask").arg("--version").env(EnvVars::PATH, bin_dir.as_os_str()), @r###"
success: true success: true
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
@ -279,9 +405,7 @@ fn tool_install_with_global_python() -> Result<()> {
Werkzeug 3.0.1 Werkzeug 3.0.1
----- stderr ----- ----- stderr -----
"###); ");
Ok(())
} }
#[test] #[test]

View File

@ -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] #[test]
fn tool_run_python_from() { fn tool_run_python_from() {
let context = TestContext::new_with_versions(&["3.12", "3.11"]) let context = TestContext::new_with_versions(&["3.12", "3.11"])