diff --git a/Cargo.lock b/Cargo.lock index d1bb68812..0094ec132 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4329,6 +4329,7 @@ dependencies = [ "uv-pep508", "uv-pypi-types", "uv-warnings", + "version-ranges", "walkdir", "zip", ] diff --git a/crates/uv-build-backend/Cargo.toml b/crates/uv-build-backend/Cargo.toml index a13c50e75..b1e924c0e 100644 --- a/crates/uv-build-backend/Cargo.toml +++ b/crates/uv-build-backend/Cargo.toml @@ -31,6 +31,7 @@ spdx = { workspace = true } thiserror = { workspace = true } toml = { workspace = true } tracing = { workspace = true } +version-ranges = { workspace = true } walkdir = { workspace = true } zip = { workspace = true } diff --git a/crates/uv-build-backend/src/metadata.rs b/crates/uv-build-backend/src/metadata.rs index d3ed665d2..30219acb7 100644 --- a/crates/uv-build-backend/src/metadata.rs +++ b/crates/uv-build-backend/src/metadata.rs @@ -9,10 +9,11 @@ use std::str::FromStr; use tracing::debug; use uv_fs::Simplified; use uv_normalize::{ExtraName, PackageName}; -use uv_pep440::{Version, VersionRangesSpecifier, VersionSpecifiers}; +use uv_pep440::{Version, VersionSpecifiers}; use uv_pep508::{Requirement, VersionOrUrl}; use uv_pypi_types::{Metadata23, VerbatimParsedUrl}; use uv_warnings::warn_user_once; +use version_ranges::Ranges; #[derive(Debug, Error)] pub enum ValidationError { @@ -134,9 +135,9 @@ impl PyProjectToml { ); passed = false; } - VersionRangesSpecifier::from_pep440_specifiers(specifier) - .ok() - .and_then(|specifier| Some(specifier.bounding_range()?.1 != Bound::Unbounded)) + Ranges::from(specifier.clone()) + .bounding_range() + .map(|bounding_range| bounding_range.1 != Bound::Unbounded) .unwrap_or(false) } }; diff --git a/crates/uv-pep440/Cargo.toml b/crates/uv-pep440/Cargo.toml index 2afc944a9..cc8aab8e0 100644 --- a/crates/uv-pep440/Cargo.toml +++ b/crates/uv-pep440/Cargo.toml @@ -25,6 +25,7 @@ serde = { workspace = true, features = ["derive"] } tracing = { workspace = true, optional = true } unicode-width = { workspace = true } unscanny = { workspace = true } +# Adds conversions from [`VersionSpecifiers`] to [`version_ranges::Ranges`] version-ranges = { workspace = true, optional = true } [dev-dependencies] diff --git a/crates/uv-pep440/src/lib.rs b/crates/uv-pep440/src/lib.rs index 14bc8f05a..982d6ede9 100644 --- a/crates/uv-pep440/src/lib.rs +++ b/crates/uv-pep440/src/lib.rs @@ -24,7 +24,7 @@ #![warn(missing_docs)] #[cfg(feature = "version-ranges")] -pub use version_ranges_specifier::{VersionRangesSpecifier, VersionRangesSpecifierError}; +pub use version_ranges::{release_specifier_to_range, release_specifiers_to_ranges}; pub use { version::{ LocalSegment, Operator, OperatorParseError, Prerelease, PrereleaseKind, Version, @@ -42,4 +42,4 @@ mod version_specifier; #[cfg(test)] mod tests; #[cfg(feature = "version-ranges")] -mod version_ranges_specifier; +mod version_ranges; diff --git a/crates/uv-pep440/src/version.rs b/crates/uv-pep440/src/version.rs index 4abf0cf21..26b3831ec 100644 --- a/crates/uv-pep440/src/version.rs +++ b/crates/uv-pep440/src/version.rs @@ -32,6 +32,8 @@ pub enum Operator { /// `!= 1.2.*` NotEqualStar, /// `~=` + /// + /// Invariant: With `~=`, there are always at least 2 release segments. TildeEqual, /// `<` LessThan, diff --git a/crates/uv-pep440/src/version_ranges.rs b/crates/uv-pep440/src/version_ranges.rs new file mode 100644 index 000000000..41c45fe29 --- /dev/null +++ b/crates/uv-pep440/src/version_ranges.rs @@ -0,0 +1,192 @@ +//! Convert [`VersionSpecifiers`] to [`version_ranges::Ranges`]. + +use version_ranges::Ranges; + +use crate::{Operator, Prerelease, Version, VersionSpecifier, VersionSpecifiers}; + +impl From for Ranges { + /// Convert [`VersionSpecifiers`] to a PubGrub-compatible version range, using PEP 440 + /// semantics. + fn from(specifiers: VersionSpecifiers) -> Self { + let mut range = Ranges::full(); + for specifier in specifiers { + range = range.intersection(&Self::from(specifier)); + } + range + } +} + +impl From for Ranges { + /// Convert the [`VersionSpecifier`] to a PubGrub-compatible version range, using PEP 440 + /// semantics. + fn from(specifier: VersionSpecifier) -> Self { + let VersionSpecifier { operator, version } = specifier; + match operator { + Operator::Equal => Ranges::singleton(version), + Operator::ExactEqual => Ranges::singleton(version), + Operator::NotEqual => Ranges::singleton(version).complement(), + Operator::TildeEqual => { + let [rest @ .., last, _] = version.release() else { + unreachable!("~= must have at least two segments"); + }; + let upper = Version::new(rest.iter().chain([&(last + 1)])) + .with_epoch(version.epoch()) + .with_dev(Some(0)); + + Ranges::from_range_bounds(version..upper) + } + Operator::LessThan => { + if version.any_prerelease() { + Ranges::strictly_lower_than(version) + } else { + // Per PEP 440: "The exclusive ordered comparison Ranges::lower_than(version), + Operator::GreaterThan => { + // Per PEP 440: "The exclusive ordered comparison >V MUST NOT allow a post-release of + // the given version unless V itself is a post release." + + if let Some(dev) = version.dev() { + Ranges::higher_than(version.with_dev(Some(dev + 1))) + } else if let Some(post) = version.post() { + Ranges::higher_than(version.with_post(Some(post + 1))) + } else { + Ranges::strictly_higher_than(version.with_max(Some(0))) + } + } + Operator::GreaterThanEqual => Ranges::higher_than(version), + Operator::EqualStar => { + let low = version.with_dev(Some(0)); + let mut high = low.clone(); + if let Some(post) = high.post() { + high = high.with_post(Some(post + 1)); + } else if let Some(pre) = high.pre() { + high = high.with_pre(Some(Prerelease { + kind: pre.kind, + number: pre.number + 1, + })); + } else { + let mut release = high.release().to_vec(); + *release.last_mut().unwrap() += 1; + high = high.with_release(release); + } + Ranges::from_range_bounds(low..high) + } + Operator::NotEqualStar => { + let low = version.with_dev(Some(0)); + let mut high = low.clone(); + if let Some(post) = high.post() { + high = high.with_post(Some(post + 1)); + } else if let Some(pre) = high.pre() { + high = high.with_pre(Some(Prerelease { + kind: pre.kind, + number: pre.number + 1, + })); + } else { + let mut release = high.release().to_vec(); + *release.last_mut().unwrap() += 1; + high = high.with_release(release); + } + Ranges::from_range_bounds(low..high).complement() + } + } + } +} + +/// Convert the [`VersionSpecifiers`] to a PubGrub-compatible version range, using release-only +/// semantics. +/// +/// Assumes that the range will only be tested against versions that consist solely of release +/// segments (e.g., `3.12.0`, but not `3.12.0b1`). +/// +/// These semantics are used for testing Python compatibility (e.g., `requires-python` against +/// the user's installed Python version). In that context, it's more intuitive that `3.13.0b0` +/// is allowed for projects that declare `requires-python = ">3.13"`. +/// +/// See: +pub fn release_specifiers_to_ranges(specifiers: VersionSpecifiers) -> Ranges { + let mut range = Ranges::full(); + for specifier in specifiers { + range = range.intersection(&release_specifier_to_range(specifier)); + } + range +} + +/// Convert the [`VersionSpecifier`] to a PubGrub-compatible version range, using release-only +/// semantics. +/// +/// Assumes that the range will only be tested against versions that consist solely of release +/// segments (e.g., `3.12.0`, but not `3.12.0b1`). +/// +/// These semantics are used for testing Python compatibility (e.g., `requires-python` against +/// the user's installed Python version). In that context, it's more intuitive that `3.13.0b0` +/// is allowed for projects that declare `requires-python = ">3.13"`. +/// +/// See: +pub fn release_specifier_to_range(specifier: VersionSpecifier) -> Ranges { + let VersionSpecifier { operator, version } = specifier; + match operator { + Operator::Equal => { + let version = version.only_release(); + Ranges::singleton(version) + } + Operator::ExactEqual => { + let version = version.only_release(); + Ranges::singleton(version) + } + Operator::NotEqual => { + let version = version.only_release(); + Ranges::singleton(version).complement() + } + Operator::TildeEqual => { + let [rest @ .., last, _] = version.release() else { + unreachable!("~= must have at least two segments"); + }; + let upper = Version::new(rest.iter().chain([&(last + 1)])); + let version = version.only_release(); + Ranges::from_range_bounds(version..upper) + } + Operator::LessThan => { + let version = version.only_release(); + Ranges::strictly_lower_than(version) + } + Operator::LessThanEqual => { + let version = version.only_release(); + Ranges::lower_than(version) + } + Operator::GreaterThan => { + let version = version.only_release(); + Ranges::strictly_higher_than(version) + } + Operator::GreaterThanEqual => { + let version = version.only_release(); + Ranges::higher_than(version) + } + Operator::EqualStar => { + let low = version.only_release(); + let high = { + let mut high = low.clone(); + let mut release = high.release().to_vec(); + *release.last_mut().unwrap() += 1; + high = high.with_release(release); + high + }; + Ranges::from_range_bounds(low..high) + } + Operator::NotEqualStar => { + let low = version.only_release(); + let high = { + let mut high = low.clone(); + let mut release = high.release().to_vec(); + *release.last_mut().unwrap() += 1; + high = high.with_release(release); + high + }; + Ranges::from_range_bounds(low..high).complement() + } + } +} diff --git a/crates/uv-pep440/src/version_ranges_specifier.rs b/crates/uv-pep440/src/version_ranges_specifier.rs deleted file mode 100644 index 0efa2112a..000000000 --- a/crates/uv-pep440/src/version_ranges_specifier.rs +++ /dev/null @@ -1,273 +0,0 @@ -use std::error::Error; -use std::fmt::{Display, Formatter}; -use std::ops::Bound; - -use version_ranges::Ranges; - -use crate::{Operator, Prerelease, Version, VersionSpecifier, VersionSpecifiers}; - -/// The conversion between PEP 440 [`VersionSpecifier`] and version-ranges -/// [`VersionRangesSpecifier`] failed. -#[derive(Debug)] -pub enum VersionRangesSpecifierError { - /// The `~=` operator requires at least two release segments - InvalidTildeEquals(VersionSpecifier), -} - -impl Error for VersionRangesSpecifierError {} - -impl Display for VersionRangesSpecifierError { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - Self::InvalidTildeEquals(specifier) => { - write!( - f, - "The `~=` operator requires at least two release segments: `{specifier}`" - ) - } - } - } -} - -/// A range of versions that can be used to satisfy a requirement. -#[derive(Debug)] -pub struct VersionRangesSpecifier(Ranges); - -impl VersionRangesSpecifier { - /// Returns an iterator over the bounds of the [`VersionRangesSpecifier`]. - pub fn iter(&self) -> impl Iterator, &Bound)> { - self.0.iter() - } - - /// Return the bounding [`Ranges`] of the [`VersionRangesSpecifier`]. - pub fn bounding_range(&self) -> Option<(Bound<&Version>, Bound<&Version>)> { - self.0.bounding_range() - } -} - -impl From> for VersionRangesSpecifier { - fn from(range: Ranges) -> Self { - VersionRangesSpecifier(range) - } -} - -impl From for Ranges { - /// Convert a PubGrub specifier to a range of versions. - fn from(specifier: VersionRangesSpecifier) -> Self { - specifier.0 - } -} - -impl VersionRangesSpecifier { - /// Convert [`VersionSpecifiers`] to a PubGrub-compatible version range, using PEP 440 - /// semantics. - pub fn from_pep440_specifiers( - specifiers: &VersionSpecifiers, - ) -> Result { - let mut range = Ranges::full(); - for specifier in specifiers.iter() { - range = range.intersection(&Self::from_pep440_specifier(specifier)?.into()); - } - Ok(Self(range)) - } - - /// Convert the [`VersionSpecifier`] to a PubGrub-compatible version range, using PEP 440 - /// semantics. - pub fn from_pep440_specifier( - specifier: &VersionSpecifier, - ) -> Result { - let ranges = match specifier.operator() { - Operator::Equal => { - let version = specifier.version().clone(); - Ranges::singleton(version) - } - Operator::ExactEqual => { - let version = specifier.version().clone(); - Ranges::singleton(version) - } - Operator::NotEqual => { - let version = specifier.version().clone(); - Ranges::singleton(version).complement() - } - Operator::TildeEqual => { - let [rest @ .., last, _] = specifier.version().release() else { - return Err(VersionRangesSpecifierError::InvalidTildeEquals( - specifier.clone(), - )); - }; - let upper = Version::new(rest.iter().chain([&(last + 1)])) - .with_epoch(specifier.version().epoch()) - .with_dev(Some(0)); - let version = specifier.version().clone(); - Ranges::from_range_bounds(version..upper) - } - Operator::LessThan => { - let version = specifier.version().clone(); - if version.any_prerelease() { - Ranges::strictly_lower_than(version) - } else { - // Per PEP 440: "The exclusive ordered comparison { - let version = specifier.version().clone(); - Ranges::lower_than(version) - } - Operator::GreaterThan => { - // Per PEP 440: "The exclusive ordered comparison >V MUST NOT allow a post-release of - // the given version unless V itself is a post release." - let version = specifier.version().clone(); - if let Some(dev) = version.dev() { - Ranges::higher_than(version.with_dev(Some(dev + 1))) - } else if let Some(post) = version.post() { - Ranges::higher_than(version.with_post(Some(post + 1))) - } else { - Ranges::strictly_higher_than(version.with_max(Some(0))) - } - } - Operator::GreaterThanEqual => { - let version = specifier.version().clone(); - Ranges::higher_than(version) - } - Operator::EqualStar => { - let low = specifier.version().clone().with_dev(Some(0)); - let mut high = low.clone(); - if let Some(post) = high.post() { - high = high.with_post(Some(post + 1)); - } else if let Some(pre) = high.pre() { - high = high.with_pre(Some(Prerelease { - kind: pre.kind, - number: pre.number + 1, - })); - } else { - let mut release = high.release().to_vec(); - *release.last_mut().unwrap() += 1; - high = high.with_release(release); - } - Ranges::from_range_bounds(low..high) - } - Operator::NotEqualStar => { - let low = specifier.version().clone().with_dev(Some(0)); - let mut high = low.clone(); - if let Some(post) = high.post() { - high = high.with_post(Some(post + 1)); - } else if let Some(pre) = high.pre() { - high = high.with_pre(Some(Prerelease { - kind: pre.kind, - number: pre.number + 1, - })); - } else { - let mut release = high.release().to_vec(); - *release.last_mut().unwrap() += 1; - high = high.with_release(release); - } - Ranges::from_range_bounds(low..high).complement() - } - }; - - Ok(Self(ranges)) - } - - /// Convert the [`VersionSpecifiers`] to a PubGrub-compatible version range, using release-only - /// semantics. - /// - /// Assumes that the range will only be tested against versions that consist solely of release - /// segments (e.g., `3.12.0`, but not `3.12.0b1`). - /// - /// These semantics are used for testing Python compatibility (e.g., `requires-python` against - /// the user's installed Python version). In that context, it's more intuitive that `3.13.0b0` - /// is allowed for projects that declare `requires-python = ">3.13"`. - /// - /// See: - pub fn from_release_specifiers( - specifiers: &VersionSpecifiers, - ) -> Result { - let mut range = Ranges::full(); - for specifier in specifiers.iter() { - range = range.intersection(&Self::from_release_specifier(specifier)?.into()); - } - Ok(Self(range)) - } - - /// Convert the [`VersionSpecifier`] to a PubGrub-compatible version range, using release-only - /// semantics. - /// - /// Assumes that the range will only be tested against versions that consist solely of release - /// segments (e.g., `3.12.0`, but not `3.12.0b1`). - /// - /// These semantics are used for testing Python compatibility (e.g., `requires-python` against - /// the user's installed Python version). In that context, it's more intuitive that `3.13.0b0` - /// is allowed for projects that declare `requires-python = ">3.13"`. - /// - /// See: - pub fn from_release_specifier( - specifier: &VersionSpecifier, - ) -> Result { - let ranges = match specifier.operator() { - Operator::Equal => { - let version = specifier.version().only_release(); - Ranges::singleton(version) - } - Operator::ExactEqual => { - let version = specifier.version().only_release(); - Ranges::singleton(version) - } - Operator::NotEqual => { - let version = specifier.version().only_release(); - Ranges::singleton(version).complement() - } - Operator::TildeEqual => { - let [rest @ .., last, _] = specifier.version().release() else { - return Err(VersionRangesSpecifierError::InvalidTildeEquals( - specifier.clone(), - )); - }; - let upper = Version::new(rest.iter().chain([&(last + 1)])); - let version = specifier.version().only_release(); - Ranges::from_range_bounds(version..upper) - } - Operator::LessThan => { - let version = specifier.version().only_release(); - Ranges::strictly_lower_than(version) - } - Operator::LessThanEqual => { - let version = specifier.version().only_release(); - Ranges::lower_than(version) - } - Operator::GreaterThan => { - let version = specifier.version().only_release(); - Ranges::strictly_higher_than(version) - } - Operator::GreaterThanEqual => { - let version = specifier.version().only_release(); - Ranges::higher_than(version) - } - Operator::EqualStar => { - let low = specifier.version().only_release(); - let high = { - let mut high = low.clone(); - let mut release = high.release().to_vec(); - *release.last_mut().unwrap() += 1; - high = high.with_release(release); - high - }; - Ranges::from_range_bounds(low..high) - } - Operator::NotEqualStar => { - let low = specifier.version().only_release(); - let high = { - let mut high = low.clone(); - let mut release = high.release().to_vec(); - *release.last_mut().unwrap() += 1; - high = high.with_release(release); - high - }; - Ranges::from_range_bounds(low..high).complement() - } - }; - Ok(Self(ranges)) - } -} diff --git a/crates/uv-pep440/src/version_specifier.rs b/crates/uv-pep440/src/version_specifier.rs index 972e5faed..4a62541e2 100644 --- a/crates/uv-pep440/src/version_specifier.rs +++ b/crates/uv-pep440/src/version_specifier.rs @@ -112,6 +112,15 @@ impl FromIterator for VersionSpecifiers { } } +impl IntoIterator for VersionSpecifiers { + type Item = VersionSpecifier; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + impl FromStr for VersionSpecifiers { type Err = VersionSpecifiersParseError; diff --git a/crates/uv-pep508/src/marker/algebra.rs b/crates/uv-pep508/src/marker/algebra.rs index a8f8f737a..a8a1b047b 100644 --- a/crates/uv-pep508/src/marker/algebra.rs +++ b/crates/uv-pep508/src/marker/algebra.rs @@ -53,8 +53,7 @@ use std::sync::MutexGuard; use itertools::Either; use rustc_hash::FxHashMap; use std::sync::LazyLock; -use uv_pep440::{Operator, VersionRangesSpecifier}; -use uv_pep440::{Version, VersionSpecifier}; +use uv_pep440::{release_specifier_to_range, Operator, Version, VersionSpecifier}; use version_ranges::Ranges; use crate::marker::MarkerValueExtra; @@ -744,11 +743,9 @@ impl Edges { /// Returns the [`Edges`] for a version specifier. fn from_specifier(specifier: VersionSpecifier) -> Edges { - let specifier = - VersionRangesSpecifier::from_release_specifier(&normalize_specifier(specifier)) - .unwrap(); + let specifier = release_specifier_to_range(normalize_specifier(specifier)); Edges::Version { - edges: Edges::from_range(&specifier.into()), + edges: Edges::from_range(&specifier), } } @@ -763,10 +760,8 @@ impl Edges { for version in versions { let specifier = VersionSpecifier::equals_version(version.clone()); let specifier = python_version_to_full_version(specifier)?; - let pubgrub_specifier = - VersionRangesSpecifier::from_release_specifier(&normalize_specifier(specifier)) - .unwrap(); - range = range.union(&pubgrub_specifier.into()); + let pubgrub_specifier = release_specifier_to_range(normalize_specifier(specifier)); + range = range.union(&pubgrub_specifier); } if negated { diff --git a/crates/uv-resolver/src/error.rs b/crates/uv-resolver/src/error.rs index 3b4301cd8..1547f9d92 100644 --- a/crates/uv-resolver/src/error.rs +++ b/crates/uv-resolver/src/error.rs @@ -19,7 +19,7 @@ use uv_distribution_types::{ BuiltDist, IndexCapabilities, IndexLocations, IndexUrl, InstalledDist, SourceDist, }; use uv_normalize::PackageName; -use uv_pep440::{Version, VersionRangesSpecifierError}; +use uv_pep440::Version; use uv_pep508::MarkerTree; use uv_static::EnvVars; @@ -37,9 +37,6 @@ pub enum ResolveError { #[error("Attempted to wait on an unregistered task: `{_0}`")] UnregisteredTask(String), - #[error(transparent)] - VersionRangesSpecifier(#[from] VersionRangesSpecifierError), - #[error("Overrides contain conflicting URLs for package `{0}`:\n- {1}\n- {2}")] ConflictingOverrideUrls(PackageName, String, String), diff --git a/crates/uv-resolver/src/lib.rs b/crates/uv-resolver/src/lib.rs index 008cbdd79..38b4ec140 100644 --- a/crates/uv-resolver/src/lib.rs +++ b/crates/uv-resolver/src/lib.rs @@ -12,7 +12,7 @@ pub use options::{Flexibility, Options, OptionsBuilder}; pub use preferences::{Preference, PreferenceError, Preferences}; pub use prerelease::PrereleaseMode; pub use python_requirement::PythonRequirement; -pub use requires_python::{RequiresPython, RequiresPythonError, RequiresPythonRange}; +pub use requires_python::{RequiresPython, RequiresPythonRange}; pub use resolution::{ AnnotationStyle, ConflictingDistributionError, DisplayResolutionGraph, ResolutionGraph, }; diff --git a/crates/uv-resolver/src/pubgrub/dependencies.rs b/crates/uv-resolver/src/pubgrub/dependencies.rs index 788e4d939..ee9ac491e 100644 --- a/crates/uv-resolver/src/pubgrub/dependencies.rs +++ b/crates/uv-resolver/src/pubgrub/dependencies.rs @@ -1,23 +1,21 @@ use std::iter; -use itertools::Itertools; -use pubgrub::Range; +use pubgrub::Ranges; use tracing::warn; use uv_normalize::{ExtraName, PackageName}; -use uv_pep440::{Version, VersionRangesSpecifier, VersionSpecifiers}; +use uv_pep440::{Version, VersionSpecifiers}; use uv_pypi_types::{ ParsedArchiveUrl, ParsedDirectoryUrl, ParsedGitUrl, ParsedPathUrl, ParsedUrl, Requirement, RequirementSource, VerbatimParsedUrl, }; use crate::pubgrub::{PubGrubPackage, PubGrubPackageInner}; -use crate::ResolveError; #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct PubGrubDependency { pub(crate) package: PubGrubPackage, - pub(crate) version: Range, + pub(crate) version: Ranges, /// The original version specifiers from the requirement. pub(crate) specifier: Option, @@ -32,12 +30,12 @@ impl PubGrubDependency { pub(crate) fn from_requirement<'a>( requirement: &'a Requirement, source_name: Option<&'a PackageName>, - ) -> impl Iterator> + 'a { + ) -> impl Iterator + 'a { // Add the package, plus any extra variants. iter::once(None) .chain(requirement.extras.clone().into_iter().map(Some)) .map(|extra| PubGrubRequirement::from_requirement(requirement, extra)) - .filter_map_ok(move |requirement| { + .filter_map(move |requirement| { let PubGrubRequirement { package, version, @@ -87,7 +85,7 @@ impl PubGrubDependency { #[derive(Debug, Clone)] pub(crate) struct PubGrubRequirement { pub(crate) package: PubGrubPackage, - pub(crate) version: Range, + pub(crate) version: Ranges, pub(crate) specifier: Option, pub(crate) url: Option, } @@ -95,10 +93,7 @@ pub(crate) struct PubGrubRequirement { impl PubGrubRequirement { /// Convert a [`Requirement`] to a PubGrub-compatible package and range, while returning the URL /// on the [`Requirement`], if any. - pub(crate) fn from_requirement( - requirement: &Requirement, - extra: Option, - ) -> Result { + pub(crate) fn from_requirement(requirement: &Requirement, extra: Option) -> Self { let (verbatim_url, parsed_url) = match &requirement.source { RequirementSource::Registry { specifier, .. } => { return Self::from_registry_requirement(specifier, extra, requirement); @@ -159,29 +154,27 @@ impl PubGrubRequirement { } }; - Ok(Self { + Self { package: PubGrubPackage::from_package( requirement.name.clone(), extra, requirement.marker.clone(), ), - version: Range::full(), + version: Ranges::full(), specifier: None, url: Some(VerbatimParsedUrl { parsed_url, verbatim: verbatim_url.clone(), }), - }) + } } fn from_registry_requirement( specifier: &VersionSpecifiers, extra: Option, requirement: &Requirement, - ) -> Result { - let version = VersionRangesSpecifier::from_pep440_specifiers(specifier)?.into(); - - let requirement = Self { + ) -> PubGrubRequirement { + Self { package: PubGrubPackage::from_package( requirement.name.clone(), extra, @@ -189,9 +182,7 @@ impl PubGrubRequirement { ), specifier: Some(specifier.clone()), url: None, - version, - }; - - Ok(requirement) + version: Ranges::from(specifier.clone()), + } } } diff --git a/crates/uv-resolver/src/requires_python.rs b/crates/uv-resolver/src/requires_python.rs index 271c61944..56e51cb53 100644 --- a/crates/uv-resolver/src/requires_python.rs +++ b/crates/uv-resolver/src/requires_python.rs @@ -1,22 +1,12 @@ -use itertools::Itertools; use pubgrub::Range; use std::cmp::Ordering; use std::collections::Bound; use std::ops::Deref; use uv_distribution_filename::WheelFilename; -use uv_pep440::{ - Version, VersionRangesSpecifier, VersionRangesSpecifierError, VersionSpecifier, - VersionSpecifiers, -}; +use uv_pep440::{release_specifiers_to_ranges, Version, VersionSpecifier, VersionSpecifiers}; use uv_pep508::{MarkerExpression, MarkerTree, MarkerValueVersion}; -#[derive(thiserror::Error, Debug)] -pub enum RequiresPythonError { - #[error(transparent)] - VersionRangesSpecifier(#[from] VersionRangesSpecifierError), -} - /// The `Requires-Python` requirement specifier. /// /// See: @@ -54,16 +44,15 @@ impl RequiresPython { } /// Returns a [`RequiresPython`] from a version specifier. - pub fn from_specifiers(specifiers: &VersionSpecifiers) -> Result { - let (lower_bound, upper_bound) = - VersionRangesSpecifier::from_release_specifiers(specifiers)? - .bounding_range() - .map(|(lower_bound, upper_bound)| (lower_bound.cloned(), upper_bound.cloned())) - .unwrap_or((Bound::Unbounded, Bound::Unbounded)); - Ok(Self { + pub fn from_specifiers(specifiers: &VersionSpecifiers) -> Self { + let (lower_bound, upper_bound) = release_specifiers_to_ranges(specifiers.clone()) + .bounding_range() + .map(|(lower_bound, upper_bound)| (lower_bound.cloned(), upper_bound.cloned())) + .unwrap_or((Bound::Unbounded, Bound::Unbounded)); + Self { specifiers: specifiers.clone(), range: RequiresPythonRange(LowerBound(lower_bound), UpperBound(upper_bound)), - }) + } } /// Returns a [`RequiresPython`] to express the intersection of the given version specifiers. @@ -71,23 +60,19 @@ impl RequiresPython { /// For example, given `>=3.8` and `>=3.9`, this would return `>=3.9`. pub fn intersection<'a>( specifiers: impl Iterator, - ) -> Result, RequiresPythonError> { + ) -> Option { // Convert to PubGrub range and perform an intersection. let range = specifiers .into_iter() - .map(VersionRangesSpecifier::from_release_specifiers) - .fold_ok(None, |range: Option>, requires_python| { + .map(|specifier| release_specifiers_to_ranges(specifier.clone())) + .fold(None, |range: Option>, requires_python| { if let Some(range) = range { - Some(range.intersection(&requires_python.into())) + Some(range.intersection(&requires_python)) } else { - Some(requires_python.into()) + Some(requires_python) } })?; - let Some(range) = range else { - return Ok(None); - }; - // Extract the bounds. let (lower_bound, upper_bound) = range .bounding_range() @@ -102,10 +87,10 @@ impl RequiresPython { // Convert back to PEP 440 specifiers. let specifiers = VersionSpecifiers::from_release_only_bounds(range.iter()); - Ok(Some(Self { + Some(Self { specifiers, range: RequiresPythonRange(lower_bound, upper_bound), - })) + }) } /// Narrow the [`RequiresPython`] by computing the intersection with the given range. @@ -260,14 +245,10 @@ impl RequiresPython { /// N.B. This operation should primarily be used when evaluating the compatibility of a /// project's `Requires-Python` specifier against a dependency's `Requires-Python` specifier. pub fn is_contained_by(&self, target: &VersionSpecifiers) -> bool { - let Ok(target) = VersionRangesSpecifier::from_release_specifiers(target) else { - return false; - }; - let target = target - .iter() - .next() - .map(|(lower, _)| lower) - .unwrap_or(&Bound::Unbounded); + let target = release_specifiers_to_ranges(target.clone()) + .bounding_range() + .map(|bounding_range| bounding_range.0.cloned()) + .unwrap_or(Bound::Unbounded); // We want, e.g., `self.range.lower()` to be `>=3.8` and `target` to be `>=3.7`. // @@ -508,12 +489,10 @@ impl serde::Serialize for RequiresPython { impl<'de> serde::Deserialize<'de> for RequiresPython { fn deserialize>(deserializer: D) -> Result { let specifiers = VersionSpecifiers::deserialize(deserializer)?; - let (lower_bound, upper_bound) = - VersionRangesSpecifier::from_release_specifiers(&specifiers) - .map_err(serde::de::Error::custom)? - .bounding_range() - .map(|(lower_bound, upper_bound)| (lower_bound.cloned(), upper_bound.cloned())) - .unwrap_or((Bound::Unbounded, Bound::Unbounded)); + let (lower_bound, upper_bound) = release_specifiers_to_ranges(specifiers.clone()) + .bounding_range() + .map(|(lower_bound, upper_bound)| (lower_bound.cloned(), upper_bound.cloned())) + .unwrap_or((Bound::Unbounded, Bound::Unbounded)); Ok(Self { specifiers, range: RequiresPythonRange(LowerBound(lower_bound), UpperBound(upper_bound)), diff --git a/crates/uv-resolver/src/requires_python/tests.rs b/crates/uv-resolver/src/requires_python/tests.rs index 660abd71b..dc8e97564 100644 --- a/crates/uv-resolver/src/requires_python/tests.rs +++ b/crates/uv-resolver/src/requires_python/tests.rs @@ -11,7 +11,7 @@ use crate::RequiresPython; #[test] fn requires_python_included() { let version_specifiers = VersionSpecifiers::from_str("==3.10.*").unwrap(); - let requires_python = RequiresPython::from_specifiers(&version_specifiers).unwrap(); + let requires_python = RequiresPython::from_specifiers(&version_specifiers); let wheel_names = &[ "bcrypt-4.1.3-cp37-abi3-macosx_10_12_universal2.whl", "black-24.4.2-cp310-cp310-win_amd64.whl", @@ -30,7 +30,7 @@ fn requires_python_included() { } let version_specifiers = VersionSpecifiers::from_str(">=3.12.3").unwrap(); - let requires_python = RequiresPython::from_specifiers(&version_specifiers).unwrap(); + let requires_python = RequiresPython::from_specifiers(&version_specifiers); let wheel_names = &["dearpygui-1.11.1-cp312-cp312-win_amd64.whl"]; for wheel_name in wheel_names { assert!( @@ -40,7 +40,7 @@ fn requires_python_included() { } let version_specifiers = VersionSpecifiers::from_str("==3.12.6").unwrap(); - let requires_python = RequiresPython::from_specifiers(&version_specifiers).unwrap(); + let requires_python = RequiresPython::from_specifiers(&version_specifiers); let wheel_names = &["lxml-5.3.0-cp312-cp312-musllinux_1_2_aarch64.whl"]; for wheel_name in wheel_names { assert!( @@ -50,7 +50,7 @@ fn requires_python_included() { } let version_specifiers = VersionSpecifiers::from_str("==3.12").unwrap(); - let requires_python = RequiresPython::from_specifiers(&version_specifiers).unwrap(); + let requires_python = RequiresPython::from_specifiers(&version_specifiers); let wheel_names = &["lxml-5.3.0-cp312-cp312-musllinux_1_2_x86_64.whl"]; for wheel_name in wheel_names { assert!( @@ -63,7 +63,7 @@ fn requires_python_included() { #[test] fn requires_python_dropped() { let version_specifiers = VersionSpecifiers::from_str("==3.10.*").unwrap(); - let requires_python = RequiresPython::from_specifiers(&version_specifiers).unwrap(); + let requires_python = RequiresPython::from_specifiers(&version_specifiers); let wheel_names = &[ "PySocks-1.7.1-py27-none-any.whl", "black-24.4.2-cp39-cp39-win_amd64.whl", @@ -83,7 +83,7 @@ fn requires_python_dropped() { } let version_specifiers = VersionSpecifiers::from_str(">=3.12.3").unwrap(); - let requires_python = RequiresPython::from_specifiers(&version_specifiers).unwrap(); + let requires_python = RequiresPython::from_specifiers(&version_specifiers); let wheel_names = &["dearpygui-1.11.1-cp310-cp310-win_amd64.whl"]; for wheel_name in wheel_names { assert!( @@ -152,7 +152,7 @@ fn is_exact_without_patch() { ]; for (version, expected) in test_cases { let version_specifiers = VersionSpecifiers::from_str(version).unwrap(); - let requires_python = RequiresPython::from_specifiers(&version_specifiers).unwrap(); + let requires_python = RequiresPython::from_specifiers(&version_specifiers); assert_eq!(requires_python.is_exact_without_patch(), expected); } } diff --git a/crates/uv-resolver/src/resolver/mod.rs b/crates/uv-resolver/src/resolver/mod.rs index 077d4934d..e8433ea86 100644 --- a/crates/uv-resolver/src/resolver/mod.rs +++ b/crates/uv-resolver/src/resolver/mod.rs @@ -13,7 +13,7 @@ use dashmap::DashMap; use either::Either; use futures::{FutureExt, StreamExt}; use itertools::Itertools; -use pubgrub::{Incompatibility, Range, State}; +use pubgrub::{Incompatibility, Range, Ranges, State}; use rustc_hash::{FxHashMap, FxHashSet}; use tokio::sync::mpsc::{self, Receiver, Sender}; use tokio::sync::oneshot; @@ -34,7 +34,7 @@ use uv_distribution_types::{ }; use uv_git::GitResolver; use uv_normalize::{ExtraName, GroupName, PackageName}; -use uv_pep440::{Version, VersionRangesSpecifier, MIN_VERSION}; +use uv_pep440::{release_specifiers_to_ranges, Version, MIN_VERSION}; use uv_pep508::MarkerTree; use uv_platform_tags::Tags; use uv_pypi_types::{Requirement, ResolutionMetadata, VerbatimParsedUrl}; @@ -465,7 +465,7 @@ impl ResolverState version, ResolverVersion::Unavailable(version, reason) => { - state.add_unavailable_version(version, reason)?; + state.add_unavailable_version(version, reason); continue; } }; @@ -1241,7 +1241,7 @@ impl ResolverState, _>>()? + .collect() } PubGrubPackageInner::Package { name, @@ -1349,12 +1349,12 @@ impl ResolverState = requirements .iter() .flat_map(|requirement| { PubGrubDependency::from_requirement(requirement, Some(name)) }) - .collect::, _>>()?; + .collect(); // If a package has metadata for an enabled dependency group, // add a dependency from it to the same package with the group @@ -1610,10 +1610,9 @@ impl ResolverState ResolverState Result<(), ResolveError> { + fn add_unavailable_version(&mut self, version: Version, reason: UnavailableVersion) { // Incompatible requires-python versions are special in that we track // them as incompatible dependencies instead of marking the package version // as unavailable directly. @@ -2301,9 +2292,6 @@ impl ForkState { | IncompatibleDist::Wheel(IncompatibleWheel::RequiresPython(requires_python, kind)), ) = reason { - let python_version: Range = - VersionRangesSpecifier::from_release_specifiers(&requires_python)?.into(); - let package = &self.next; self.pubgrub .add_incompatibility(Incompatibility::from_dependency( @@ -2314,13 +2302,13 @@ impl ForkState { PythonRequirementKind::Installed => PubGrubPython::Installed, PythonRequirementKind::Target => PubGrubPython::Target, })), - python_version.clone(), + release_specifiers_to_ranges(requires_python), ), )); self.pubgrub .partial_solution .add_decision(self.next.clone(), version); - return Ok(()); + return; }; self.pubgrub .add_incompatibility(Incompatibility::custom_version( @@ -2328,7 +2316,6 @@ impl ForkState { version.clone(), UnavailableReason::Version(reason), )); - Ok(()) } /// Subset the current markers with the new markers and update the python requirements fields diff --git a/crates/uv/src/commands/build_frontend.rs b/crates/uv/src/commands/build_frontend.rs index 458cf3cce..51d11b140 100644 --- a/crates/uv/src/commands/build_frontend.rs +++ b/crates/uv/src/commands/build_frontend.rs @@ -396,7 +396,7 @@ async fn build_package( // (3) `Requires-Python` in `pyproject.toml` if interpreter_request.is_none() { if let Ok(workspace) = workspace { - interpreter_request = find_requires_python(workspace)? + interpreter_request = find_requires_python(workspace) .as_ref() .map(RequiresPython::specifiers) .map(|specifiers| { diff --git a/crates/uv/src/commands/pip/operations.rs b/crates/uv/src/commands/pip/operations.rs index 1eef37c1a..ba257b4a6 100644 --- a/crates/uv/src/commands/pip/operations.rs +++ b/crates/uv/src/commands/pip/operations.rs @@ -771,7 +771,4 @@ pub(crate) enum Error { #[error(transparent)] Anyhow(#[from] anyhow::Error), - - #[error(transparent)] - VersionRangesSpecifier(#[from] uv_pep440::VersionRangesSpecifierError), } diff --git a/crates/uv/src/commands/project/init.rs b/crates/uv/src/commands/project/init.rs index 4b72ce5c1..f0144b070 100644 --- a/crates/uv/src/commands/project/init.rs +++ b/crates/uv/src/commands/project/init.rs @@ -362,7 +362,7 @@ async fn init_project( } ref python_request @ PythonRequest::Version(VersionRequest::Range(ref specifiers, _)) => { - let requires_python = RequiresPython::from_specifiers(specifiers)?; + let requires_python = RequiresPython::from_specifiers(specifiers); let python_request = if no_pin_python { None @@ -417,10 +417,7 @@ async fn init_project( (requires_python, python_request) } } - } else if let Some(requires_python) = workspace - .as_ref() - .and_then(|workspace| find_requires_python(workspace).ok().flatten()) - { + } else if let Some(requires_python) = workspace.as_ref().and_then(find_requires_python) { // (2) `Requires-Python` from the workspace let python_request = PythonRequest::Version(VersionRequest::Range( requires_python.specifiers().clone(), diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 597b857f2..f73ddfd92 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -390,7 +390,7 @@ async fn do_lock( // Determine the supported Python range. If no range is defined, and warn and default to the // current minor version. - let requires_python = find_requires_python(workspace)?; + let requires_python = find_requires_python(workspace); let requires_python = if let Some(requires_python) = requires_python { if requires_python.is_unbounded() { diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index b928f1c8a..02854f820 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -176,9 +176,6 @@ pub(crate) enum ProjectError { #[error(transparent)] Operation(#[from] pip::operations::Error), - #[error(transparent)] - RequiresPython(#[from] uv_resolver::RequiresPythonError), - #[error(transparent)] Interpreter(#[from] uv_python::InterpreterError), @@ -208,9 +205,7 @@ pub(crate) enum ProjectError { /// /// For a [`Workspace`] with multiple packages, the `Requires-Python` bound is the union of the /// `Requires-Python` bounds of all the packages. -pub(crate) fn find_requires_python( - workspace: &Workspace, -) -> Result, uv_resolver::RequiresPythonError> { +pub(crate) fn find_requires_python(workspace: &Workspace) -> Option { RequiresPython::intersection(workspace.packages().values().filter_map(|member| { member .pyproject_toml() @@ -341,7 +336,7 @@ impl WorkspacePython { python_request: Option, workspace: &Workspace, ) -> Result { - let requires_python = find_requires_python(workspace)?; + let requires_python = find_requires_python(workspace); let (source, python_request) = if let Some(request) = python_request { // (1) Explicit request from user diff --git a/crates/uv/src/commands/python/find.rs b/crates/uv/src/commands/python/find.rs index 2b3da29ef..06d43298e 100644 --- a/crates/uv/src/commands/python/find.rs +++ b/crates/uv/src/commands/python/find.rs @@ -55,7 +55,7 @@ pub(crate) async fn find( }; if let Some(project) = project { - request = find_requires_python(project.workspace())? + request = find_requires_python(project.workspace()) .as_ref() .map(RequiresPython::specifiers) .map(|specifiers| { diff --git a/crates/uv/src/commands/python/pin.rs b/crates/uv/src/commands/python/pin.rs index 653142c75..f588e624a 100644 --- a/crates/uv/src/commands/python/pin.rs +++ b/crates/uv/src/commands/python/pin.rs @@ -253,7 +253,7 @@ fn assert_pin_compatible_with_project(pin: &Pin, virtual_project: &VirtualProjec project_workspace.project_name(), project_workspace.workspace().install_path().display() ); - let requires_python = find_requires_python(project_workspace.workspace())?; + let requires_python = find_requires_python(project_workspace.workspace()); (requires_python, "project") } VirtualProject::NonProject(workspace) => { @@ -261,7 +261,7 @@ fn assert_pin_compatible_with_project(pin: &Pin, virtual_project: &VirtualProjec "Discovered virtual workspace at: {}", workspace.install_path().display() ); - let requires_python = find_requires_python(workspace)?; + let requires_python = find_requires_python(workspace); (requires_python, "workspace") } }; diff --git a/crates/uv/src/commands/venv.rs b/crates/uv/src/commands/venv.rs index 0900eacba..b1b85ee50 100644 --- a/crates/uv/src/commands/venv.rs +++ b/crates/uv/src/commands/venv.rs @@ -198,7 +198,6 @@ async fn venv_impl( if interpreter_request.is_none() { if let Some(project) = project { interpreter_request = find_requires_python(project.workspace()) - .into_diagnostic()? .as_ref() .map(RequiresPython::specifiers) .map(|specifiers| {