Skip to content

Commit

Permalink
Respect requires-python when installing tools (#10401)
Browse files Browse the repository at this point in the history
## 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 (*)
...
```
  • Loading branch information
charliermarsh authored Jan 8, 2025
1 parent 333f03f commit 4c161d2
Show file tree
Hide file tree
Showing 10 changed files with 320 additions and 64 deletions.
2 changes: 1 addition & 1 deletion crates/uv-requirements/src/specification.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ use uv_workspace::pyproject::PyProjectToml;

use crate::RequirementsSource;

#[derive(Debug, Default)]
#[derive(Debug, Default, Clone)]
pub struct RequirementsSpecification {
/// The name of the project specifying requirements.
pub project: Option<PackageName>,
Expand Down
28 changes: 28 additions & 0 deletions crates/uv-resolver/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ use crate::fork_urls::ForkUrls;
use crate::prerelease::AllowPrerelease;
use crate::pubgrub::{PubGrubPackage, PubGrubPackageInner, PubGrubReportFormatter};
use crate::python_requirement::PythonRequirement;
use crate::requires_python::LowerBound;
use crate::resolution::ConflictingDistributionError;
use crate::resolver::{
MetadataUnavailable, ResolverEnvironment, UnavailablePackage, UnavailableReason,
Expand Down Expand Up @@ -294,6 +295,33 @@ impl NoSolutionError {
strip(derivation_tree).expect("derivation tree should contain at least one term")
}

/// Given a [`DerivationTree`], identify the largest required Python version that is missing.
pub fn find_requires_python(&self) -> LowerBound {
fn find(derivation_tree: &ErrorTree, minimum: &mut LowerBound) {
match derivation_tree {
DerivationTree::Derived(derived) => {
find(derived.cause1.as_ref(), minimum);
find(derived.cause2.as_ref(), minimum);
}
DerivationTree::External(External::FromDependencyOf(.., package, version)) => {
if let PubGrubPackageInner::Python(_) = &**package {
if let Some((lower, ..)) = version.bounding_range() {
let lower = LowerBound::new(lower.cloned());
if lower > *minimum {
*minimum = lower;
}
}
}
}
DerivationTree::External(_) => {}
}
}

let mut minimum = LowerBound::default();
find(&self.error, &mut minimum);
minimum
}

/// Initialize a [`NoSolutionHeader`] for this error.
pub fn header(&self) -> NoSolutionHeader {
NoSolutionHeader::new(self.env.clone())
Expand Down
66 changes: 40 additions & 26 deletions crates/uv-resolver/src/requires_python.rs
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,17 @@ impl SimplifiedMarkerTree {
pub struct LowerBound(Bound<Version>);

impl LowerBound {
/// Initialize a [`LowerBound`] with the given bound.
///
/// These bounds use release-only semantics when comparing versions.
pub fn new(bound: Bound<Version>) -> Self {
Self(match bound {
Bound::Included(version) => Bound::Included(version.only_release()),
Bound::Excluded(version) => Bound::Excluded(version.only_release()),
Bound::Unbounded => Bound::Unbounded,
})
}

/// Return the [`LowerBound`] truncated to the major and minor version.
fn major_minor(&self) -> Self {
match &self.0 {
Expand All @@ -600,6 +611,15 @@ impl LowerBound {
Bound::Unbounded => Self(Bound::Unbounded),
}
}

/// Returns `true` if the lower bound contains the given version.
pub fn contains(&self, version: &Version) -> bool {
match self.0 {
Bound::Included(ref bound) => bound <= version,
Bound::Excluded(ref bound) => bound < version,
Bound::Unbounded => true,
}
}
}

impl PartialOrd for LowerBound {
Expand Down Expand Up @@ -668,19 +688,6 @@ impl Default for LowerBound {
}
}

impl LowerBound {
/// Initialize a [`LowerBound`] with the given bound.
///
/// These bounds use release-only semantics when comparing versions.
pub fn new(bound: Bound<Version>) -> Self {
Self(match bound {
Bound::Included(version) => Bound::Included(version.only_release()),
Bound::Excluded(version) => Bound::Excluded(version.only_release()),
Bound::Unbounded => Bound::Unbounded,
})
}
}

impl Deref for LowerBound {
type Target = Bound<Version>;

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

impl UpperBound {
/// Initialize a [`UpperBound`] with the given bound.
///
/// These bounds use release-only semantics when comparing versions.
pub fn new(bound: Bound<Version>) -> Self {
Self(match bound {
Bound::Included(version) => Bound::Included(version.only_release()),
Bound::Excluded(version) => Bound::Excluded(version.only_release()),
Bound::Unbounded => Bound::Unbounded,
})
}

/// Return the [`UpperBound`] truncated to the major and minor version.
fn major_minor(&self) -> Self {
match &self.0 {
Expand All @@ -721,6 +739,15 @@ impl UpperBound {
Bound::Unbounded => Self(Bound::Unbounded),
}
}

/// Returns `true` if the upper bound contains the given version.
pub fn contains(&self, version: &Version) -> bool {
match self.0 {
Bound::Included(ref bound) => bound >= version,
Bound::Excluded(ref bound) => bound > version,
Bound::Unbounded => true,
}
}
}

impl PartialOrd for UpperBound {
Expand Down Expand Up @@ -787,19 +814,6 @@ impl Default for UpperBound {
}
}

impl UpperBound {
/// Initialize a [`UpperBound`] with the given bound.
///
/// These bounds use release-only semantics when comparing versions.
pub fn new(bound: Bound<Version>) -> Self {
Self(match bound {
Bound::Included(version) => Bound::Included(version.only_release()),
Bound::Excluded(version) => Bound::Excluded(version.only_release()),
Bound::Unbounded => Bound::Unbounded,
})
}
}

impl Deref for UpperBound {
type Target = Bound<Version>;

Expand Down
4 changes: 2 additions & 2 deletions crates/uv/src/commands/project/environment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ impl CachedEnvironment {
/// interpreter.
pub(crate) async fn get_or_create(
spec: EnvironmentSpecification<'_>,
interpreter: Interpreter,
interpreter: &Interpreter,
settings: &ResolverInstallerSettings,
state: &SharedState,
resolve: Box<dyn ResolveLogger>,
Expand All @@ -56,7 +56,7 @@ impl CachedEnvironment {
"Caching via interpreter: `{}`",
interpreter.sys_executable().display()
);
interpreter
interpreter.clone()
};

// Resolve the requirements with the interpreter.
Expand Down
2 changes: 1 addition & 1 deletion crates/uv/src/commands/project/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1086,7 +1086,7 @@ pub(crate) async fn resolve_names(
Ok(requirements)
}

#[derive(Debug)]
#[derive(Debug, Clone)]
pub(crate) struct EnvironmentSpecification<'lock> {
/// The requirements to include in the environment.
requirements: RequirementsSpecification,
Expand Down
4 changes: 2 additions & 2 deletions crates/uv/src/commands/project/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ pub(crate) async fn run(
RequirementsSpecification::from_overrides(requirements, constraints, overrides);
let result = CachedEnvironment::get_or_create(
EnvironmentSpecification::from(spec),
interpreter,
&interpreter,
&settings,
&state,
if show_resolution {
Expand Down Expand Up @@ -852,7 +852,7 @@ pub(crate) async fn run(
lock.as_ref()
.map(|(lock, install_path)| (lock, install_path.as_ref())),
),
base_interpreter.clone(),
&base_interpreter,
&settings,
&state,
if show_resolution {
Expand Down
110 changes: 103 additions & 7 deletions crates/uv/src/commands/tool/common.rs
Original file line number Diff line number Diff line change
@@ -1,25 +1,32 @@
use std::fmt::Write;
use std::{collections::BTreeSet, ffi::OsString};

use anyhow::{bail, Context};
use itertools::Itertools;
use owo_colors::OwoColorize;
use std::collections::Bound;
use std::fmt::Write;
use std::{collections::BTreeSet, ffi::OsString};
use tracing::{debug, warn};

use uv_cache::Cache;
use uv_client::BaseClientBuilder;
use uv_distribution_types::{InstalledDist, Name};
#[cfg(unix)]
use uv_fs::replace_symlink;
use uv_fs::Simplified;
use uv_installer::SitePackages;
use uv_pep440::{Version, VersionSpecifier, VersionSpecifiers};
use uv_pep508::PackageName;
use uv_pypi_types::Requirement;
use uv_python::PythonEnvironment;
use uv_settings::ToolOptions;
use uv_python::{
EnvironmentPreference, Interpreter, PythonDownloads, PythonEnvironment, PythonInstallation,
PythonPreference, PythonRequest, PythonVariant, VersionRequest,
};
use uv_settings::{PythonInstallMirrors, ToolOptions};
use uv_shell::Shell;
use uv_tool::{entrypoint_paths, tool_executable_dir, InstalledTools, Tool, ToolEntrypoint};
use uv_warnings::warn_user;

use crate::commands::ExitStatus;
use crate::commands::project::ProjectError;
use crate::commands::reporters::PythonDownloadReporter;
use crate::commands::{pip, ExitStatus};
use crate::printer::Printer;

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

/// Given a no-solution error and the [`Interpreter`] that was used during the solve, attempt to
/// discover an alternate [`Interpreter`] that satisfies the `requires-python` constraint.
pub(crate) async fn refine_interpreter(
interpreter: &Interpreter,
python_request: Option<&PythonRequest>,
err: &pip::operations::Error,
client_builder: &BaseClientBuilder<'_>,
reporter: &PythonDownloadReporter,
install_mirrors: &PythonInstallMirrors,
python_preference: PythonPreference,
python_downloads: PythonDownloads,
cache: &Cache,
) -> anyhow::Result<Option<Interpreter>, ProjectError> {
let pip::operations::Error::Resolve(uv_resolver::ResolveError::NoSolution(ref no_solution_err)) =
err
else {
return Ok(None);
};

// Infer the `requires-python` constraint from the error.
let requires_python = no_solution_err.find_requires_python();

// If the existing interpreter already satisfies the `requires-python` constraint, we don't need
// to refine it. We'd expect to fail again anyway.
if requires_python.contains(interpreter.python_version()) {
return Ok(None);
}

// If the user passed a `--python` request, and the refined interpreter is incompatible, we
// can't use it.
if let Some(python_request) = python_request {
if !python_request.satisfied(interpreter, cache) {
return Ok(None);
}
}

// We want an interpreter that's as close to the required version as possible. If we choose the
// "latest" Python, we risk choosing a version that lacks wheels for the tool's requirements
// (assuming those requirements don't publish source distributions).
//
// TODO(charlie): Solve for the Python version iteratively (or even, within the resolver
// itself). The current strategy can also fail if the tool's requirements have greater
// `requires-python` constraints, and we didn't see them in the initial solve. It can also fail
// if the tool's requirements don't publish wheels for this interpreter version, though that's
// rarer.
let lower_bound = match requires_python.as_ref() {
Bound::Included(version) => VersionSpecifier::greater_than_equal_version(version.clone()),
Bound::Excluded(version) => VersionSpecifier::greater_than_version(version.clone()),
Bound::Unbounded => unreachable!("`requires-python` should never be unbounded"),
};

let upper_bound = match requires_python.as_ref() {
Bound::Included(version) => {
let major = version.release().first().copied().unwrap_or(0);
let minor = version.release().get(1).copied().unwrap_or(0);
VersionSpecifier::less_than_version(Version::new([major, minor + 1]))
}
Bound::Excluded(version) => {
let major = version.release().first().copied().unwrap_or(0);
let minor = version.release().get(1).copied().unwrap_or(0);
VersionSpecifier::less_than_version(Version::new([major, minor + 1]))
}
Bound::Unbounded => unreachable!("`requires-python` should never be unbounded"),
};

let python_request = PythonRequest::Version(VersionRequest::Range(
VersionSpecifiers::from_iter([lower_bound, upper_bound]),
PythonVariant::default(),
));

debug!("Refining interpreter with: {python_request}");

let interpreter = PythonInstallation::find_or_download(
Some(&python_request),
EnvironmentPreference::OnlySystem,
python_preference,
python_downloads,
client_builder,
cache,
Some(reporter),
install_mirrors.python_install_mirror.as_deref(),
install_mirrors.pypy_install_mirror.as_deref(),
)
.await?
.into_interpreter();

Ok(Some(interpreter))
}

/// Installs tool executables for a given package and handles any conflicts.
pub(crate) fn install_executables(
environment: &PythonEnvironment,
Expand Down
Loading

0 comments on commit 4c161d2

Please sign in to comment.