Skip to content

Commit c421cb9

Browse files
committed
Respect global Python version pins in uv tool run and uv tool install
1 parent 83958b4 commit c421cb9

File tree

5 files changed

+250
-31
lines changed

5 files changed

+250
-31
lines changed

crates/uv-python/src/discovery.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ use crate::{BrokenSymlink, Interpreter, PythonVersion};
4040
/// A request to find a Python installation.
4141
///
4242
/// See [`PythonRequest::from_str`].
43-
#[derive(Debug, Clone, PartialEq, Eq, Default)]
43+
#[derive(Debug, Clone, Eq, Default)]
4444
pub enum PythonRequest {
4545
/// An appropriate default Python installation
4646
///
@@ -67,6 +67,12 @@ pub enum PythonRequest {
6767
Key(PythonDownloadRequest),
6868
}
6969

70+
impl PartialEq for PythonRequest {
71+
fn eq(&self, other: &Self) -> bool {
72+
self.to_canonical_string() == other.to_canonical_string()
73+
}
74+
}
75+
7076
impl<'a> serde::Deserialize<'a> for PythonRequest {
7177
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
7278
where

crates/uv/src/commands/tool/install.rs

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@ use uv_distribution_types::{
1414
NameRequirementSpecification, Requirement, RequirementSource,
1515
UnresolvedRequirementSpecification,
1616
};
17+
use uv_fs::CWD;
1718
use uv_normalize::PackageName;
1819
use uv_pep440::{VersionSpecifier, VersionSpecifiers};
1920
use uv_pep508::MarkerTree;
2021
use uv_python::{
2122
EnvironmentPreference, PythonDownloads, PythonInstallation, PythonPreference, PythonRequest,
23+
PythonVersionFile, VersionFileDiscoveryOptions,
2224
};
2325
use uv_requirements::{RequirementsSource, RequirementsSpecification};
2426
use uv_settings::{PythonInstallMirrors, ResolverInstallerOptions, ToolOptions};
@@ -72,7 +74,25 @@ pub(crate) async fn install(
7274

7375
let reporter = PythonDownloadReporter::single(printer);
7476

75-
let python_request = python.as_deref().map(PythonRequest::parse);
77+
let (python_request, explicit_python_request) = if let Some(request) = python.as_deref() {
78+
(Some(PythonRequest::parse(request)), true)
79+
} else {
80+
// Discover a global Python version pin, if no request was made
81+
(
82+
PythonVersionFile::discover(
83+
// TODO(zanieb): We don't use the directory, should we expose another interface?
84+
// Should `no_local` be implied by `None` here?
85+
&*CWD,
86+
&VersionFileDiscoveryOptions::default()
87+
// TODO(zanieb): Propagate `no_config` from the global options to here
88+
.with_no_config(false)
89+
.with_no_local(true),
90+
)
91+
.await?
92+
.and_then(PythonVersionFile::into_version),
93+
false,
94+
)
95+
};
7696

7797
// Pre-emptively identify a Python interpreter. We need an interpreter to resolve any unnamed
7898
// requirements, even if we end up using a different interpreter for the tool install itself.
@@ -362,13 +382,30 @@ pub(crate) async fn install(
362382
environment.interpreter().sys_executable().display()
363383
);
364384
true
365-
} else {
385+
} else if explicit_python_request {
366386
let _ = writeln!(
367387
printer.stderr(),
368388
"Ignoring existing environment for `{from}`: the requested Python interpreter does not match the environment interpreter",
369389
from = from.name.cyan(),
370390
);
371391
false
392+
} else {
393+
// Allow the existing environment if the user didn't explicitly request another
394+
// version
395+
if let Some(ref tool_receipt) = existing_tool_receipt {
396+
if settings.reinstall.is_all() && tool_receipt.python().is_none() && python_request.is_some() {
397+
let _ = writeln!(
398+
printer.stderr(),
399+
"Ignoring existing environment for `{from}`: the Python interpreter does not match the environment interpreter",
400+
from = from.name.cyan(),
401+
);
402+
false
403+
} else {
404+
true
405+
}
406+
} else {
407+
true
408+
}
372409
}
373410
});
374411

