Skip to content

Commit 4851fc5

Browse files
committed
Respect global Python version pins in uv tool run and uv tool install
1 parent 39e2e3e commit 4851fc5

File tree

5 files changed

+330
-67
lines changed

5 files changed

+330
-67
lines changed

crates/uv-python/src/discovery.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ use crate::{BrokenSymlink, Interpreter, PythonInstallationKey, PythonVersion};
4141
/// A request to find a Python installation.
4242
///
4343
/// See [`PythonRequest::from_str`].
44-
#[derive(Debug, Clone, PartialEq, Eq, Default, Hash)]
44+
#[derive(Debug, Clone, Eq, Default)]
4545
pub enum PythonRequest {
4646
/// An appropriate default Python installation
4747
///
@@ -68,6 +68,18 @@ pub enum PythonRequest {
6868
Key(PythonDownloadRequest),
6969
}
7070

71+
impl PartialEq for PythonRequest {
72+
fn eq(&self, other: &Self) -> bool {
73+
self.to_canonical_string() == other.to_canonical_string()
74+
}
75+
}
76+
77+
impl std::hash::Hash for PythonRequest {
78+
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
79+
self.to_canonical_string().hash(state);
80+
}
81+
}
82+
7183
impl<'a> serde::Deserialize<'a> for PythonRequest {
7284
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
7385
where

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

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@ use uv_distribution_types::{
1414
ExtraBuildRequires, NameRequirementSpecification, Requirement, RequirementSource,
1515
UnresolvedRequirementSpecification,
1616
};
17+
use uv_fs::CWD;
1718
use uv_installer::{InstallationStrategy, SatisfiesResult, SitePackages};
1819
use uv_normalize::PackageName;
1920
use uv_pep440::{VersionSpecifier, VersionSpecifiers};
2021
use uv_pep508::MarkerTree;
2122
use uv_preview::Preview;
2223
use uv_python::{
2324
EnvironmentPreference, PythonDownloads, PythonInstallation, PythonPreference, PythonRequest,
25+
PythonVersionFile, VersionFileDiscoveryOptions,
2426
};
2527
use uv_requirements::{RequirementsSource, RequirementsSpecification};
2628
use uv_settings::{PythonInstallMirrors, ResolverInstallerOptions, ToolOptions};
@@ -72,7 +74,25 @@ pub(crate) async fn install(
7274
) -> Result<ExitStatus> {
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.
@@ -355,13 +375,30 @@ pub(crate) async fn install(
355375
environment.interpreter().sys_executable().display()
356376
);
357377
true
358-
} else {
378+
} else if explicit_python_request {
359379
let _ = writeln!(
360380
printer.stderr(),
361381
"Ignoring existing environment for `{}`: the requested Python interpreter does not match the environment interpreter",
362382
package_name.cyan(),
363383
);
364384
false
385+
} else {
386+
// Allow the existing environment if the user didn't explicitly request another
387+
// version
388+
if let Some(ref tool_receipt) = existing_tool_receipt {
389+
if settings.reinstall.is_all() && tool_receipt.python().is_none() && python_request.is_some() {
390+
let _ = writeln!(
391+
printer.stderr(),
392+
"Ignoring existing environment for `{from}`: the Python interpreter does not match the environment interpreter",
393+
from = package_name.cyan(),
394+
);
395+
false
396+
} else {
397+
true
398+
}
399+
} else {
400+
true
401+
}
365402
}
366403
});
367404

