mirror of
https://github.com/astral-sh/uv.git
synced 2025-07-07 13:25:00 +00:00
Fix equals-star and tilde-equals with python_version
and python_full_version
(#14271)
The marker display code assumes that all versions are normalized, in that all trailing zeroes are stripped. This is not the case for tilde-equals and equals-star versions, where the trailing zeroes (before the `.*`) are semantically relevant. This would cause path dependent-behavior where we would get a different marker string depending on whether a version with or without a trailing zero was added to the cache first. To handle both equals-star and tilde-equals when converting `python_version` to `python_full_version` markers, we have to merge the version normalization (i.e. trimming the trailing zeroes) and the conversion both to `python_full_version` and to `Ranges`, while special casing equals-star and tilde-equals. To avoid churn in lockfiles, we only trim in the conversion to `Ranges` for markers, but keep using untrimmed versions for requires-python. (Note that this behavior is technically also path dependent, as versions with and without trailing zeroes have the same Hash and Eq. E.q., `requires-python == ">= 3.10.0"` and `requires-python == ">= 3.10"` in the same workspace could lead to either value in `uv.lock`, and which one it is could change if we make unrelated (performance) changes. Always trimming however definitely changes lockfiles, a churn I wouldn't do outside another breaking or lockfile-changing change.) Nevertheless, there is a change for users who have `requires-python = "~= 3.12.0"` in their `pyproject.toml`, as this now hits the correct normalization path. Fixes #14231 Fixes #14270
This commit is contained in:
parent
3774a656d7
commit
43745d2ecf
13 changed files with 382 additions and 163 deletions
|
@ -71,7 +71,7 @@ impl RequiresPython {
|
|||
// Warn if there’s exactly one `~=` specifier without a patch.
|
||||
if let [spec] = &specs[..] {
|
||||
if spec.is_tilde_without_patch() {
|
||||
if let Some((lo_b, hi_b)) = release_specifier_to_range(spec.clone())
|
||||
if let Some((lo_b, hi_b)) = release_specifier_to_range(spec.clone(), false)
|
||||
.bounding_range()
|
||||
.map(|(l, u)| (l.cloned(), u.cloned()))
|
||||
{
|
||||
|
@ -80,8 +80,8 @@ impl RequiresPython {
|
|||
warn_user_once!(
|
||||
"The release specifier (`{spec}`) contains a compatible release \
|
||||
match without a patch version. This will be interpreted as \
|
||||
`{lo_spec}, {hi_spec}`. Did you mean `{spec}.0` to freeze the minor \
|
||||
version?"
|
||||
`{lo_spec}, {hi_spec}`. Did you mean `{spec}.0` to freeze the \
|
||||
minor version?"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -610,6 +610,24 @@ impl Version {
|
|||
Self::new(self.release().iter().copied())
|
||||
}
|
||||
|
||||
/// Return the version with any segments apart from the release removed, with trailing zeroes
|
||||
/// trimmed.
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn only_release_trimmed(&self) -> Self {
|
||||
if let Some(last_non_zero) = self.release().iter().rposition(|segment| *segment != 0) {
|
||||
if last_non_zero == self.release().len() {
|
||||
// Already trimmed.
|
||||
self.clone()
|
||||
} else {
|
||||
Self::new(self.release().iter().take(last_non_zero + 1).copied())
|
||||
}
|
||||
} else {
|
||||
// `0` is a valid version.
|
||||
Self::new([0])
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the version with trailing `.0` release segments removed.
|
||||
///
|
||||
/// # Panics
|
||||
|
|
|
@ -130,10 +130,11 @@ impl From<VersionSpecifier> for Ranges<Version> {
|
|||
///
|
||||
/// 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> {
|
||||
specifiers
|
||||
.into_iter()
|
||||
.map(release_specifier_to_range)
|
||||
.fold(Ranges::full(), |acc, range| acc.intersection(&range))
|
||||
let mut range = Ranges::full();
|
||||
for specifier in specifiers {
|
||||
range = range.intersection(&release_specifier_to_range(specifier, false));
|
||||
}
|
||||
range
|
||||
}
|
||||
|
||||
/// Convert the [`VersionSpecifier`] to a PubGrub-compatible version range, using release-only
|
||||
|
@ -147,67 +148,57 @@ pub fn release_specifiers_to_ranges(specifiers: VersionSpecifiers) -> Ranges<Ver
|
|||
/// 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> {
|
||||
pub fn release_specifier_to_range(specifier: VersionSpecifier, trim: bool) -> Ranges<Version> {
|
||||
let VersionSpecifier { operator, version } = specifier;
|
||||
// Note(konsti): We switched strategies to trimmed for the markers, but we don't want to cause
|
||||
// churn in lockfile requires-python, so we only trim for markers.
|
||||
let version_trimmed = if trim {
|
||||
version.only_release_trimmed()
|
||||
} else {
|
||||
version.only_release()
|
||||
};
|
||||
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()
|
||||
}
|
||||
// Trailing zeroes are not semantically relevant.
|
||||
Operator::Equal => Ranges::singleton(version_trimmed),
|
||||
Operator::ExactEqual => Ranges::singleton(version_trimmed),
|
||||
Operator::NotEqual => Ranges::singleton(version_trimmed).complement(),
|
||||
Operator::LessThan => Ranges::strictly_lower_than(version_trimmed),
|
||||
Operator::LessThanEqual => Ranges::lower_than(version_trimmed),
|
||||
Operator::GreaterThan => Ranges::strictly_higher_than(version_trimmed),
|
||||
Operator::GreaterThanEqual => Ranges::higher_than(version_trimmed),
|
||||
|
||||
// Trailing zeroes are semantically relevant.
|
||||
Operator::TildeEqual => {
|
||||
let release = version.release();
|
||||
let [rest @ .., last, _] = &*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)
|
||||
Ranges::from_range_bounds(version_trimmed..upper)
|
||||
}
|
||||
Operator::EqualStar => {
|
||||
let low = version.only_release();
|
||||
// For (not-)equal-star, trailing zeroes are still before the star.
|
||||
let low_full = version.only_release();
|
||||
let high = {
|
||||
let mut high = low.clone();
|
||||
let mut high = low_full.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)
|
||||
Ranges::from_range_bounds(version..high)
|
||||
}
|
||||
Operator::NotEqualStar => {
|
||||
let low = version.only_release();
|
||||
// For (not-)equal-star, trailing zeroes are still before the star.
|
||||
let low_full = version.only_release();
|
||||
let high = {
|
||||
let mut high = low.clone();
|
||||
let mut high = low_full.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()
|
||||
Ranges::from_range_bounds(version..high).complement()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -222,8 +213,8 @@ impl LowerBound {
|
|||
/// 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::Included(version) => Bound::Included(version.only_release_trimmed()),
|
||||
Bound::Excluded(version) => Bound::Excluded(version.only_release_trimmed()),
|
||||
Bound::Unbounded => Bound::Unbounded,
|
||||
})
|
||||
}
|
||||
|
@ -357,8 +348,8 @@ impl UpperBound {
|
|||
/// 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::Included(version) => Bound::Included(version.only_release_trimmed()),
|
||||
Bound::Excluded(version) => Bound::Excluded(version.only_release_trimmed()),
|
||||
Bound::Unbounded => Bound::Unbounded,
|
||||
})
|
||||
}
|
||||
|
|
|
@ -80,24 +80,38 @@ impl VersionSpecifiers {
|
|||
|
||||
// Add specifiers for the holes between the bounds.
|
||||
for (lower, upper) in bounds {
|
||||
match (next, lower) {
|
||||
let specifier = 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()));
|
||||
Some(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:?}"
|
||||
);
|
||||
(Bound::Excluded(prev), Bound::Included(lower)) => {
|
||||
match *prev.only_release_trimmed().release() {
|
||||
[major] if *lower.only_release_trimmed().release() == [major, 1] => {
|
||||
Some(VersionSpecifier::not_equals_star_version(Version::new([
|
||||
major, 0,
|
||||
])))
|
||||
}
|
||||
[major, minor]
|
||||
if *lower.only_release_trimmed().release() == [major, minor + 1] =>
|
||||
{
|
||||
Some(VersionSpecifier::not_equals_star_version(Version::new([
|
||||
major, minor,
|
||||
])))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(specifier) = specifier {
|
||||
specifiers.push(specifier);
|
||||
} else {
|
||||
#[cfg(feature = "tracing")]
|
||||
warn!(
|
||||
"Ignoring unsupported gap in `requires-python` version: {next:?} -> {lower:?}"
|
||||
);
|
||||
}
|
||||
next = upper;
|
||||
}
|
||||
|
@ -348,6 +362,33 @@ impl VersionSpecifier {
|
|||
Ok(Self { operator, version })
|
||||
}
|
||||
|
||||
/// Remove all non-release parts of the version.
|
||||
///
|
||||
/// The marker decision diagram relies on the assumption that the negation of a marker tree is
|
||||
/// the complement of the marker space. However, pre-release versions violate this assumption.
|
||||
///
|
||||
/// For example, the marker `python_full_version > '3.9' or python_full_version <= '3.9'`
|
||||
/// does not match `python_full_version == 3.9.0a0` and so cannot simplify to `true`. However,
|
||||
/// its negation, `python_full_version > '3.9' and python_full_version <= '3.9'`, also does not
|
||||
/// match `3.9.0a0` and simplifies to `false`, which violates the algebra decision diagrams
|
||||
/// rely on. For this reason we ignore pre-release versions entirely when evaluating markers.
|
||||
///
|
||||
/// Note that `python_version` cannot take on pre-release values as it is truncated to just the
|
||||
/// major and minor version segments. Thus using release-only specifiers is definitely necessary
|
||||
/// for `python_version` to fully simplify any ranges, such as
|
||||
/// `python_version > '3.9' or python_version <= '3.9'`, which is always `true` for
|
||||
/// `python_version`. For `python_full_version` however, this decision is a semantic change.
|
||||
///
|
||||
/// For Python versions, the major.minor is considered the API version, so unlike the rules
|
||||
/// for package versions in PEP 440, we Python `3.9.0a0` is acceptable for `>= "3.9"`.
|
||||
#[must_use]
|
||||
pub fn only_release(self) -> Self {
|
||||
Self {
|
||||
operator: self.operator,
|
||||
version: self.version.only_release(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `==<version>`
|
||||
pub fn equals_version(version: Version) -> Self {
|
||||
Self {
|
||||
|
@ -442,14 +483,23 @@ impl VersionSpecifier {
|
|||
(Some(VersionSpecifier::equals_version(v1.clone())), None)
|
||||
}
|
||||
// `v >= 3.7 && v < 3.8` is equivalent to `v == 3.7.*`
|
||||
(Bound::Included(v1), Bound::Excluded(v2))
|
||||
if v1.release().len() == 2
|
||||
&& *v2.release() == [v1.release()[0], v1.release()[1] + 1] =>
|
||||
{
|
||||
(
|
||||
Some(VersionSpecifier::equals_star_version(v1.clone())),
|
||||
None,
|
||||
)
|
||||
(Bound::Included(v1), Bound::Excluded(v2)) => {
|
||||
match *v1.only_release_trimmed().release() {
|
||||
[major] if *v2.only_release_trimmed().release() == [major, 1] => {
|
||||
let version = Version::new([major, 0]);
|
||||
(Some(VersionSpecifier::equals_star_version(version)), None)
|
||||
}
|
||||
[major, minor]
|
||||
if *v2.only_release_trimmed().release() == [major, minor + 1] =>
|
||||
{
|
||||
let version = Version::new([major, minor]);
|
||||
(Some(VersionSpecifier::equals_star_version(version)), None)
|
||||
}
|
||||
_ => (
|
||||
VersionSpecifier::from_lower_bound(&Bound::Included(v1.clone())),
|
||||
VersionSpecifier::from_upper_bound(&Bound::Excluded(v2.clone())),
|
||||
),
|
||||
}
|
||||
}
|
||||
(lower, upper) => (
|
||||
VersionSpecifier::from_lower_bound(lower),
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
#![warn(missing_docs)]
|
||||
|
||||
#[cfg(feature = "schemars")]
|
||||
use std::borrow::Cow;
|
||||
use std::error::Error;
|
||||
use std::fmt::{Debug, Display, Formatter};
|
||||
|
|
|
@ -172,7 +172,7 @@ impl InternerGuard<'_> {
|
|||
),
|
||||
// Normalize `python_version` markers to `python_full_version` nodes.
|
||||
MarkerValueVersion::PythonVersion => {
|
||||
match python_version_to_full_version(normalize_specifier(specifier)) {
|
||||
match python_version_to_full_version(specifier.only_release()) {
|
||||
Ok(specifier) => (
|
||||
Variable::Version(CanonicalMarkerValueVersion::PythonFullVersion),
|
||||
Edges::from_specifier(specifier),
|
||||
|
@ -1214,7 +1214,7 @@ impl Edges {
|
|||
|
||||
/// Returns the [`Edges`] for a version specifier.
|
||||
fn from_specifier(specifier: VersionSpecifier) -> Edges {
|
||||
let specifier = release_specifier_to_range(normalize_specifier(specifier));
|
||||
let specifier = release_specifier_to_range(specifier.only_release(), true);
|
||||
Edges::Version {
|
||||
edges: Edges::from_range(&specifier),
|
||||
}
|
||||
|
@ -1227,9 +1227,9 @@ impl Edges {
|
|||
let mut range: Ranges<Version> = versions
|
||||
.into_iter()
|
||||
.map(|version| {
|
||||
let specifier = VersionSpecifier::equals_version(version.clone());
|
||||
let specifier = VersionSpecifier::equals_version(version.only_release());
|
||||
let specifier = python_version_to_full_version(specifier)?;
|
||||
Ok(release_specifier_to_range(normalize_specifier(specifier)))
|
||||
Ok(release_specifier_to_range(specifier, true))
|
||||
})
|
||||
.flatten_ok()
|
||||
.collect::<Result<Ranges<_>, NodeId>>()?;
|
||||
|
@ -1526,57 +1526,62 @@ impl Edges {
|
|||
}
|
||||
}
|
||||
|
||||
// Normalize a [`VersionSpecifier`] before adding it to the tree.
|
||||
fn normalize_specifier(specifier: VersionSpecifier) -> VersionSpecifier {
|
||||
let (operator, version) = specifier.into_parts();
|
||||
|
||||
// The decision diagram relies on the assumption that the negation of a marker tree is
|
||||
// the complement of the marker space. However, pre-release versions violate this assumption.
|
||||
//
|
||||
// For example, the marker `python_full_version > '3.9' or python_full_version <= '3.9'`
|
||||
// does not match `python_full_version == 3.9.0a0` and so cannot simplify to `true`. However,
|
||||
// its negation, `python_full_version > '3.9' and python_full_version <= '3.9'`, also does not
|
||||
// match `3.9.0a0` and simplifies to `false`, which violates the algebra decision diagrams
|
||||
// rely on. For this reason we ignore pre-release versions entirely when evaluating markers.
|
||||
//
|
||||
// Note that `python_version` cannot take on pre-release values as it is truncated to just the
|
||||
// major and minor version segments. Thus using release-only specifiers is definitely necessary
|
||||
// for `python_version` to fully simplify any ranges, such as `python_version > '3.9' or python_version <= '3.9'`,
|
||||
// which is always `true` for `python_version`. For `python_full_version` however, this decision
|
||||
// is a semantic change.
|
||||
let mut release = &*version.release();
|
||||
|
||||
// Strip any trailing `0`s.
|
||||
//
|
||||
// The [`Version`] type ignores trailing `0`s for equality, but still preserves them in its
|
||||
// [`Display`] output. We must normalize all versions by stripping trailing `0`s to remove the
|
||||
// distinction between versions like `3.9` and `3.9.0`. Otherwise, their output would depend on
|
||||
// which form was added to the global marker interner first.
|
||||
//
|
||||
// Note that we cannot strip trailing `0`s for star equality, as `==3.0.*` is different from `==3.*`.
|
||||
if !operator.is_star() {
|
||||
if let Some(end) = release.iter().rposition(|segment| *segment != 0) {
|
||||
if end > 0 {
|
||||
release = &release[..=end];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VersionSpecifier::from_version(operator, Version::new(release)).unwrap()
|
||||
}
|
||||
|
||||
/// Returns the equivalent `python_full_version` specifier for a `python_version` specifier.
|
||||
///
|
||||
/// Returns `Err` with a constant node if the equivalent comparison is always `true` or `false`.
|
||||
fn python_version_to_full_version(specifier: VersionSpecifier) -> Result<VersionSpecifier, NodeId> {
|
||||
// Trailing zeroes matter only for (not-)equals-star and tilde-equals. This means that below
|
||||
// the next two blocks, we can use the trimmed release as the release.
|
||||
if specifier.operator().is_star() {
|
||||
// Input python_version python_full_version
|
||||
// ==3.* 3.* 3.*
|
||||
// ==3.0.* 3.0 3.0.*
|
||||
// ==3.0.0.* 3.0 3.0.*
|
||||
// ==3.9.* 3.9 3.9.*
|
||||
// ==3.9.0.* 3.9 3.9.*
|
||||
// ==3.9.0.0.* 3.9 3.9.*
|
||||
// ==3.9.1.* FALSE FALSE
|
||||
// ==3.9.1.0.* FALSE FALSE
|
||||
// ==3.9.1.0.0.* FALSE FALSE
|
||||
return match &*specifier.version().release() {
|
||||
// `3.*`
|
||||
[_major] => Ok(specifier),
|
||||
// Ex) `3.9.*`, `3.9.0.*`, or `3.9.0.0.*`
|
||||
[major, minor, rest @ ..] if rest.iter().all(|x| *x == 0) => {
|
||||
let python_version = Version::new([major, minor]);
|
||||
// Unwrap safety: A star operator with two version segments is always valid.
|
||||
Ok(VersionSpecifier::from_version(*specifier.operator(), python_version).unwrap())
|
||||
}
|
||||
// Ex) `3.9.1.*` or `3.9.0.1.*`
|
||||
_ => Err(NodeId::FALSE),
|
||||
};
|
||||
}
|
||||
|
||||
if *specifier.operator() == Operator::TildeEqual {
|
||||
// python_version python_full_version
|
||||
// ~=3 (not possible)
|
||||
// ~= 3.0 >= 3.0, < 4.0
|
||||
// ~= 3.9 >= 3.9, < 4.0
|
||||
// ~= 3.9.0 == 3.9.*
|
||||
// ~= 3.9.1 FALSE
|
||||
// ~= 3.9.0.0 == 3.9.*
|
||||
// ~= 3.9.0.1 FALSE
|
||||
return match &*specifier.version().release() {
|
||||
// Ex) `3.0`, `3.7`
|
||||
[_major, _minor] => Ok(specifier),
|
||||
// Ex) `3.9`, `3.9.0`, or `3.9.0.0`
|
||||
[major, minor, rest @ ..] if rest.iter().all(|x| *x == 0) => {
|
||||
let python_version = Version::new([major, minor]);
|
||||
Ok(VersionSpecifier::equals_star_version(python_version))
|
||||
}
|
||||
// Ex) `3.9.1` or `3.9.0.1`
|
||||
_ => Err(NodeId::FALSE),
|
||||
};
|
||||
}
|
||||
|
||||
// Extract the major and minor version segments if the specifier contains exactly
|
||||
// those segments, or if it contains a major segment with an implied minor segment of `0`.
|
||||
let major_minor = match *specifier.version().release() {
|
||||
// For star operators, we cannot add a trailing `0`.
|
||||
//
|
||||
// `python_version == 3.*` is equivalent to `python_full_version == 3.*`. Adding a
|
||||
// trailing `0` would result in `python_version == 3.0.*`, which is incorrect.
|
||||
[_major] if specifier.operator().is_star() => return Ok(specifier),
|
||||
let major_minor = match *specifier.version().only_release_trimmed().release() {
|
||||
// Add a trailing `0` for the minor version, which is implied.
|
||||
// For example, `python_version == 3` matches `3.0.1`, `3.0.2`, etc.
|
||||
[major] => Some((major, 0)),
|
||||
|
@ -1614,9 +1619,10 @@ fn python_version_to_full_version(specifier: VersionSpecifier) -> Result<Version
|
|||
VersionSpecifier::less_than_version(Version::new([major, minor + 1]))
|
||||
}
|
||||
|
||||
// `==3.7.*`, `!=3.7.*`, `~=3.7` already represent the equivalent `python_full_version`
|
||||
// comparison.
|
||||
Operator::EqualStar | Operator::NotEqualStar | Operator::TildeEqual => specifier,
|
||||
Operator::EqualStar | Operator::NotEqualStar | Operator::TildeEqual => {
|
||||
// Handled above.
|
||||
unreachable!()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
let [major, minor, ..] = *specifier.version().release() else {
|
||||
|
@ -1624,13 +1630,14 @@ fn python_version_to_full_version(specifier: VersionSpecifier) -> Result<Version
|
|||
};
|
||||
|
||||
Ok(match specifier.operator() {
|
||||
// `python_version` cannot have more than two release segments, so equality is impossible.
|
||||
Operator::Equal | Operator::ExactEqual | Operator::EqualStar | Operator::TildeEqual => {
|
||||
// `python_version` cannot have more than two release segments, and we know
|
||||
// that the following release segments aren't purely zeroes so equality is impossible.
|
||||
Operator::Equal | Operator::ExactEqual => {
|
||||
return Err(NodeId::FALSE);
|
||||
}
|
||||
|
||||
// Similarly, inequalities are always `true`.
|
||||
Operator::NotEqual | Operator::NotEqualStar => return Err(NodeId::TRUE),
|
||||
Operator::NotEqual => return Err(NodeId::TRUE),
|
||||
|
||||
// `python_version {<,<=} 3.7.8` is equivalent to `python_full_version < 3.8`.
|
||||
Operator::LessThan | Operator::LessThanEqual => {
|
||||
|
@ -1641,6 +1648,11 @@ fn python_version_to_full_version(specifier: VersionSpecifier) -> Result<Version
|
|||
Operator::GreaterThan | Operator::GreaterThanEqual => {
|
||||
VersionSpecifier::greater_than_equal_version(Version::new([major, minor + 1]))
|
||||
}
|
||||
|
||||
Operator::EqualStar | Operator::NotEqualStar | Operator::TildeEqual => {
|
||||
// Handled above.
|
||||
unreachable!()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -64,8 +64,8 @@ fn collect_dnf(
|
|||
continue;
|
||||
}
|
||||
|
||||
// Detect whether the range for this edge can be simplified as a star inequality.
|
||||
if let Some(specifier) = star_range_inequality(&range) {
|
||||
// Detect whether the range for this edge can be simplified as a star specifier.
|
||||
if let Some(specifier) = star_range_specifier(&range) {
|
||||
path.push(MarkerExpression::Version {
|
||||
key: marker.key().into(),
|
||||
specifier,
|
||||
|
@ -343,22 +343,34 @@ where
|
|||
Some(excluded)
|
||||
}
|
||||
|
||||
/// Returns `Some` if the version expression can be simplified as a star inequality with the given
|
||||
/// specifier.
|
||||
/// Returns `Some` if the version range can be simplified as a star specifier.
|
||||
///
|
||||
/// For example, `python_full_version < '3.8' or python_full_version >= '3.9'` can be simplified to
|
||||
/// `python_full_version != '3.8.*'`.
|
||||
fn star_range_inequality(range: &Ranges<Version>) -> Option<VersionSpecifier> {
|
||||
/// Only for the two bounds case not covered by [`VersionSpecifier::from_release_only_bounds`].
|
||||
///
|
||||
/// For negative ranges like `python_full_version < '3.8' or python_full_version >= '3.9'`,
|
||||
/// returns `!= '3.8.*'`.
|
||||
fn star_range_specifier(range: &Ranges<Version>) -> Option<VersionSpecifier> {
|
||||
if range.iter().count() != 2 {
|
||||
return None;
|
||||
}
|
||||
// Check for negative star range: two segments [(Unbounded, Excluded(v1)), (Included(v2), Unbounded)]
|
||||
let (b1, b2) = range.iter().collect_tuple()?;
|
||||
|
||||
match (b1, b2) {
|
||||
((Bound::Unbounded, Bound::Excluded(v1)), (Bound::Included(v2), Bound::Unbounded))
|
||||
if v1.release().len() == 2
|
||||
&& *v2.release() == [v1.release()[0], v1.release()[1] + 1] =>
|
||||
{
|
||||
Some(VersionSpecifier::not_equals_star_version(v1.clone()))
|
||||
if let ((Bound::Unbounded, Bound::Excluded(v1)), (Bound::Included(v2), Bound::Unbounded)) =
|
||||
(b1, b2)
|
||||
{
|
||||
match *v1.only_release_trimmed().release() {
|
||||
[major] if *v2.release() == [major, 1] => {
|
||||
Some(VersionSpecifier::not_equals_star_version(Version::new([
|
||||
major, 0,
|
||||
])))
|
||||
}
|
||||
[major, minor] if *v2.release() == [major, minor + 1] => {
|
||||
Some(VersionSpecifier::not_equals_star_version(v1.clone()))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
_ => None,
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -2271,13 +2271,13 @@ mod test {
|
|||
#[test]
|
||||
fn test_marker_simplification() {
|
||||
assert_false("python_version == '3.9.1'");
|
||||
assert_false("python_version == '3.9.0.*'");
|
||||
assert_true("python_version != '3.9.1'");
|
||||
|
||||
// Technically these is are valid substring comparison, but we do not allow them.
|
||||
// e.g., using a version with patch components with `python_version` is considered
|
||||
// impossible to satisfy since the value it is truncated at the minor version
|
||||
assert_false("python_version in '3.9.0'");
|
||||
// This is an edge case that happens to be supported, but is not critical to support.
|
||||
assert_simplifies(
|
||||
"python_version in '3.9.0'",
|
||||
"python_full_version == '3.9.*'",
|
||||
);
|
||||
// e.g., using a version that is not PEP 440 compliant is considered arbitrary
|
||||
assert_true("python_version in 'foo'");
|
||||
// e.g., including `*` versions, which would require tracking a version specifier
|
||||
|
@ -2287,16 +2287,25 @@ mod test {
|
|||
assert_true("python_version in '3.9,3.10'");
|
||||
assert_true("python_version in '3.9 or 3.10'");
|
||||
|
||||
// e.g, when one of the values cannot be true
|
||||
// TODO(zanieb): This seems like a quirk of the `python_full_version` normalization, this
|
||||
// should just act as though the patch version isn't present
|
||||
assert_false("python_version in '3.9 3.10.0 3.11'");
|
||||
// This is an edge case that happens to be supported, but is not critical to support.
|
||||
assert_simplifies(
|
||||
"python_version in '3.9 3.10.0 3.11'",
|
||||
"python_full_version >= '3.9' and python_full_version < '3.12'",
|
||||
);
|
||||
|
||||
assert_simplifies("python_version == '3.9'", "python_full_version == '3.9.*'");
|
||||
assert_simplifies(
|
||||
"python_version == '3.9.0'",
|
||||
"python_full_version == '3.9.*'",
|
||||
);
|
||||
assert_simplifies(
|
||||
"python_version == '3.9.0.*'",
|
||||
"python_full_version == '3.9.*'",
|
||||
);
|
||||
assert_simplifies(
|
||||
"python_version == '3.*'",
|
||||
"python_full_version >= '3' and python_full_version < '4'",
|
||||
);
|
||||
|
||||
// `<version> in`
|
||||
// e.g., when the range is not contiguous
|
||||
|
@ -2528,6 +2537,68 @@ mod test {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_python_version_equal_star() {
|
||||
// Input, equivalent with python_version, equivalent with python_full_version
|
||||
let cases = [
|
||||
("3.*", "3.*", "3.*"),
|
||||
("3.0.*", "3.0", "3.0.*"),
|
||||
("3.0.0.*", "3.0", "3.0.*"),
|
||||
("3.9.*", "3.9", "3.9.*"),
|
||||
("3.9.0.*", "3.9", "3.9.*"),
|
||||
("3.9.0.0.*", "3.9", "3.9.*"),
|
||||
];
|
||||
for (input, equal_python_version, equal_python_full_version) in cases {
|
||||
assert_eq!(
|
||||
m(&format!("python_version == '{input}'")),
|
||||
m(&format!("python_version == '{equal_python_version}'")),
|
||||
"{input} {equal_python_version}"
|
||||
);
|
||||
assert_eq!(
|
||||
m(&format!("python_version == '{input}'")),
|
||||
m(&format!(
|
||||
"python_full_version == '{equal_python_full_version}'"
|
||||
)),
|
||||
"{input} {equal_python_full_version}"
|
||||
);
|
||||
}
|
||||
|
||||
let cases_false = ["3.9.1.*", "3.9.1.0.*", "3.9.1.0.0.*"];
|
||||
for input in cases_false {
|
||||
assert!(
|
||||
m(&format!("python_version == '{input}'")).is_false(),
|
||||
"{input}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tilde_equal_normalization() {
|
||||
assert_eq!(
|
||||
m("python_version ~= '3.10.0'"),
|
||||
m("python_version >= '3.10.0' and python_version < '3.11.0'")
|
||||
);
|
||||
|
||||
// Two digit versions such as `python_version` get padded with a zero, so they can never
|
||||
// match
|
||||
assert_eq!(m("python_version ~= '3.10.1'"), MarkerTree::FALSE);
|
||||
|
||||
assert_eq!(
|
||||
m("python_version ~= '3.10'"),
|
||||
m("python_version >= '3.10' and python_version < '4.0'")
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
m("python_full_version ~= '3.10.0'"),
|
||||
m("python_full_version >= '3.10.0' and python_full_version < '3.11.0'")
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
m("python_full_version ~= '3.10'"),
|
||||
m("python_full_version >= '3.10' and python_full_version < '4.0'")
|
||||
);
|
||||
}
|
||||
|
||||
/// This tests marker implication.
|
||||
///
|
||||
/// Specifically, these test cases come from a [bug] where `foo` and `bar`
|
||||
|
@ -3324,4 +3395,32 @@ mod test {
|
|||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// Case a: There is no version `3` (no trailing zero) in the interner yet.
|
||||
#[test]
|
||||
fn marker_normalization_a() {
|
||||
let left_tree = m("python_version == '3.0.*'");
|
||||
let left = left_tree.try_to_string().unwrap();
|
||||
let right = "python_full_version == '3.0.*'";
|
||||
assert_eq!(left, right, "{left} != {right}");
|
||||
}
|
||||
|
||||
/// Case b: There is already a version `3` (no trailing zero) in the interner.
|
||||
#[test]
|
||||
fn marker_normalization_b() {
|
||||
m("python_version >= '3' and python_version <= '3.0'");
|
||||
|
||||
let left_tree = m("python_version == '3.0.*'");
|
||||
let left = left_tree.try_to_string().unwrap();
|
||||
let right = "python_full_version == '3.0.*'";
|
||||
assert_eq!(left, right, "{left} != {right}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_normalization_c() {
|
||||
let left_tree = MarkerTree::from_str("python_version == '3.10.0.*'").unwrap();
|
||||
let left = left_tree.try_to_string().unwrap();
|
||||
let right = "python_full_version == '3.10.*'";
|
||||
assert_eq!(left, right, "{left} != {right}");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,7 +3,9 @@ use petgraph::{
|
|||
graph::{DiGraph, NodeIndex},
|
||||
};
|
||||
use rustc_hash::{FxHashMap, FxHashSet};
|
||||
use std::{borrow::Cow, collections::BTreeSet, hash::Hash, rc::Rc};
|
||||
#[cfg(feature = "schemars")]
|
||||
use std::borrow::Cow;
|
||||
use std::{collections::BTreeSet, hash::Hash, rc::Rc};
|
||||
use uv_normalize::{ExtraName, GroupName, PackageName};
|
||||
|
||||
use crate::dependency_groups::{DependencyGroupSpecifier, DependencyGroups};
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
use serde::{Serialize, Serializer};
|
||||
#[cfg(feature = "schemars")]
|
||||
use std::borrow::Cow;
|
||||
use std::fmt::Display;
|
||||
use std::str::FromStr;
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
---
|
||||
source: crates/uv-requirements-txt/src/lib.rs
|
||||
expression: actual
|
||||
snapshot_kind: text
|
||||
---
|
||||
RequirementsTxt {
|
||||
requirements: [
|
||||
|
@ -23,7 +24,7 @@ RequirementsTxt {
|
|||
),
|
||||
),
|
||||
),
|
||||
marker: python_full_version >= '3.8' and python_full_version < '4.0',
|
||||
marker: python_full_version >= '3.8' and python_full_version < '4',
|
||||
origin: Some(
|
||||
File(
|
||||
"<REQUIREMENTS_DIR>/poetry-with-hashes.txt",
|
||||
|
@ -54,7 +55,7 @@ RequirementsTxt {
|
|||
),
|
||||
),
|
||||
),
|
||||
marker: python_full_version >= '3.8' and python_full_version < '4.0',
|
||||
marker: python_full_version >= '3.8' and python_full_version < '4',
|
||||
origin: Some(
|
||||
File(
|
||||
"<REQUIREMENTS_DIR>/poetry-with-hashes.txt",
|
||||
|
@ -85,7 +86,7 @@ RequirementsTxt {
|
|||
),
|
||||
),
|
||||
),
|
||||
marker: python_full_version >= '3.8' and python_full_version < '4.0' and sys_platform == 'win32',
|
||||
marker: python_full_version >= '3.8' and python_full_version < '4' and sys_platform == 'win32',
|
||||
origin: Some(
|
||||
File(
|
||||
"<REQUIREMENTS_DIR>/poetry-with-hashes.txt",
|
||||
|
@ -116,7 +117,7 @@ RequirementsTxt {
|
|||
),
|
||||
),
|
||||
),
|
||||
marker: python_full_version >= '3.8' and python_full_version < '4.0',
|
||||
marker: python_full_version >= '3.8' and python_full_version < '4',
|
||||
origin: Some(
|
||||
File(
|
||||
"<REQUIREMENTS_DIR>/poetry-with-hashes.txt",
|
||||
|
@ -148,7 +149,7 @@ RequirementsTxt {
|
|||
),
|
||||
),
|
||||
),
|
||||
marker: python_full_version >= '3.8' and python_full_version < '4.0',
|
||||
marker: python_full_version >= '3.8' and python_full_version < '4',
|
||||
origin: Some(
|
||||
File(
|
||||
"<REQUIREMENTS_DIR>/poetry-with-hashes.txt",
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
---
|
||||
source: crates/uv-requirements-txt/src/lib.rs
|
||||
expression: actual
|
||||
snapshot_kind: text
|
||||
---
|
||||
RequirementsTxt {
|
||||
requirements: [
|
||||
|
@ -23,7 +24,7 @@ RequirementsTxt {
|
|||
),
|
||||
),
|
||||
),
|
||||
marker: python_full_version >= '3.8' and python_full_version < '4.0',
|
||||
marker: python_full_version >= '3.8' and python_full_version < '4',
|
||||
origin: Some(
|
||||
File(
|
||||
"<REQUIREMENTS_DIR>/poetry-with-hashes.txt",
|
||||
|
@ -54,7 +55,7 @@ RequirementsTxt {
|
|||
),
|
||||
),
|
||||
),
|
||||
marker: python_full_version >= '3.8' and python_full_version < '4.0',
|
||||
marker: python_full_version >= '3.8' and python_full_version < '4',
|
||||
origin: Some(
|
||||
File(
|
||||
"<REQUIREMENTS_DIR>/poetry-with-hashes.txt",
|
||||
|
@ -85,7 +86,7 @@ RequirementsTxt {
|
|||
),
|
||||
),
|
||||
),
|
||||
marker: python_full_version >= '3.8' and python_full_version < '4.0' and sys_platform == 'win32',
|
||||
marker: python_full_version >= '3.8' and python_full_version < '4' and sys_platform == 'win32',
|
||||
origin: Some(
|
||||
File(
|
||||
"<REQUIREMENTS_DIR>/poetry-with-hashes.txt",
|
||||
|
@ -116,7 +117,7 @@ RequirementsTxt {
|
|||
),
|
||||
),
|
||||
),
|
||||
marker: python_full_version >= '3.8' and python_full_version < '4.0',
|
||||
marker: python_full_version >= '3.8' and python_full_version < '4',
|
||||
origin: Some(
|
||||
File(
|
||||
"<REQUIREMENTS_DIR>/poetry-with-hashes.txt",
|
||||
|
@ -148,7 +149,7 @@ RequirementsTxt {
|
|||
),
|
||||
),
|
||||
),
|
||||
marker: python_full_version >= '3.8' and python_full_version < '4.0',
|
||||
marker: python_full_version >= '3.8' and python_full_version < '4',
|
||||
origin: Some(
|
||||
File(
|
||||
"<REQUIREMENTS_DIR>/poetry-with-hashes.txt",
|
||||
|
|
|
@ -5013,14 +5013,14 @@ fn lock_requires_python_not_equal() -> Result<()> {
|
|||
"#,
|
||||
)?;
|
||||
|
||||
uv_snapshot!(context.filters(), context.lock(), @r###"
|
||||
uv_snapshot!(context.filters(), context.lock(), @r"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
Resolved 2 packages in [TIME]
|
||||
"###);
|
||||
");
|
||||
|
||||
let lock = fs_err::read_to_string(&lockfile).unwrap();
|
||||
|
||||
|
@ -27522,7 +27522,7 @@ fn windows_arm() -> Result<()> {
|
|||
lock, @r#"
|
||||
version = 1
|
||||
revision = 2
|
||||
requires-python = ">=3.12.[X], <3.13"
|
||||
requires-python = "==3.12.*"
|
||||
resolution-markers = [
|
||||
"platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||
"platform_machine == 'AMD64' and sys_platform == 'win32'",
|
||||
|
@ -27599,7 +27599,7 @@ fn windows_amd64_required() -> Result<()> {
|
|||
lock, @r#"
|
||||
version = 1
|
||||
revision = 2
|
||||
requires-python = ">=3.12.[X], <3.13"
|
||||
requires-python = "==3.12.*"
|
||||
required-markers = [
|
||||
"platform_machine == 'x86' and sys_platform == 'win32'",
|
||||
"platform_machine == 'AMD64' and sys_platform == 'win32'",
|
||||
|
@ -28725,3 +28725,34 @@ fn lock_prefix_match() -> Result<()> {
|
|||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Regression test for <https://github.com/astral-sh/uv/issues/14231>.
|
||||
#[test]
|
||||
fn test_tilde_equals_python_version() -> Result<()> {
|
||||
let context = TestContext::new("3.12");
|
||||
|
||||
let pyproject_toml = context.temp_dir.child("pyproject.toml");
|
||||
pyproject_toml.write_str(
|
||||
r#"
|
||||
[project]
|
||||
name = "debug"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"anyio==4.2.0; python_full_version >= '3.11'",
|
||||
"anyio==4.3.0; python_full_version ~= '3.10.0'",
|
||||
]
|
||||
"#,
|
||||
)?;
|
||||
|
||||
uv_snapshot!(context.filters(), context.lock(), @r"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
Resolved 7 packages in [TIME]
|
||||
");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue