From db3611e5c13e81241acdce891f3080a2dd8d4265 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 10 Aug 2026 20:35:46 -0400 Subject: [PATCH] Preserve consecutive Python minor-version exclusions --- crates/uv-pep440/src/version_specifier.rs | 45 ++++++++------ crates/uv/tests/lock/lock.rs | 72 +++++++++++++++++++++++ 2 files changed, 99 insertions(+), 18 deletions(-) diff --git a/crates/uv-pep440/src/version_specifier.rs b/crates/uv-pep440/src/version_specifier.rs index c8c136ca52e..de6082c6e36 100644 --- a/crates/uv-pep440/src/version_specifier.rs +++ b/crates/uv-pep440/src/version_specifier.rs @@ -93,38 +93,47 @@ impl VersionSpecifiers { // Add specifiers for the holes between the bounds. for (lower, upper) in bounds { - let specifier = match (next, lower) { + let gap_specifiers = 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 => { - Some(VersionSpecifier::not_equals_version(prev.clone())) + vec![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)) => { - 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] => + let prev = prev.only_release_trimmed(); + let lower = lower.only_release_trimmed(); + match (&*prev.release(), &*lower.release()) { + ([major], [lower_major, lower_minor]) if major == lower_major => (0 + ..*lower_minor) + .map(|minor| { + VersionSpecifier::not_equals_star_version(Version::new([ + *major, minor, + ])) + }) + .collect(), + ([major, minor], [lower_major, lower_minor]) + if major == lower_major && minor < lower_minor => { - Some(VersionSpecifier::not_equals_star_version(Version::new([ - major, minor, - ]))) + (*minor..*lower_minor) + .map(|minor| { + VersionSpecifier::not_equals_star_version(Version::new([ + *major, minor, + ])) + }) + .collect() } - _ => None, + _ => Vec::new(), } } - _ => None, + _ => Vec::new(), }; - if let Some(specifier) = specifier { - specifiers.push(specifier); - } else { + if gap_specifiers.is_empty() { #[cfg(feature = "tracing")] warn!( "Ignoring unsupported gap in `requires-python` version: {next:?} -> {lower:?}" ); + } else { + specifiers.extend(gap_specifiers); } next = upper; } diff --git a/crates/uv/tests/lock/lock.rs b/crates/uv/tests/lock/lock.rs index 21420d000e6..9c1b5268d05 100644 --- a/crates/uv/tests/lock/lock.rs +++ b/crates/uv/tests/lock/lock.rs @@ -7191,6 +7191,78 @@ fn lock_requires_python_not_equal() -> Result<()> { Ok(()) } +/// Lock a requirement from PyPI when `Requires-Python` excludes consecutive minor versions. +#[cfg(feature = "test-universal")] +#[test] +fn lock_requires_python_not_equal_consecutive_wildcards() -> Result<()> { + let context = uv_test::test_context!("3.13"); + + let lockfile = context.temp_dir.join("uv.lock"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.10, !=3.11.*, !=3.12.*, <3.14" + dependencies = ["iniconfig"] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 2 packages in [TIME] + "); + + let lock = fs_err::read_to_string(&lockfile).unwrap(); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.10, !=3.11.*, !=3.12.*, <3.14" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload-time = "2023-01-07T11:08:11.254Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "iniconfig" }, + ] + + [package.metadata] + requires-dist = [{ name = "iniconfig" }] + "# + ); + }); + + // Re-run with `--locked`. + uv_snapshot!(context.filters(), context.lock().arg("--locked"), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 2 packages in [TIME] + "); + + Ok(()) +} + /// Lock a requirement from PyPI, respecting the `Requires-Python` metadata. In this case, /// `Requires-Python` uses a pre-release specifier, but it's effectively ignored, as `>=3.11.0b1` /// is interpreted as equivalent to `>=3.11.0`.