@@ -640,7 +677,12 @@ pub(crate) async fn install(
640677
&installed_tools,
641678
&options,
642679
force || invalid_tool_receipt,
643-
python_request,
680+
// Only persist the Python request if it was explicitly provided
681+
if explicit_python_request {
682+
python_request
683+
} else {
684+
None
685+
},
644686
requirements,
645687
constraints,
646688
overrides,

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

Lines changed: 34 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,15 @@ use uv_distribution_types::{
2525
IndexUrl, Name, NameRequirementSpecification, Requirement, RequirementSource,
2626
UnresolvedRequirement, UnresolvedRequirementSpecification,
2727
};
28+
use uv_fs::CWD;
2829
use uv_fs::Simplified;
2930
use uv_installer::{InstallationStrategy, SatisfiesResult, SitePackages};
3031
use uv_normalize::PackageName;
3132
use uv_pep440::{VersionSpecifier, VersionSpecifiers};
3233
use uv_pep508::MarkerTree;
3334
use uv_preview::Preview;
35+
use uv_python::PythonVersionFile;
36+
use uv_python::VersionFileDiscoveryOptions;
3437
use uv_python::{
3538
EnvironmentPreference, PythonDownloads, PythonEnvironment, PythonInstallation,
3639
PythonPreference, PythonRequest,
@@ -699,43 +702,38 @@ async fn get_or_create_environment(
699702
) -> Result<(ToolRequirement, PythonEnvironment), ProjectError> {
700703
let reporter = PythonDownloadReporter::single(printer);
701704

702-
// Figure out what Python we're targeting, either explicitly like `uvx python@3`, or via the
703-
// -p/--python flag.
704-
let python_request = match request {
705-
ToolRequest::Python {
706-
request: tool_python_request,
707-
..
708-
} => {
709-
match python {
710-
None => Some(tool_python_request.clone()),
711-
712-
// The user is both invoking a python interpreter directly and also supplying the
713-
// -p/--python flag. Cases like `uvx -p pypy python` are allowed, for two reasons:
714-
// 1) Previously this was the only way to invoke e.g. PyPy via `uvx`, and it's nice
715-
// to remain compatible with that. 2) A script might define an alias like `uvx
716-
// --python $MY_PYTHON ...`, and it's nice to be able to run the interpreter
717-
// directly while sticking to that alias.
718-
//
719-
// However, we want to error out if we see conflicting or redundant versions like
720-
// `uvx -p python38 python39`.
721-
//
722-
// Note that a command like `uvx default` doesn't bring us here. ToolRequest::parse
723-
// returns ToolRequest::Package rather than ToolRequest::Python in that case. See
724-
// PythonRequest::try_from_tool_name.
725-
Some(python_flag) => {
726-
if tool_python_request != &PythonRequest::Default {
727-
return Err(anyhow::anyhow!(
728-
"Received multiple Python version requests: `{}` and `{}`",
729-
python_flag.to_string().cyan(),
730-
tool_python_request.to_canonical_string().cyan()
731-
)
732-
.into());
733-
}
734-
Some(PythonRequest::parse(python_flag))
735-
}
736-
}
705+
// Determine explicit Python version requests
706+
let explicit_python_request = python.map(PythonRequest::parse);
707+
let tool_python_request = match request {
708+
ToolRequest::Python { request, .. } => Some(request.clone()),
709+
ToolRequest::Package { .. } => None,
710+
};
711+
712+
// Resolve Python request with version file lookup when no explicit request
713+
let python_request = match (explicit_python_request, tool_python_request) {
714+
// e.g., `uvx --python 3.10 python3.12`
715+
(Some(explicit), Some(tool_request)) if tool_request != PythonRequest::Default => {
716+
// Conflict: both --python flag and versioned tool name
717+
return Err(anyhow::anyhow!(
718+
"Received multiple Python version requests: `{}` and `{}`",
719+
explicit.to_canonical_string().cyan(),
720+
tool_request.to_canonical_string().cyan()
721+
)
722+
.into());
737723
}
738-
ToolRequest::Package { .. } => python.map(PythonRequest::parse),
724+
// e.g, `uvx --python 3.10 ...`
725+
(Some(explicit), _) => Some(explicit),
726+
// e.g., `uvx python` or `uvx <tool>`
727+
(None, Some(PythonRequest::Default) | None) => PythonVersionFile::discover(
728+
&*CWD,
729+
&VersionFileDiscoveryOptions::default()
730+
.with_no_config(false)
731+
.with_no_local(true),
732+
)
733+
.await?
734+
.and_then(PythonVersionFile::into_version),
735+
// e.g., `uvx python3.12`
736+
(None, Some(tool_request)) => Some(tool_request),
739737
};
740738

741739
// Discover an interpreter.

0 commit comments

Comments
 (0)