Skip to content

Commit 4c161d2

Browse files
Respect requires-python when installing tools (#10401)
## Summary This PR revives #7827 to improve tool resolutions such that, if the resolution fails, and the selected interpreter doesn't match the required Python version from the solve, we attempt to re-solve with a newly-discovered interpreter that _does_ match the required Python version. For now, we attempt to choose a Python interpreter that's greater than the inferred `requires-python`, but compatible with the same Python minor. This helps avoid successive failures for cases like Posting, where choosing Python 3.13 fails because it has a dependency that lacks source distributions and doesn't publish any Python 3.13 wheels. We should further improve the strategy to solve _that_ case too, but this is at least the more conservative option... In short, if you do `uv tool instal posting`, and we find Python 3.8 on your machine, we'll detect that `requires-python: >=3.11`, then search for the latest Python 3.11 interpreter and re-resolve. Closes #6381. Closes #10282. ## Test Plan The following should succeed: ``` cargo run python uninstall --all cargo run python install 3.8 cargo run tool install posting ``` In the logs, we see: ``` ... DEBUG No compatible version found for: posting DEBUG Refining interpreter with: Python >=3.11, <3.12 DEBUG Searching for Python >=3.11, <3.12 in managed installations or search path DEBUG Searching for managed installations at `/Users/crmarsh/.local/share/uv/python` DEBUG Skipping incompatible managed installation `cpython-3.8.20-macos-aarch64-none` DEBUG Found `cpython-3.13.1-macos-aarch64-none` at `/opt/homebrew/bin/python3` (search path) DEBUG Skipping interpreter at `/opt/homebrew/opt/[email protected]/bin/python3.13` from search path: does not satisfy request `>=3.11, <3.12` DEBUG Found `cpython-3.11.7-macos-aarch64-none` at `/opt/homebrew/bin/python3.11` (search path) DEBUG Re-resolving with Python 3.11.7 DEBUG Using request timeout of 30s DEBUG Solving with installed Python version: 3.11.7 DEBUG Solving with target Python version: >=3.11.7 DEBUG Adding direct dependency: posting* DEBUG Searching for a compatible version of posting (*) ... ```
1 parent 333f03f commit 4c161d2

File tree

10 files changed

+320
-64
lines changed

10 files changed

+320
-64
lines changed

crates/uv-requirements/src/specification.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ use uv_workspace::pyproject::PyProjectToml;
5050

5151
use crate::RequirementsSource;
5252

53-
#[derive(Debug, Default)]
53+
#[derive(Debug, Default, Clone)]
5454
pub struct RequirementsSpecification {
5555
/// The name of the project specifying requirements.
5656
pub project: Option<PackageName>,

crates/uv-resolver/src/error.rs

+28
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ use crate::fork_urls::ForkUrls;
2222
use crate::prerelease::AllowPrerelease;
2323
use crate::pubgrub::{PubGrubPackage, PubGrubPackageInner, PubGrubReportFormatter};
2424
use crate::python_requirement::PythonRequirement;
25+
use crate::requires_python::LowerBound;
2526
use crate::resolution::ConflictingDistributionError;
2627
use crate::resolver::{
2728
MetadataUnavailable, ResolverEnvironment, UnavailablePackage, UnavailableReason,
@@ -294,6 +295,33 @@ impl NoSolutionError {
294295
strip(derivation_tree).expect("derivation tree should contain at least one term")
295296
}
296297

298+
/// Given a [`DerivationTree`], identify the largest required Python version that is missing.
299+
pub fn find_requires_python(&self) -> LowerBound {
300+
fn find(derivation_tree: &ErrorTree, minimum: &mut LowerBound) {
301+
match derivation_tree {
302+
DerivationTree::Derived(derived) => {
303+
find(derived.cause1.as_ref(), minimum);
304+
find(derived.cause2.as_ref(), minimum);
305+
}
306+
DerivationTree::External(External::FromDependencyOf(.., package, version)) => {
307+
if let PubGrubPackageInner::Python(_) = &**package {
308+
if let Some((lower, ..)) = version.bounding_range() {
309+
let lower = LowerBound::new(lower.cloned());
310+
if lower > *minimum {
311+
*minimum = lower;
312+
}
313+
}
314+
}
315+
}
316+
DerivationTree::External(_) => {}
317+
}
318+
}
319+
320+
let mut minimum = LowerBound::default();
321+
find(&self.error, &mut minimum);
322+
minimum
323+
}
324+
297325
/// Initialize a [`NoSolutionHeader`] for this error.
298326
pub fn header(&self) -> NoSolutionHeader {
299327
NoSolutionHeader::new(self.env.clone())

crates/uv-resolver/src/requires_python.rs

+40-26
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,17 @@ impl SimplifiedMarkerTree {
586586
pub struct LowerBound(Bound<Version>);
587587

588588
impl LowerBound {
589+
/// Initialize a [`LowerBound`] with the given bound.
590+
///
591+
/// These bounds use release-only semantics when comparing versions.
592+
pub fn new(bound: Bound<Version>) -> Self {
593+
Self(match bound {
594+
Bound::Included(version) => Bound::Included(version.only_release()),
595+
Bound::Excluded(version) => Bound::Excluded(version.only_release()),
596+
Bound::Unbounded => Bound::Unbounded,
597+
})
598+
}
599+
589600
/// Return the [`LowerBound`] truncated to the major and minor version.
590601
fn major_minor(&self) -> Self {
591602
match &self.0 {
@@ -600,6 +611,15 @@ impl LowerBound {
600611
Bound::Unbounded => Self(Bound::Unbounded),
601612
}
602613
}
614+
615+
/// Returns `true` if the lower bound contains the given version.
616+
pub fn contains(&self, version: &Version) -> bool {
617+
match self.0 {
618+
Bound::Included(ref bound) => bound <= version,
619+
Bound::Excluded(ref bound) => bound < version,
620+
Bound::Unbounded => true,
621+
}
622+
}
603623
}
604624

605625
impl PartialOrd for LowerBound {
@@ -668,19 +688,6 @@ impl Default for LowerBound {
668688
}
669689
}
670690

671-
impl LowerBound {
672-
/// Initialize a [`LowerBound`] with the given bound.
673-
///
674-
/// These bounds use release-only semantics when comparing versions.
675-
pub fn new(bound: Bound<Version>) -> Self {
676-
Self(match bound {
677-
Bound::Included(version) => Bound::Included(version.only_release()),
678-
Bound::Excluded(version) => Bound::Excluded(version.only_release()),
679-
Bound::Unbounded => Bound::Unbounded,
680-
})
681-
}
682-
}
683-
684691
impl Deref for LowerBound {
685692
type Target = Bound<Version>;
686693

@@ -699,6 +706,17 @@ impl From<LowerBound> for Bound<Version> {
699706
pub struct UpperBound(Bound<Version>);
700707

701708
impl UpperBound {
709+
/// Initialize a [`UpperBound`] with the given bound.
710+
///
711+
/// These bounds use release-only semantics when comparing versions.
712+
pub fn new(bound: Bound<Version>) -> Self {
713+
Self(match bound {
714+
Bound::Included(version) => Bound::Included(version.only_release()),
715+
Bound::Excluded(version) => Bound::Excluded(version.only_release()),
716+
Bound::Unbounded => Bound::Unbounded,
717+
})
718+
}
719+
702720
/// Return the [`UpperBound`] truncated to the major and minor version.
703721
fn major_minor(&self) -> Self {
704722
match &self.0 {
@@ -721,6 +739,15 @@ impl UpperBound {
721739
Bound::Unbounded => Self(Bound::Unbounded),
722740
}
723741
}
742+
743+
/// Returns `true` if the upper bound contains the given version.
744+
pub fn contains(&self, version: &Version) -> bool {
745+
match self.0 {
746+
Bound::Included(ref bound) => bound >= version,
747+
Bound::Excluded(ref bound) => bound > version,
748+
Bound::Unbounded => true,
749+
}
750+
}
724751
}
725752

726753
impl PartialOrd for UpperBound {
@@ -787,19 +814,6 @@ impl Default for UpperBound {
787814
}
788815
}
789816

790-
impl UpperBound {
791-
/// Initialize a [`UpperBound`] with the given bound.
792-
///
793-
/// These bounds use release-only semantics when comparing versions.
794-
pub fn new(bound: Bound<Version>) -> Self {
795-
Self(match bound {
796-
Bound::Included(version) => Bound::Included(version.only_release()),
797-
Bound::Excluded(version) => Bound::Excluded(version.only_release()),
798-
Bound::Unbounded => Bound::Unbounded,
799-
})
800-
}
801-
}
802-
803817
impl Deref for UpperBound {
804818
type Target = Bound<Version>;
805819

crates/uv/src/commands/project/environment.rs

+2-2
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ impl CachedEnvironment {
2929
/// interpreter.
3030
pub(crate) async fn get_or_create(
3131
spec: EnvironmentSpecification<'_>,
32-
interpreter: Interpreter,
32+
interpreter: &Interpreter,
3333
settings: &ResolverInstallerSettings,
3434
state: &SharedState,
3535
resolve: Box<dyn ResolveLogger>,
@@ -56,7 +56,7 @@ impl CachedEnvironment {
5656
"Caching via interpreter: `{}`",
5757
interpreter.sys_executable().display()
5858
);
59-
interpreter
59+
interpreter.clone()
6060
};
6161

6262
// Resolve the requirements with the interpreter.

crates/uv/src/commands/project/mod.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -1086,7 +1086,7 @@ pub(crate) async fn resolve_names(
10861086
Ok(requirements)
10871087
}
10881088

1089-
#[derive(Debug)]
1089+
#[derive(Debug, Clone)]
10901090
pub(crate) struct EnvironmentSpecification<'lock> {
10911091
/// The requirements to include in the environment.
10921092
requirements: RequirementsSpecification,

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

+2-2
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ pub(crate) async fn run(
296296
RequirementsSpecification::from_overrides(requirements, constraints, overrides);
297297
let result = CachedEnvironment::get_or_create(
298298
EnvironmentSpecification::from(spec),
299-
interpreter,
299+
&interpreter,
300300
&settings,
301301
&state,
302302
if show_resolution {
@@ -852,7 +852,7 @@ pub(crate) async fn run(
852852
lock.as_ref()
853853
.map(|(lock, install_path)| (lock, install_path.as_ref())),
854854
),
855-
base_interpreter.clone(),
855+
&base_interpreter,
856856
&settings,
857857
&state,
858858
if show_resolution {

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

+103-7
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,32 @@
1-
use std::fmt::Write;
2-
use std::{collections::BTreeSet, ffi::OsString};
3-
41
use anyhow::{bail, Context};
52
use itertools::Itertools;
63
use owo_colors::OwoColorize;
4+
use std::collections::Bound;
5+
use std::fmt::Write;
6+
use std::{collections::BTreeSet, ffi::OsString};
77
use tracing::{debug, warn};
8-
8+
use uv_cache::Cache;
9+
use uv_client::BaseClientBuilder;
910
use uv_distribution_types::{InstalledDist, Name};
1011
#[cfg(unix)]
1112
use uv_fs::replace_symlink;
1213
use uv_fs::Simplified;
1314
use uv_installer::SitePackages;
15+
use uv_pep440::{Version, VersionSpecifier, VersionSpecifiers};
1416
use uv_pep508::PackageName;
1517
use uv_pypi_types::Requirement;
16-
use uv_python::PythonEnvironment;
17-
use uv_settings::ToolOptions;
18+
use uv_python::{
19+
EnvironmentPreference, Interpreter, PythonDownloads, PythonEnvironment, PythonInstallation,
20+
PythonPreference, PythonRequest, PythonVariant, VersionRequest,
21+
};
22+
use uv_settings::{PythonInstallMirrors, ToolOptions};
1823
use uv_shell::Shell;
1924
use uv_tool::{entrypoint_paths, tool_executable_dir, InstalledTools, Tool, ToolEntrypoint};
2025
use uv_warnings::warn_user;
2126

22-
use crate::commands::ExitStatus;
27+
use crate::commands::project::ProjectError;
28+
use crate::commands::reporters::PythonDownloadReporter;
29+
use crate::commands::{pip, ExitStatus};
2330
use crate::printer::Printer;
2431

2532
/// Return all packages which contain an executable with the given name.
@@ -61,6 +68,95 @@ pub(crate) fn remove_entrypoints(tool: &Tool) {
6168
}
6269
}
6370

71+
/// Given a no-solution error and the [`Interpreter`] that was used during the solve, attempt to
72+
/// discover an alternate [`Interpreter`] that satisfies the `requires-python` constraint.
73+
pub(crate) async fn refine_interpreter(
74+
interpreter: &Interpreter,
75+
python_request: Option<&PythonRequest>,
76+
err: &pip::operations::Error,
77+
client_builder: &BaseClientBuilder<'_>,
78+
reporter: &PythonDownloadReporter,
79+
install_mirrors: &PythonInstallMirrors,
80+
python_preference: PythonPreference,
81+
python_downloads: PythonDownloads,
82+
cache: &Cache,
83+
) -> anyhow::Result<Option<Interpreter>, ProjectError> {
84+
let pip::operations::Error::Resolve(uv_resolver::ResolveError::NoSolution(ref no_solution_err)) =
85+
err
86+
else {
87+
return Ok(None);
88+
};
89+
90+
// Infer the `requires-python` constraint from the error.
91+
let requires_python = no_solution_err.find_requires_python();
92+
93+
// If the existing interpreter already satisfies the `requires-python` constraint, we don't need
94+
// to refine it. We'd expect to fail again anyway.
95+
if requires_python.contains(interpreter.python_version()) {
96+
return Ok(None);
97+
}
98+
99+
// If the user passed a `--python` request, and the refined interpreter is incompatible, we
100+
// can't use it.
101+
if let Some(python_request) = python_request {
102+
if !python_request.satisfied(interpreter, cache) {
103+
return Ok(None);
104+
}
105+
}
106+
107+
// We want an interpreter that's as close to the required version as possible. If we choose the
108+
// "latest" Python, we risk choosing a version that lacks wheels for the tool's requirements
109+
// (assuming those requirements don't publish source distributions).
110+
//
111+
// TODO(charlie): Solve for the Python version iteratively (or even, within the resolver
112+
// itself). The current strategy can also fail if the tool's requirements have greater
113+
// `requires-python` constraints, and we didn't see them in the initial solve. It can also fail
114+
// if the tool's requirements don't publish wheels for this interpreter version, though that's
115+
// rarer.
116+
let lower_bound = match requires_python.as_ref() {
117+
Bound::Included(version) => VersionSpecifier::greater_than_equal_version(version.clone()),
118+
Bound::Excluded(version) => VersionSpecifier::greater_than_version(version.clone()),
119+
Bound::Unbounded => unreachable!("`requires-python` should never be unbounded"),
120+
};
121+
122+
let upper_bound = match requires_python.as_ref() {
123+
Bound::Included(version) => {
124+
let major = version.release().first().copied().unwrap_or(0);
125+
let minor = version.release().get(1).copied().unwrap_or(0);
126+
VersionSpecifier::less_than_version(Version::new([major, minor + 1]))
127+
}
128+
Bound::Excluded(version) => {
129+
let major = version.release().first().copied().unwrap_or(0);
130+
let minor = version.release().get(1).copied().unwrap_or(0);
131+
VersionSpecifier::less_than_version(Version::new([major, minor + 1]))
132+
}
133+
Bound::Unbounded => unreachable!("`requires-python` should never be unbounded"),
134+
};
135+
136+
let python_request = PythonRequest::Version(VersionRequest::Range(
137+
VersionSpecifiers::from_iter([lower_bound, upper_bound]),
138+
PythonVariant::default(),
139+
));
140+
141+
debug!("Refining interpreter with: {python_request}");
142+
143+
let interpreter = PythonInstallation::find_or_download(
144+
Some(&python_request),
145+
EnvironmentPreference::OnlySystem,
146+
python_preference,
147+
python_downloads,
148+
client_builder,
149+
cache,
150+
Some(reporter),
151+
install_mirrors.python_install_mirror.as_deref(),
152+
install_mirrors.pypy_install_mirror.as_deref(),
153+
)
154+
.await?
155+
.into_interpreter();
156+
157+
Ok(Some(interpreter))
158+
}
159+
64160
/// Installs tool executables for a given package and handles any conflicts.
65161
pub(crate) fn install_executables(
66162
environment: &PythonEnvironment,

0 commit comments

Comments
 (0)