diff --git a/crates/uv-pep440/src/version.rs b/crates/uv-pep440/src/version.rs index 223701692..44db1d88d 100644 --- a/crates/uv-pep440/src/version.rs +++ b/crates/uv-pep440/src/version.rs @@ -292,7 +292,7 @@ impl Version { /// /// When the iterator yields no elements. #[inline] - pub fn new(release_numbers: I) -> Self + pub fn new(release_segments: I) -> Self where I: IntoIterator, R: Borrow, @@ -302,7 +302,7 @@ impl Version { small: VersionSmall::new(), }, } - .with_release(release_numbers) + .with_release(release_segments) } /// Whether this is an alpha/beta/rc or dev version diff --git a/crates/uv-pep508/src/marker/algebra.rs b/crates/uv-pep508/src/marker/algebra.rs index 6b166dbc6..611063aaa 100644 --- a/crates/uv-pep508/src/marker/algebra.rs +++ b/crates/uv-pep508/src/marker/algebra.rs @@ -47,6 +47,7 @@ use std::cmp::Ordering; use std::fmt; +use std::hash::Hash; use std::ops::Bound; use std::sync::{LazyLock, Mutex, MutexGuard}; @@ -183,6 +184,55 @@ impl InternerGuard<'_> { } } }, + // ` ~= python_version` and ` ~= python_full_version` + MarkerExpression::VersionInvertedTilde { key, specifier } => match key { + MarkerValueVersion::ImplementationVersion => { + // The number of segments in the implementation version is unknown and may + // only be a single one not support with tilde equal + return NodeId::FALSE; + } + MarkerValueVersion::PythonFullVersion => { + // Given `3.10 ~= python_full_version`, + // which matches for 3.10.0, 3.10.1, ... it becomes + // `python_full_version < 3.11 and python_version >= 3.10` + let major = specifier.version().release().first().copied().unwrap_or(0); + let minor = specifier.version().release().get(1).copied().unwrap_or(0); + + let upper_bound = + VersionSpecifier::less_than_version(Version::new([major, minor + 1, 0])); + let lower_bound = if minor == 0 { + VersionSpecifier::greater_than_equal_version(Version::new([major])) + } else { + VersionSpecifier::greater_than_equal_version(Version::new([major, minor])) + }; + let ranges = release_specifier_to_range(upper_bound, true) + .intersection(&release_specifier_to_range(lower_bound, true)); + ( + Variable::Version(CanonicalMarkerValueVersion::PythonFullVersion), + Edges::from_version_ranges(&ranges), + ) + } + // Normalize `python_version` markers to `python_full_version` nodes. + MarkerValueVersion::PythonVersion => { + // Given `3.10 ~= python_version`, + // which matches for 3.0, 3.1, ..., 3.9, 3.10, it becomes + // `python_full_version < 3.11 and python_version >= 3` + let major = specifier.version().release().first().copied().unwrap_or(0); + let minor = specifier.version().release().get(1).copied().unwrap_or(0); + + let upper_bound = + VersionSpecifier::less_than_version(Version::new([major, minor + 1])); + let lower_bound = + VersionSpecifier::greater_than_equal_version(Version::new([major])); + let ranges = release_specifier_to_range(upper_bound, true) + .intersection(&release_specifier_to_range(lower_bound, true)); + + ( + Variable::Version(CanonicalMarkerValueVersion::PythonFullVersion), + Edges::from_version_ranges(&ranges), + ) + } + }, // A variable representing the output of a version key. Edges correspond // to disjoint version ranges. MarkerExpression::VersionIn { @@ -707,7 +757,7 @@ impl InternerGuard<'_> { if matches!(i, NodeId::TRUE) { let var = Variable::Version(CanonicalMarkerValueVersion::PythonFullVersion); let edges = Edges::Version { - edges: Edges::from_range(&py_range), + edges: Edges::from_ranges(&py_range), }; return self.create_node(var, edges).negate(i); } @@ -1220,7 +1270,7 @@ impl Edges { }; Edges::String { - edges: Edges::from_range(&range), + edges: Edges::from_ranges(&range), } } @@ -1228,7 +1278,13 @@ impl Edges { fn from_specifier(specifier: VersionSpecifier) -> Edges { let specifier = release_specifier_to_range(specifier.only_release(), true); Edges::Version { - edges: Edges::from_range(&specifier), + edges: Edges::from_ranges(&specifier), + } + } + + fn from_version_ranges(ranges: &Ranges) -> Edges { + Edges::Version { + edges: Edges::from_ranges(ranges), } } @@ -1254,7 +1310,7 @@ impl Edges { } Ok(Edges::Version { - edges: Edges::from_range(&range), + edges: Edges::from_ranges(&range), }) } @@ -1275,25 +1331,25 @@ impl Edges { } Edges::Version { - edges: Edges::from_range(&range), + edges: Edges::from_ranges(&range), } } /// Returns an [`Edges`] where values in the given range are `true`. - fn from_range(range: &Ranges) -> SmallVec<(Ranges, NodeId)> + fn from_ranges(ranges: &Ranges) -> SmallVec<(Ranges, NodeId)> where T: Ord + Clone, { let mut edges = SmallVec::new(); // Add the `true` edges. - for (start, end) in range.iter() { + for (start, end) in ranges.iter() { let range = Ranges::from_range_bounds((start.clone(), end.clone())); edges.push((range, NodeId::TRUE)); } // Add the `false` edges. - for (start, end) in range.complement().iter() { + for (start, end) in ranges.complement().iter() { let range = Ranges::from_range_bounds((start.clone(), end.clone())); edges.push((range, NodeId::FALSE)); } @@ -1610,10 +1666,10 @@ fn python_version_to_full_version(specifier: VersionSpecifier) -> Result { VersionSpecifier::equals_star_version(version) @@ -1638,13 +1694,13 @@ fn python_version_to_full_version(specifier: VersionSpecifier) -> Result { @@ -1668,8 +1724,10 @@ fn python_version_to_full_version(specifier: VersionSpecifier) -> Result( parse_inverted_version_expr(&l_string, operator, key, reporter) } // '...' == - MarkerValue::MarkerEnvString(key) => Some(MarkerExpression::String { - key, + MarkerValue::MarkerEnvString(key) => { // Invert the operator to normalize the expression order. - operator: operator.invert(), - value: l_string, - }), + if let Some(operator) = operator.invert() { + Some(MarkerExpression::String { + key, + operator, + value: l_string, + }) + } else { + debug_assert_eq!(operator, MarkerOperator::TildeEqual); + reporter.report( + MarkerWarningKind::StringStringComparison, + format!( + "Comparing a string with `~=` doesn't make sense: + '{l_string}' {operator} {r_value}, will be ignored" + ), + ); + None + } + } // `"test" in extras` or `"dev" in dependency_groups` MarkerValue::MarkerEnvList(key) => { let operator = @@ -484,9 +498,6 @@ fn parse_inverted_version_expr( key: MarkerValueVersion, reporter: &mut impl Reporter, ) -> Option { - // Invert the operator to normalize the expression order. - let marker_operator = marker_operator.invert(); - // Not star allowed here, `'3.*' == python_version` is not a valid PEP 440 comparison. let version = match value.parse::() { Ok(version) => version, @@ -503,6 +514,25 @@ fn parse_inverted_version_expr( } }; + // Invert the operator to normalize the expression order. + let Some(marker_operator) = marker_operator.invert() else { + // The only operator that can't be inverted is `~=`. + debug_assert_eq!(marker_operator, MarkerOperator::TildeEqual); + return Some(MarkerExpression::VersionInvertedTilde { + key, + specifier: match VersionSpecifier::from_version(Operator::TildeEqual, version) { + Ok(specifier) => specifier, + Err(err) => { + reporter.report( + MarkerWarningKind::Pep440Error, + format!("Invalid operator/version combination: {err}"), + ); + return None; + } + }, + }); + }; + let Some(operator) = marker_operator.to_pep440_operator() else { reporter.report( MarkerWarningKind::Pep440Error, diff --git a/crates/uv-pep508/src/marker/simplify.rs b/crates/uv-pep508/src/marker/simplify.rs index b1565835b..9a392c8a1 100644 --- a/crates/uv-pep508/src/marker/simplify.rs +++ b/crates/uv-pep508/src/marker/simplify.rs @@ -410,6 +410,10 @@ fn is_negation(left: &MarkerExpression, right: &MarkerExpression) -> bool { .negate() .is_some_and(|negated| negated == *specifier2.operator()) } + MarkerExpression::VersionInvertedTilde { .. } => { + // The inversion is not a single expression. + false + } MarkerExpression::VersionIn { key, versions, diff --git a/crates/uv-pep508/src/marker/tree.rs b/crates/uv-pep508/src/marker/tree.rs index f874fd447..cea938d04 100644 --- a/crates/uv-pep508/src/marker/tree.rs +++ b/crates/uv-pep508/src/marker/tree.rs @@ -268,20 +268,22 @@ impl MarkerOperator { } /// Inverts this marker operator. - pub(crate) fn invert(self) -> MarkerOperator { - match self { + /// + /// `~=` has no inverse. + pub(crate) fn invert(self) -> Option { + Some(match self { Self::LessThan => Self::GreaterThan, Self::LessEqual => Self::GreaterEqual, Self::GreaterThan => Self::LessThan, Self::GreaterEqual => Self::LessEqual, Self::Equal => Self::Equal, Self::NotEqual => Self::NotEqual, - Self::TildeEqual => Self::TildeEqual, + Self::TildeEqual => return None, Self::In => Self::Contains, Self::NotIn => Self::NotContains, Self::Contains => Self::In, Self::NotContains => Self::NotIn, - } + }) } /// Negates this marker operator. @@ -513,6 +515,12 @@ pub enum MarkerExpression { key: MarkerValueVersion, specifier: VersionSpecifier, }, + /// Special case for ` ~= python_version` and + /// ` ~= python_full_version`. + VersionInvertedTilde { + key: MarkerValueVersion, + specifier: VersionSpecifier, + }, /// A version in list expression, e.g. ` in `. /// /// A special case of [`MarkerExpression::String`] with the [`MarkerOperator::In`] operator for @@ -526,7 +534,7 @@ pub enum MarkerExpression { versions: Vec, operator: ContainerOperator, }, - /// An string marker comparison, e.g. `sys_platform == '...'`. + /// A string marker comparison, e.g. `sys_platform == '...'`. /// /// Inverted string expressions, e.g `'...' == sys_platform`, are also normalized to this form. String { @@ -551,6 +559,8 @@ pub enum MarkerExpression { pub(crate) enum MarkerExpressionKind { /// A version expression, e.g. ` `. Version(MarkerValueVersion), + /// An inverted tilde-equal version expression, e.g. ` ~= `. + VersionInvertedTilde(MarkerValueVersion), /// A version `in` expression, e.g. ` in `. VersionIn(MarkerValueVersion), /// A string marker comparison, e.g. `sys_platform == '...'`. @@ -667,6 +677,9 @@ impl MarkerExpression { pub(crate) fn kind(&self) -> MarkerExpressionKind { match self { MarkerExpression::Version { key, .. } => MarkerExpressionKind::Version(*key), + MarkerExpression::VersionInvertedTilde { key, .. } => { + MarkerExpressionKind::VersionInvertedTilde(*key) + } MarkerExpression::VersionIn { key, .. } => MarkerExpressionKind::VersionIn(*key), MarkerExpression::String { key, .. } => MarkerExpressionKind::String(*key), MarkerExpression::List { pair, .. } => MarkerExpressionKind::List(pair.key()), @@ -686,6 +699,10 @@ impl Display for MarkerExpression { } write!(f, "{key} {op} '{version}'") } + MarkerExpression::VersionInvertedTilde { key, specifier } => { + let (op, version) = (specifier.operator(), specifier.version()); + write!(f, "'{version}' {op} {key}") + } MarkerExpression::VersionIn { key, versions, @@ -703,7 +720,8 @@ impl Display for MarkerExpression { operator, MarkerOperator::Contains | MarkerOperator::NotContains ) { - return write!(f, "'{value}' {} {key}", operator.invert()); + // Unwrap safety: `in` and `not in` have inverses. + return write!(f, "'{value}' {} {key}", operator.invert().unwrap()); } write!(f, "{key} {operator} '{value}'") @@ -2843,6 +2861,39 @@ mod test { ); } + #[test] + fn test_tilde_equal_inverted_normalization() { + assert_eq!( + m("'3.0' ~= python_version"), + m("python_version >= '3' and python_version <= '3.0'") + ); + + assert_eq!( + m("'3.10' ~= python_version"), + m("python_version >= '3' and python_version <= '3.10'") + ); + + assert_eq!( + m("'3.10.0' ~= python_version"), + m("python_version >= '3' and python_version <= '3.10'") + ); + + assert_eq!( + m("'3.10' ~= python_full_version"), + m("python_full_version < '3.11' and python_full_version >= '3.10.0'") + ); + + assert_eq!( + m("'3.10.10' ~= python_full_version"), + m("python_full_version < '3.11' and python_full_version >= '3.10.0'") + ); + + assert_simplifies( + "python_version == '3.0.*'", + "python_full_version == '3.0.*'", + ); + } + /// This tests marker implication. /// /// Specifically, these test cases come from a [bug] where `foo` and `bar` @@ -2926,7 +2977,7 @@ mod test { ); assert_eq!( m("'3.6' ~= python_version").negate(), - m("python_version < '3.6' or python_version != '3.*'") + m("python_full_version < '3' or python_full_version >= '3.7'") ); assert_eq!( m("python_version ~= '3.6.2'").negate(),