Skip to content

Respect requires-python when installing tools #10401

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jan 8, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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}");

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For some reason I don't love "refining", but I don't have a great alternative off the top of my head :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lol, fair!

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
Loading