diff --git a/Cargo.lock b/Cargo.lock index 794ca45bb..f4af45d5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4396,7 +4396,6 @@ dependencies = [ "pep508_rs", "platform-tags", "predicates", - "pubgrub", "pypi-types", "rayon", "regex", diff --git a/crates/uv-resolver/src/lib.rs b/crates/uv-resolver/src/lib.rs index 023f8aa82..65a4223d3 100644 --- a/crates/uv-resolver/src/lib.rs +++ b/crates/uv-resolver/src/lib.rs @@ -10,6 +10,7 @@ pub use preferences::{Preference, PreferenceError}; pub use prerelease_mode::PreReleaseMode; pub use pubgrub::{PubGrubSpecifier, PubGrubSpecifierError}; pub use python_requirement::PythonRequirement; +pub use requires_python::{RequiresPython, RequiresPythonError}; pub use resolution::{AnnotationStyle, DisplayResolutionGraph, ResolutionGraph}; pub use resolution_mode::ResolutionMode; pub use resolver::{ @@ -39,6 +40,7 @@ mod prerelease_mode; mod pubgrub; mod python_requirement; mod redirect; +mod requires_python; mod resolution; mod resolution_mode; mod resolver; diff --git a/crates/uv-resolver/src/lock.rs b/crates/uv-resolver/src/lock.rs index 9a08258ec..f0b500503 100644 --- a/crates/uv-resolver/src/lock.rs +++ b/crates/uv-resolver/src/lock.rs @@ -20,7 +20,7 @@ use distribution_types::{ GitSourceDist, IndexUrl, PathBuiltDist, PathSourceDist, RegistryBuiltDist, RegistryBuiltWheel, RegistrySourceDist, RemoteSource, Resolution, ResolvedDist, ToUrlError, }; -use pep440_rs::{Version, VersionSpecifiers}; +use pep440_rs::Version; use pep508_rs::{MarkerEnvironment, MarkerTree, VerbatimUrl}; use platform_tags::{TagCompatibility, TagPriority, Tags}; use pypi_types::{HashDigest, ParsedArchiveUrl, ParsedGitUrl}; @@ -29,7 +29,7 @@ use uv_git::{GitReference, GitSha, RepositoryReference, ResolvedRepositoryRefere use uv_normalize::{ExtraName, GroupName, PackageName}; use crate::resolution::AnnotatedDist; -use crate::ResolutionGraph; +use crate::{RequiresPython, ResolutionGraph}; #[derive(Clone, Debug, serde::Deserialize)] #[serde(try_from = "LockWire")] @@ -37,7 +37,7 @@ pub struct Lock { version: u32, distributions: Vec, /// The range of supported Python versions. - requires_python: Option, + requires_python: Option, /// A map from distribution ID to index in `distributions`. /// /// This can be used to quickly lookup the full distribution for any ID @@ -107,7 +107,7 @@ impl Lock { /// Initialize a [`Lock`] from a list of [`Distribution`] entries. fn new( distributions: Vec, - requires_python: Option, + requires_python: Option, ) -> Result { let wire = LockWire { version: 1, @@ -123,7 +123,7 @@ impl Lock { } /// Returns the supported Python version range for the lockfile, if present. - pub fn requires_python(&self) -> Option<&VersionSpecifiers> { + pub fn requires_python(&self) -> Option<&RequiresPython> { self.requires_python.as_ref() } @@ -226,7 +226,7 @@ struct LockWire { #[serde(rename = "distribution")] distributions: Vec, #[serde(rename = "requires-python")] - requires_python: Option, + requires_python: Option, } impl From for LockWire { diff --git a/crates/uv-resolver/src/pubgrub/report.rs b/crates/uv-resolver/src/pubgrub/report.rs index e5fc14c0a..3b49cdf9b 100644 --- a/crates/uv-resolver/src/pubgrub/report.rs +++ b/crates/uv-resolver/src/pubgrub/report.rs @@ -13,12 +13,13 @@ use pubgrub::type_aliases::Map; use rustc_hash::FxHashMap; use distribution_types::IndexLocations; -use pep440_rs::{Version, VersionSpecifiers}; +use pep440_rs::Version; use uv_normalize::PackageName; use crate::candidate_selector::CandidateSelector; -use crate::python_requirement::{PythonRequirement, RequiresPython}; +use crate::python_requirement::{PythonRequirement, PythonTarget}; use crate::resolver::{IncompletePackage, UnavailablePackage, UnavailableReason}; +use crate::RequiresPython; use super::{PubGrubPackage, PubGrubPackageInner, PubGrubPython}; @@ -534,9 +535,11 @@ impl PubGrubReportFormatter<'_> { PubGrubPackageInner::Python(PubGrubPython::Target) ) { if let Some(python) = self.python_requirement { - if let Some(RequiresPython::Specifiers(specifiers)) = python.target() { + if let Some(PythonTarget::RequiresPython(requires_python)) = + python.target() + { hints.insert(PubGrubHint::RequiresPython { - requires_python: specifiers.clone(), + requires_python: requires_python.clone(), package: package.clone(), package_set: self .simplify_set(package_set, package) @@ -632,7 +635,7 @@ pub(crate) enum PubGrubHint { }, /// The `Requires-Python` requirement was not satisfied. RequiresPython { - requires_python: VersionSpecifiers, + requires_python: RequiresPython, #[derivative(PartialEq = "ignore", Hash = "ignore")] package: PubGrubPackage, #[derivative(PartialEq = "ignore", Hash = "ignore")] diff --git a/crates/uv-resolver/src/python_requirement.rs b/crates/uv-resolver/src/python_requirement.rs index c26deb78f..13554e0a3 100644 --- a/crates/uv-resolver/src/python_requirement.rs +++ b/crates/uv-resolver/src/python_requirement.rs @@ -1,9 +1,9 @@ -use std::collections::Bound; - use pep440_rs::VersionSpecifiers; use pep508_rs::StringVersion; use uv_interpreter::{Interpreter, PythonVersion}; +use crate::RequiresPython; + #[derive(Debug, Clone, Eq, PartialEq)] pub struct PythonRequirement { /// The installed version of Python. @@ -13,7 +13,7 @@ pub struct PythonRequirement { /// when specifying an alternate Python version for the resolution. /// /// If `None`, the target version is the same as the installed version. - target: Option, + target: Option, } impl PythonRequirement { @@ -22,7 +22,7 @@ impl PythonRequirement { pub fn from_python_version(interpreter: &Interpreter, python_version: &PythonVersion) -> Self { Self { installed: interpreter.python_full_version().clone(), - target: Some(RequiresPython::Specifier(StringVersion { + target: Some(PythonTarget::Version(StringVersion { string: python_version.to_string(), version: python_version.python_full_version(), })), @@ -33,11 +33,11 @@ impl PythonRequirement { /// [`MarkerEnvironment`]. pub fn from_requires_python( interpreter: &Interpreter, - requires_python: &VersionSpecifiers, + requires_python: &RequiresPython, ) -> Self { Self { installed: interpreter.python_full_version().clone(), - target: Some(RequiresPython::Specifiers(requires_python.clone())), + target: Some(PythonTarget::RequiresPython(requires_python.clone())), } } @@ -55,103 +55,49 @@ impl PythonRequirement { } /// Return the target version of Python. - pub fn target(&self) -> Option<&RequiresPython> { + pub fn target(&self) -> Option<&PythonTarget> { self.target.as_ref() } } #[derive(Debug, Clone, Eq, PartialEq)] -pub enum RequiresPython { - /// The [`RequiresPython`] specifier is a single version specifier, as provided via +pub enum PythonTarget { + /// The [`PythonTarget`] specifier is a single version specifier, as provided via /// `--python-version` on the command line. /// /// The use of a separate enum variant allows us to use a verbatim representation when reporting /// back to the user. - Specifier(StringVersion), - /// The [`RequiresPython`] specifier is a set of version specifiers, as extracted from the + Version(StringVersion), + /// The [`PythonTarget`] specifier is a set of version specifiers, as extracted from the /// `Requires-Python` field in a `pyproject.toml` or `METADATA` file. - Specifiers(VersionSpecifiers), + RequiresPython(RequiresPython), } -impl RequiresPython { - /// Returns `true` if the target Python is covered by the [`VersionSpecifiers`]. - /// - /// For example, if the target Python is `>=3.8`, then `>=3.7` would cover it. However, `>=3.9` - /// would not. - /// - /// We treat `Requires-Python` as a lower bound. For example, if the requirement expresses - /// `>=3.8, <4`, we treat it as `>=3.8`. `Requires-Python` itself was intended to enable - /// packages to drop support for older versions of Python without breaking installations on - /// those versions, and packages cannot know whether they are compatible with future, unreleased - /// versions of Python. - /// - /// See: - pub fn contains(&self, requires_python: &VersionSpecifiers) -> bool { +impl PythonTarget { + /// Returns `true` if the target Python is compatible with the [`VersionSpecifiers`]. + pub fn is_compatible_with(&self, target: &VersionSpecifiers) -> bool { match self { - RequiresPython::Specifier(specifier) => requires_python.contains(specifier), - RequiresPython::Specifiers(specifiers) => { - let Ok(target) = crate::pubgrub::PubGrubSpecifier::try_from(specifiers) else { - return false; - }; - - let Ok(requires_python) = - crate::pubgrub::PubGrubSpecifier::try_from(requires_python) - else { - return false; - }; - - // If the dependency has no lower bound, then it supports all versions. - let Some((requires_python_lower, _)) = requires_python.iter().next() else { - return true; - }; - - // If we have no lower bound, then there must be versions we support that the - // dependency does not. - let Some((target_lower, _)) = target.iter().next() else { - return false; - }; - - // We want, e.g., `target_lower` to be `>=3.8` and `requires_python_lower` to be - // `>=3.7`. - // - // That is: `requires_python_lower` should be less than or equal to `target_lower`. - match (requires_python_lower, target_lower) { - (Bound::Included(requires_python_lower), Bound::Included(target_lower)) => { - requires_python_lower <= target_lower - } - (Bound::Excluded(requires_python_lower), Bound::Included(target_lower)) => { - requires_python_lower < target_lower - } - (Bound::Included(requires_python_lower), Bound::Excluded(target_lower)) => { - requires_python_lower <= target_lower - } - (Bound::Excluded(requires_python_lower), Bound::Excluded(target_lower)) => { - requires_python_lower < target_lower - } - // If the dependency has no lower bound, then it supports all versions. - (Bound::Unbounded, _) => true, - // If we have no lower bound, then there must be versions we support that the - // dependency does not. - (_, Bound::Unbounded) => false, - } + PythonTarget::Version(version) => target.contains(version), + PythonTarget::RequiresPython(requires_python) => { + requires_python.is_contained_by(target) } } } - /// Returns the [`VersionSpecifiers`] for the [`RequiresPython`] specifier. - pub fn as_specifiers(&self) -> Option<&VersionSpecifiers> { + /// Returns the [`RequiresPython`] for the [`PythonTarget`] specifier. + pub fn as_requires_python(&self) -> Option<&RequiresPython> { match self { - RequiresPython::Specifier(_) => None, - RequiresPython::Specifiers(specifiers) => Some(specifiers), + PythonTarget::Version(_) => None, + PythonTarget::RequiresPython(requires_python) => Some(requires_python), } } } -impl std::fmt::Display for RequiresPython { +impl std::fmt::Display for PythonTarget { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - RequiresPython::Specifier(specifier) => std::fmt::Display::fmt(specifier, f), - RequiresPython::Specifiers(specifiers) => std::fmt::Display::fmt(specifiers, f), + PythonTarget::Version(specifier) => std::fmt::Display::fmt(specifier, f), + PythonTarget::RequiresPython(specifiers) => std::fmt::Display::fmt(specifiers, f), } } } diff --git a/crates/uv-resolver/src/requires_python.rs b/crates/uv-resolver/src/requires_python.rs new file mode 100644 index 000000000..8914447dc --- /dev/null +++ b/crates/uv-resolver/src/requires_python.rs @@ -0,0 +1,146 @@ +use std::collections::Bound; + +use itertools::Itertools; +use pubgrub::range::Range; + +use pep440_rs::{Version, VersionSpecifier, VersionSpecifiers}; + +#[derive(thiserror::Error, Debug)] +pub enum RequiresPythonError { + #[error(transparent)] + PubGrub(#[from] crate::pubgrub::PubGrubSpecifierError), +} + +/// The `Requires-Python` requirement specifier. +/// +/// We treat `Requires-Python` as a lower bound. For example, if the requirement expresses +/// `>=3.8, <4`, we treat it as `>=3.8`. `Requires-Python` itself was intended to enable +/// packages to drop support for older versions of Python without breaking installations on +/// those versions, and packages cannot know whether they are compatible with future, unreleased +/// versions of Python. +/// +/// See: +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub struct RequiresPython(VersionSpecifiers); + +impl RequiresPython { + /// Returns a [`RequiresPython`] to express `>=` equality with the given version. + pub fn greater_than_equal_version(version: Version) -> Self { + Self(VersionSpecifiers::from( + VersionSpecifier::greater_than_equal_version(version), + )) + } + + /// Returns a [`RequiresPython`] to express the union of the given version specifiers. + /// + /// For example, given `>=3.8` and `>=3.9`, this would return `>=3.8`. + pub fn union<'a>( + specifiers: impl Iterator, + ) -> Result, RequiresPythonError> { + // Convert to PubGrub range and perform a union. + let range = specifiers + .into_iter() + .map(crate::pubgrub::PubGrubSpecifier::try_from) + .fold_ok(None, |range: Option>, requires_python| { + if let Some(range) = range { + Some(range.union(&requires_python.into())) + } else { + Some(requires_python.into()) + } + })?; + + let Some(range) = range else { + return Ok(None); + }; + + // Convert back to PEP 440 specifiers. + let requires_python = Self( + range + .iter() + .flat_map(VersionSpecifier::from_bounds) + .collect(), + ); + + Ok(Some(requires_python)) + } + + /// Returns `true` if the `Requires-Python` is compatible with the given version. + pub fn contains(&self, version: &Version) -> bool { + self.0.contains(version) + } + + /// Returns `true` if the `Requires-Python` is compatible with the given version specifiers. + /// + /// For example, if the `Requires-Python` is `>=3.8`, then `>=3.7` would be considered + /// compatible, since all versions in the `Requires-Python` range are also covered by the + /// provided range. However, `>=3.9` would not be considered compatible, as the + /// `Requires-Python` includes Python 3.8, but `>=3.9` does not. + pub fn is_contained_by(&self, target: &VersionSpecifiers) -> bool { + let Ok(requires_python) = crate::pubgrub::PubGrubSpecifier::try_from(&self.0) else { + return false; + }; + + let Ok(target) = crate::pubgrub::PubGrubSpecifier::try_from(target) else { + return false; + }; + + // If the dependency has no lower bound, then it supports all versions. + let Some((target_lower, _)) = target.iter().next() else { + return true; + }; + + // If we have no lower bound, then there must be versions we support that the + // dependency does not. + let Some((requires_python_lower, _)) = requires_python.iter().next() else { + return false; + }; + + // We want, e.g., `requires_python_lower` to be `>=3.8` and `version_lower` to be + // `>=3.7`. + // + // That is: `version_lower` should be less than or equal to `requires_python_lower`. + match (target_lower, requires_python_lower) { + (Bound::Included(target_lower), Bound::Included(requires_python_lower)) => { + target_lower <= requires_python_lower + } + (Bound::Excluded(target_lower), Bound::Included(requires_python_lower)) => { + target_lower < requires_python_lower + } + (Bound::Included(target_lower), Bound::Excluded(requires_python_lower)) => { + target_lower <= requires_python_lower + } + (Bound::Excluded(target_lower), Bound::Excluded(requires_python_lower)) => { + target_lower < requires_python_lower + } + // If the dependency has no lower bound, then it supports all versions. + (Bound::Unbounded, _) => true, + // If we have no lower bound, then there must be versions we support that the + // dependency does not. + (_, Bound::Unbounded) => false, + } + } + + /// Returns the [`VersionSpecifiers`] for the `Requires-Python` specifier. + pub fn specifiers(&self) -> &VersionSpecifiers { + &self.0 + } +} + +impl std::fmt::Display for RequiresPython { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.0, f) + } +} + +impl serde::Serialize for RequiresPython { + fn serialize(&self, serializer: S) -> Result { + self.0.serialize(serializer) + } +} + +impl<'de> serde::Deserialize<'de> for RequiresPython { + fn deserialize>(deserializer: D) -> Result { + let specifiers = VersionSpecifiers::deserialize(deserializer)?; + Ok(Self(specifiers)) + } +} diff --git a/crates/uv-resolver/src/resolution/graph.rs b/crates/uv-resolver/src/resolution/graph.rs index 43ff5bed4..4363aecec 100644 --- a/crates/uv-resolver/src/resolution/graph.rs +++ b/crates/uv-resolver/src/resolution/graph.rs @@ -9,7 +9,7 @@ use rustc_hash::{FxHashMap, FxHashSet}; use distribution_types::{ Dist, DistributionMetadata, Name, ResolutionDiagnostic, VersionId, VersionOrUrlRef, }; -use pep440_rs::{Version, VersionSpecifier, VersionSpecifiers}; +use pep440_rs::{Version, VersionSpecifier}; use pep508_rs::{MarkerEnvironment, MarkerTree}; use pypi_types::{ParsedUrlError, Yanked}; use uv_git::GitResolver; @@ -17,12 +17,13 @@ use uv_normalize::{ExtraName, GroupName, PackageName}; use crate::preferences::Preferences; use crate::pubgrub::{PubGrubDistribution, PubGrubPackageInner}; -use crate::python_requirement::RequiresPython; +use crate::python_requirement::PythonTarget; use crate::redirect::url_to_precise; use crate::resolution::AnnotatedDist; use crate::resolver::Resolution; use crate::{ - InMemoryIndex, Manifest, MetadataResponse, PythonRequirement, ResolveError, VersionsResponse, + InMemoryIndex, Manifest, MetadataResponse, PythonRequirement, RequiresPython, ResolveError, + VersionsResponse, }; /// A complete resolution graph in which every node represents a pinned package and every edge @@ -32,7 +33,7 @@ pub struct ResolutionGraph { /// The underlying graph. pub(crate) petgraph: Graph, /// The range of supported Python versions. - pub(crate) requires_python: Option, + pub(crate) requires_python: Option, /// Any diagnostics that were encountered while building the graph. pub(crate) diagnostics: Vec, } @@ -319,7 +320,7 @@ impl ResolutionGraph { // included packages. let requires_python = python .target() - .and_then(RequiresPython::as_specifiers) + .and_then(PythonTarget::as_requires_python) .cloned(); Ok(Self { diff --git a/crates/uv-resolver/src/resolver/mod.rs b/crates/uv-resolver/src/resolver/mod.rs index 367fca428..2730ba068 100644 --- a/crates/uv-resolver/src/resolver/mod.rs +++ b/crates/uv-resolver/src/resolver/mod.rs @@ -730,7 +730,7 @@ impl ResolverState( interpreter: &Interpreter, tags: &Tags, markers: Option<&MarkerEnvironment>, - requires_python: Option<&VersionSpecifiers>, + requires_python: Option<&RequiresPython>, client: &RegistryClient, flat_index: &FlatIndex, index: &InMemoryIndex, diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 7f81a2e70..2ea12b58b 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -1,10 +1,7 @@ use anstream::eprint; -use itertools::Itertools; -use pubgrub::range::Range; use distribution_types::{IndexLocations, UnresolvedRequirementSpecification}; use install_wheel_rs::linker::LinkMode; -use pep440_rs::{Version, VersionSpecifier, VersionSpecifiers}; use uv_cache::Cache; use uv_client::RegistryClientBuilder; use uv_configuration::{ @@ -17,7 +14,7 @@ use uv_git::GitResolver; use uv_interpreter::PythonEnvironment; use uv_normalize::PackageName; use uv_requirements::upgrade::{read_lockfile, LockedRequirements}; -use uv_resolver::{ExcludeNewer, FlatIndex, InMemoryIndex, Lock, OptionsBuilder, PubGrubSpecifier}; +use uv_resolver::{ExcludeNewer, FlatIndex, InMemoryIndex, Lock, OptionsBuilder, RequiresPython}; use uv_types::{BuildIsolation, EmptyInstalledPackages, HashStrategy, InFlight}; use uv_warnings::warn_user; @@ -109,38 +106,20 @@ pub(super) async fn do_lock( // // For a workspace, we compute the union of all workspace requires-python values, ensuring we // keep track of `None` vs. a full range. - let requires_python_workspace = workspace - .packages() - .values() - .filter_map(|member| { + let requires_python_workspace = + RequiresPython::union(workspace.packages().values().filter_map(|member| { member .pyproject_toml() .project .as_ref() .and_then(|project| project.requires_python.as_ref()) - }) - // Convert to pubgrub range, perform the union, convert back to pep440_rs. - .map(PubGrubSpecifier::try_from) - .fold_ok(None, |range: Option>, requires_python| { - if let Some(range) = range { - Some(range.union(&requires_python.into())) - } else { - Some(requires_python.into()) - } - })? - .map(|range| { - range - .iter() - .flat_map(VersionSpecifier::from_bounds) - .collect() - }); + }))?; let requires_python = if let Some(requires_python) = requires_python_workspace { requires_python } else { - let requires_python = VersionSpecifiers::from( - VersionSpecifier::greater_than_equal_version(venv.interpreter().python_minor_version()), - ); + let requires_python = + RequiresPython::greater_than_equal_version(venv.interpreter().python_minor_version()); if let Some(root_project_name) = root_project_name.as_ref() { warn_user!( "No `requires-python` field found in `{root_project_name}`. Defaulting to `{requires_python}`.", diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 0e342f95e..cef7f1378 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -7,7 +7,7 @@ use tracing::debug; use distribution_types::{IndexLocations, Resolution}; use install_wheel_rs::linker::LinkMode; -use pep440_rs::{Version, VersionSpecifiers}; +use pep440_rs::Version; use uv_cache::Cache; use uv_client::{BaseClientBuilder, Connectivity, RegistryClientBuilder}; use uv_configuration::{ @@ -21,7 +21,7 @@ use uv_git::GitResolver; use uv_installer::{SatisfiesResult, SitePackages}; use uv_interpreter::{find_default_interpreter, PythonEnvironment}; use uv_requirements::{RequirementsSource, RequirementsSpecification}; -use uv_resolver::{FlatIndex, InMemoryIndex, Options}; +use uv_resolver::{FlatIndex, InMemoryIndex, Options, RequiresPython}; use uv_types::{BuildIsolation, HashStrategy, InFlight}; use crate::commands::pip; @@ -34,7 +34,7 @@ pub(crate) mod sync; #[derive(thiserror::Error, Debug)] pub(crate) enum ProjectError { #[error("The current Python version ({0}) is not compatible with the locked Python requirement ({1})")] - RequiresPython(Version, VersionSpecifiers), + PythonIncompatibility(Version, RequiresPython), #[error(transparent)] Interpreter(#[from] uv_interpreter::Error), @@ -64,7 +64,7 @@ pub(crate) enum ProjectError { Operation(#[from] pip::operations::Error), #[error(transparent)] - PubGrubSpecifier(#[from] uv_resolver::PubGrubSpecifierError), + RequiresPython(#[from] uv_resolver::RequiresPythonError), } /// Initialize a virtual environment for the current project. diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index 036ae7aee..5b9ccff7f 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -82,7 +82,7 @@ pub(super) async fn do_sync( // Validate that the Python version is supported by the lockfile. if let Some(requires_python) = lock.requires_python() { if !requires_python.contains(venv.interpreter().python_version()) { - return Err(ProjectError::RequiresPython( + return Err(ProjectError::PythonIncompatibility( venv.interpreter().python_version().clone(), requires_python.clone(), ));