mirror of
https://github.com/astral-sh/uv.git
synced 2025-07-07 13:25:00 +00:00
Respect global Python version pins in uv tool run
and uv tool install
This commit is contained in:
parent
1dbe750452
commit
98f0621d39
5 changed files with 255 additions and 36 deletions
|
@ -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<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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"])
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue