Skip to content

Commit

Permalink
Add optional conversion to version-ranges
Browse files Browse the repository at this point in the history
  • Loading branch information
konstin committed Oct 30, 2024
1 parent c2268ac commit 85dc599
Show file tree
Hide file tree
Showing 7 changed files with 282 additions and 5 deletions.
18 changes: 17 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "pep440_rs"
version = "0.7.1"
version = "0.7.2"
description = "A library for python version numbers and specifiers, implementing PEP 440"
edition = "2021"
include = ["/src", "Changelog.md", "License-Apache", "License-BSD", "Readme.md", "pyproject.toml"]
Expand All @@ -19,6 +19,8 @@ rkyv = { version = "0.8.8", optional = true }
tracing = { version = "0.1.40", optional = true }
unicode-width = { version = "0.2.0" }
unscanny = { version = "0.1.0" }
# Adds conversions from [`VersionSpecifiers`] to [`version_ranges::Ranges`]
version-ranges = { version = "0.1.0", optional = true }

[dev-dependencies]
indoc = { version = "2.0.5" }
6 changes: 5 additions & 1 deletion Changelog.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
# 0.7.0
# 0.7.2

* Add optional conversion to [version-ranges](https://crates.io/crates/version-ranges).

# 0.7.1

* Make rkyv optional

Expand Down
7 changes: 7 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
//! the version matching needs to catch all sorts of special cases
#![warn(missing_docs)]

#[cfg(feature = "version-ranges")]
pub use version_ranges::{release_specifier_to_range, release_specifiers_to_ranges};
pub use {
version::{
LocalSegment, Operator, OperatorParseError, Prerelease, PrereleaseKind, Version,
Expand All @@ -47,3 +49,8 @@ pub use {

mod version;
mod version_specifier;

#[cfg(test)]
mod tests;
#[cfg(feature = "version-ranges")]
mod version_ranges;
5 changes: 5 additions & 0 deletions src/version.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ pub enum Operator {
/// `!= 1.2.*`
NotEqualStar,
/// `~=`
///
/// Invariant: With `~=`, there are always at least 2 release segments.
TildeEqual,
/// `<`
LessThan,
Expand Down Expand Up @@ -1247,6 +1249,9 @@ struct VersionFull {
/// > label”, separated from the public version identifier by a plus.
/// > Local version labels have no specific semantics assigned, but
/// > some syntactic restrictions are imposed.
///
/// Local versions allow multiple segments separated by periods, such as `deadbeef.1.2.3`, see
/// [`LocalSegment`] for details on the semantics.
local: Vec<LocalSegment>,
/// An internal-only segment that does not exist in PEP 440, used to
/// represent the smallest possible version of a release, preceding any
Expand Down
192 changes: 192 additions & 0 deletions src/version_ranges.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
//! Convert [`VersionSpecifiers`] to [`version_ranges::Ranges`].
use version_ranges::Ranges;

use crate::{Operator, Prerelease, Version, VersionSpecifier, VersionSpecifiers};

impl From<VersionSpecifiers> for Ranges<Version> {
/// 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<VersionSpecifier> for Ranges<Version> {
/// 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 <V MUST NOT allow a
// pre-release of the specified version unless the specified version is itself a
// pre-release."
Ranges::strictly_lower_than(version.with_min(Some(0)))
}
}
Operator::LessThanEqual => 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: <https://github.com/pypa/pip/blob/a432c7f4170b9ef798a15f035f5dfdb4cc939f35/src/pip/_internal/resolution/resolvelib/candidates.py#L540>
pub fn release_specifiers_to_ranges(specifiers: VersionSpecifiers) -> Ranges<Version> {
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: <https://github.com/pypa/pip/blob/a432c7f4170b9ef798a15f035f5dfdb4cc939f35/src/pip/_internal/resolution/resolvelib/candidates.py#L540>
pub fn release_specifier_to_range(specifier: VersionSpecifier) -> Ranges<Version> {
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()
}
}
}
55 changes: 53 additions & 2 deletions src/version_specifier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ use std::cmp::Ordering;
use std::ops::Bound;
use std::str::FromStr;

use serde::{de, Deserialize, Deserializer, Serialize, Serializer};

use crate::{
version, Operator, OperatorParseError, Version, VersionPattern, VersionPatternParseError,
};
use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
#[cfg(feature = "tracing")]
use tracing::warn;

/// Sorted version specifiers, such as `>=2.1,<3`.
///
Expand Down Expand Up @@ -62,6 +63,47 @@ impl VersionSpecifiers {
specifiers.sort_by(|a, b| a.version().cmp(b.version()));
Self(specifiers)
}

/// Returns the [`VersionSpecifiers`] whose union represents the given range.
///
/// This function is not applicable to ranges involving pre-release versions.
pub fn from_release_only_bounds<'a>(
mut bounds: impl Iterator<Item = (&'a Bound<Version>, &'a Bound<Version>)>,
) -> Self {
let mut specifiers = Vec::new();

let Some((start, mut next)) = bounds.next() else {
return Self::empty();
};

// Add specifiers for the holes between the bounds.
for (lower, upper) in bounds {
match (next, lower) {
// Ex) [3.7, 3.8.5), (3.8.5, 3.9] -> >=3.7,!=3.8.5,<=3.9
(Bound::Excluded(prev), Bound::Excluded(lower)) if prev == lower => {
specifiers.push(VersionSpecifier::not_equals_version(prev.clone()));
}
// Ex) [3.7, 3.8), (3.8, 3.9] -> >=3.7,!=3.8.*,<=3.9
(Bound::Excluded(prev), Bound::Included(lower))
if prev.release().len() == 2
&& lower.release() == [prev.release()[0], prev.release()[1] + 1] =>
{
specifiers.push(VersionSpecifier::not_equals_star_version(prev.clone()));
}
_ => {
#[cfg(feature = "tracing")]
warn!("Ignoring unsupported gap in `requires-python` version: {next:?} -> {lower:?}");
}
}
next = upper;
}
let end = next;

// Add the specifiers for the bounding range.
specifiers.extend(VersionSpecifier::from_release_only_bounds((start, end)));

Self::from_unsorted(specifiers)
}
}

impl FromIterator<VersionSpecifier> for VersionSpecifiers {
Expand All @@ -70,6 +112,15 @@ impl FromIterator<VersionSpecifier> for VersionSpecifiers {
}
}

impl IntoIterator for VersionSpecifiers {
type Item = VersionSpecifier;
type IntoIter = std::vec::IntoIter<VersionSpecifier>;

fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}

impl FromStr for VersionSpecifiers {
type Err = VersionSpecifiersParseError;

Expand Down

0 comments on commit 85dc599

Please sign in to comment.