@@ -600,7 +637,12 @@ pub(crate) async fn install(
600637
&installed_tools,
601638
options,
602639
force || invalid_tool_receipt,
603-
python_request,
640+
// Only persist the Python request if it was explicitly provided
641+
if explicit_python_request {
642+
python_request
643+
} else {
644+
None
645+
},
604646
requirements,
605647
constraints,
606648
overrides,

crates/uv/src/commands/tool/run.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,14 @@ use uv_distribution_types::{
2424
IndexUrl, Name, NameRequirementSpecification, Requirement, RequirementSource,
2525
UnresolvedRequirement, UnresolvedRequirementSpecification,
2626
};
27+
use uv_fs::CWD;
2728
use uv_fs::Simplified;
2829
use uv_installer::{SatisfiesResult, SitePackages};
2930
use uv_normalize::PackageName;
3031
use uv_pep440::{VersionSpecifier, VersionSpecifiers};
3132
use uv_pep508::MarkerTree;
33+
use uv_python::PythonVersionFile;
34+
use uv_python::VersionFileDiscoveryOptions;
3235
use uv_python::{
3336
EnvironmentPreference, PythonDownloads, PythonEnvironment, PythonInstallation,
3437
PythonPreference, PythonRequest,
@@ -735,6 +738,23 @@ async fn get_or_create_environment(
735738
ToolRequest::Package { .. } => python.map(PythonRequest::parse),
736739
};
737740

741+
// Discover a global Python version pin, if no request was made.
742+
let python_request = if python_request.is_none() {
743+
PythonVersionFile::discover(
744+
// TODO(zanieb): We don't use the directory, should we expose another interface?
745+
// Should `no_local` be implied by `None` here?
746+
&*CWD,
747+
&VersionFileDiscoveryOptions::default()
748+
// TODO(zanieb): Propagate `no_config` from the global options to here
749+
.with_no_config(false)
750+
.with_no_local(true),
751+
)
752+
.await?
753+
.and_then(PythonVersionFile::into_version)
754+
} else {
755+
python_request
756+
};
757+
738758
// Discover an interpreter.
739759
let interpreter = PythonInstallation::find_or_download(
740760
python_request.as_ref(),

crates/uv/tests/it/tool_install.rs

Lines changed: 151 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use std::process::Command;
22

33
use anyhow::Result;
4+
use assert_cmd::assert::OutputAssertExt;
45
use assert_fs::{
56
assert::PathAssert,
67
fixture::{FileTouch, FileWriteStr, PathChild},
@@ -178,15 +179,20 @@ fn tool_install() {
178179
}
179180

180181
#[test]
181-
fn tool_install_with_global_python() -> Result<()> {
182-
let context = TestContext::new_with_versions(&["3.11", "3.12"])
182+
fn tool_install_python_from_global_version_file() {
183+
let context = TestContext::new_with_versions(&["3.11", "3.12", "3.13"])
183184
.with_filtered_counts()
184185
.with_filtered_exe_suffix();
185186
let tool_dir = context.temp_dir.child("tools");
186187
let bin_dir = context.temp_dir.child("bin");
187-
let uv = context.user_config_dir.child("uv");
188-
let versions = uv.child(".python-version");
189-
versions.write_str("3.11")?;
188+
189+
// Pin to 3.12
190+
context
191+
.python_pin()
192+
.arg("3.12")
193+
.arg("--global")
194+
.assert()
195+
.success();
190196

191197
// Install a tool
192198
uv_snapshot!(context.filters(), context.tool_install()
@@ -212,37 +218,158 @@ fn tool_install_with_global_python() -> Result<()> {
212218
Installed 1 executable: flask
213219
"###);
214220

215-
tool_dir.child("flask").assert(predicate::path::is_dir());
216-
assert!(
217-
bin_dir
218-
.child(format!("flask{}", std::env::consts::EXE_SUFFIX))
219-
.exists()
220-
);
221-
222-
uv_snapshot!(context.filters(), Command::new("flask").arg("--version").env(EnvVars::PATH, bin_dir.as_os_str()), @r###"
221+
// It should use the version from the global file
222+
uv_snapshot!(context.filters(), Command::new("flask").arg("--version").env(EnvVars::PATH, bin_dir.as_os_str()), @r"
223223
success: true
224224
exit_code: 0
225225
----- stdout -----
226-
Python 3.11.[X]
226+
Python 3.12.[X]
227227
Flask 3.0.2
228228
Werkzeug 3.0.1
229229
230230
----- stderr -----
231-
"###);
231+
");
232232

233233
// Change global version
234-
uv_snapshot!(context.filters(), context.python_pin().arg("3.12").arg("--global"),
235-
@r"
234+
context
235+
.python_pin()
236+
.arg("3.13")
237+
.arg("--global")
238+
.assert()
239+
.success();
240+
241+
// Installing flask again should be a no-op, even though the global pin changed
242+
uv_snapshot!(context.filters(), context.tool_install()
243+
.arg("flask")
244+
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
245+
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
246+
.env(EnvVars::PATH, bin_dir.as_os_str()), @r"
236247
success: true
237248
exit_code: 0
238249
----- stdout -----
239-
Updated `[UV_USER_CONFIG_DIR]/.python-version` from `3.11` -> `3.12`
240250
241251
----- stderr -----
242-
"
243-
);
252+
`flask` is already installed
253+
");
244254

245-
// Install flask again
255+
uv_snapshot!(context.filters(), Command::new("flask").arg("--version").env(EnvVars::PATH, bin_dir.as_os_str()), @r"
256+
success: true
257+
exit_code: 0
258+
----- stdout -----
259+
Python 3.12.[X]
260+
Flask 3.0.2
261+
Werkzeug 3.0.1
262+
263+
----- stderr -----
264+
");
265+
266+
// Using `--upgrade` forces us to check the environment
267+
uv_snapshot!(context.filters(), context.tool_install()
268+
.arg("flask")
269+
.arg("--upgrade")
270+
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
271+
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
272+
.env(EnvVars::PATH, bin_dir.as_os_str()), @r"
273+
success: true
274+
exit_code: 0
275+
----- stdout -----
276+
277+
----- stderr -----
278+
Resolved [N] packages in [TIME]
279+
Audited [N] packages in [TIME]
280+
Installed 1 executable: flask
281+
");
282+
283+
// This will not change to the new global pin, since there was not a reinstall request
284+
uv_snapshot!(context.filters(), Command::new("flask").arg("--version").env(EnvVars::PATH, bin_dir.as_os_str()), @r"
285+
success: true
286+
exit_code: 0
287+
----- stdout -----
288+
Python 3.12.[X]
289+
Flask 3.0.2
290+
Werkzeug 3.0.1
291+
292+
----- stderr -----
293+
");
294+
295+
// Using `--reinstall` forces us to install flask again
296+
uv_snapshot!(context.filters(), context.tool_install()
297+
.arg("flask")
298+
.arg("--reinstall")
299+
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
300+
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
301+
.env(EnvVars::PATH, bin_dir.as_os_str()), @r"
302+
success: true
303+
exit_code: 0
304+
----- stdout -----
305+
306+
----- stderr -----
307+
Ignoring existing environment for `flask`: the Python interpreter does not match the environment interpreter
308+
Resolved [N] packages in [TIME]
309+
Prepared [N] packages in [TIME]
310+
Installed [N] packages in [TIME]
311+
+ blinker==1.7.0
312+
+ click==8.1.7
313+
+ flask==3.0.2
314+
+ itsdangerous==2.1.2
315+
+ jinja2==3.1.3
316+
+ markupsafe==2.1.5
317+
+ werkzeug==3.0.1
318+
Installed 1 executable: flask
319+
");
320+
321+
// This will change to the new global pin, since there was not an explicit request recorded in
322+
// the receipt
323+
uv_snapshot!(context.filters(), Command::new("flask").arg("--version").env(EnvVars::PATH, bin_dir.as_os_str()), @r"
324+
success: true
325+
exit_code: 0
326+
----- stdout -----
327+
Python 3.13.[X]
328+
Flask 3.0.2
329+
Werkzeug 3.0.1
330+
331+
----- stderr -----
332+
");
333+
334+
// If we request a specific Python version, it takes precedence over the pin
335+
uv_snapshot!(context.filters(), context.tool_install()
336+
.arg("flask")
337+
.arg("--python")
338+
.arg("3.11")
339+
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
340+
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
341+
.env(EnvVars::PATH, bin_dir.as_os_str()), @r"
342+
success: true
343+
exit_code: 0
344+
----- stdout -----
345+
346+
----- stderr -----
347+
Ignoring existing environment for `flask`: the requested Python interpreter does not match the environment interpreter
348+
Resolved [N] packages in [TIME]
349+
Prepared [N] packages in [TIME]
350+
Installed [N] packages in [TIME]
351+
+ blinker==1.7.0
352+
+ click==8.1.7
353+
+ flask==3.0.2
354+
+ itsdangerous==2.1.2
355+
+ jinja2==3.1.3
356+
+ markupsafe==2.1.5
357+
+ werkzeug==3.0.1
358+
Installed 1 executable: flask
359+
");
360+
361+
uv_snapshot!(context.filters(), Command::new("flask").arg("--version").env(EnvVars::PATH, bin_dir.as_os_str()), @r"
362+
success: true
363+
exit_code: 0
364+
----- stdout -----
365+
Python 3.11.[X]
366+
Flask 3.0.2
367+
Werkzeug 3.0.1
368+
369+
----- stderr -----
370+
");
371+
372+
// Use `--reinstall` to install flask again
246373
uv_snapshot!(context.filters(), context.tool_install()
247374
.arg("flask")
248375
.arg("--reinstall")
@@ -268,9 +395,8 @@ fn tool_install_with_global_python() -> Result<()> {
268395
Installed 1 executable: flask
269396
");
270397

271-
// Currently, when reinstalling a tool we use the original version the tool
272-
// was installed with, not the most up-to-date global version
273-
uv_snapshot!(context.filters(), Command::new("flask").arg("--version").env(EnvVars::PATH, bin_dir.as_os_str()), @r###"
398+
// We should continue to use the version from the install, not the global pin
399+
uv_snapshot!(context.filters(), Command::new("flask").arg("--version").env(EnvVars::PATH, bin_dir.as_os_str()), @r"
274400
success: true
275401
exit_code: 0
276402
----- stdout -----
@@ -279,9 +405,7 @@ fn tool_install_with_global_python() -> Result<()> {
279405
Werkzeug 3.0.1
280406
281407
----- stderr -----
282-
"###);
283-
284-
Ok(())
408+
");
285409
}
286410

287411
#[test]

0 commit comments

Comments
 (0)