diff --git a/crates/uv-distribution-types/src/prioritized_distribution.rs b/crates/uv-distribution-types/src/prioritized_distribution.rs index 7abd7b9d15d61..230ff3401a4e4 100644 --- a/crates/uv-distribution-types/src/prioritized_distribution.rs +++ b/crates/uv-distribution-types/src/prioritized_distribution.rs @@ -926,6 +926,10 @@ fn implied_platform_markers(filename: &WheelFilename) -> MarkerTree { fn implied_python_markers(filename: &WheelFilename) -> MarkerTree { let mut marker = MarkerTree::FALSE; + // If any ABI tag is `abi3` (the stable ABI), the python tag represents a minimum version + // rather than an exact version. For example, `cp39-abi3` means "compatible with CPython 3.9+". + let is_abi3 = filename.abi_tags().contains(&AbiTag::Abi3); + for python_tag in filename.python_tags() { // First, construct the version marker based on the tag let mut tree = match python_tag { @@ -934,12 +938,21 @@ fn implied_python_markers(filename: &WheelFilename) -> MarkerTree { return MarkerTree::TRUE; } LanguageTag::Python { major, minor: None } | LanguageTag::CPythonMajor { major } => { - MarkerTree::expression(MarkerExpression::Version { - key: uv_pep508::MarkerValueVersion::PythonVersion, - specifier: VersionSpecifier::equals_star_version(Version::new([u64::from( - *major, - )])), - }) + if is_abi3 { + MarkerTree::expression(MarkerExpression::Version { + key: uv_pep508::MarkerValueVersion::PythonVersion, + specifier: VersionSpecifier::greater_than_equal_version(Version::new([ + u64::from(*major), + ])), + }) + } else { + MarkerTree::expression(MarkerExpression::Version { + key: uv_pep508::MarkerValueVersion::PythonVersion, + specifier: VersionSpecifier::equals_star_version(Version::new([ + u64::from(*major), + ])), + }) + } } LanguageTag::Python { major, @@ -956,13 +969,25 @@ fn implied_python_markers(filename: &WheelFilename) -> MarkerTree { } | LanguageTag::Pyston { python_version: (major, minor), - } => MarkerTree::expression(MarkerExpression::Version { - key: uv_pep508::MarkerValueVersion::PythonVersion, - specifier: VersionSpecifier::equals_star_version(Version::new([ - u64::from(*major), - u64::from(*minor), - ])), - }), + } => { + if is_abi3 { + MarkerTree::expression(MarkerExpression::Version { + key: uv_pep508::MarkerValueVersion::PythonVersion, + specifier: VersionSpecifier::greater_than_equal_version(Version::new([ + u64::from(*major), + u64::from(*minor), + ])), + }) + } else { + MarkerTree::expression(MarkerExpression::Version { + key: uv_pep508::MarkerValueVersion::PythonVersion, + specifier: VersionSpecifier::equals_star_version(Version::new([ + u64::from(*major), + u64::from(*minor), + ])), + }) + } + } }; // Then, add implementation markers for implementation-specific tags @@ -1123,6 +1148,20 @@ mod tests { "example-1.0-py311.py312-none-any.whl", "python_full_version >= '3.11' and python_full_version < '3.13'", ); + + // abi3 wheels: the python tag represents a minimum version, not an exact version. + assert_python_markers( + "example-1.0-cp39-abi3-any.whl", + "python_full_version >= '3.9' and platform_python_implementation == 'CPython'", + ); + assert_python_markers( + "example-1.0-cp312-abi3-any.whl", + "python_full_version >= '3.12' and platform_python_implementation == 'CPython'", + ); + assert_python_markers( + "example-1.0-cp3-abi3-any.whl", + "python_full_version >= '3' and platform_python_implementation == 'CPython'", + ); } #[test] @@ -1147,5 +1186,11 @@ mod tests { "example-1.0-py3-none-any.whl", "python_full_version >= '3' and python_full_version < '4'", ); + + // abi3 wheel: cp39-abi3 means CPython >= 3.9, combined with platform markers. + assert_implied_markers( + "example-1.0-cp39-abi3-manylinux_2_28_x86_64.whl", + "python_full_version >= '3.9' and platform_python_implementation == 'CPython' and sys_platform == 'linux' and platform_machine == 'x86_64'", + ); } } diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 7b50e8a307282..9e0d1e3a1127f 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -34129,6 +34129,121 @@ fn lock_supported_environment_wheel_only_package_requires_compatible_wheels() -> Ok(()) } +/// An abi3 wheel (e.g., `cp37-abi3`) should be considered compatible with any Python version at or +/// above the tag's version. When `tool.uv.environments` constrains to a specific Python version +/// (e.g., 3.12), the resolver should recognize that a `cp37-abi3` wheel covers that environment +/// rather than treating the `cp37` tag as an exact Python 3.7 requirement. +#[test] +fn lock_supported_environment_abi3_wheel() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + // Create a local flat index with the abi3 test wheel. + let index_dir = context.temp_dir.child("local_index"); + fs_err::create_dir_all(&index_dir)?; + + for entry in fs_err::read_dir(context.workspace_root.join("test/links"))? { + let entry = entry?; + let path = entry.path(); + if path + .file_name() + .and_then(|file_name| file_name.to_str()) + .is_some_and(|file_name| file_name.starts_with("abi3_package")) + { + let dest = index_dir.join(path.file_name().unwrap()); + fs_err::copy(&path, &dest)?; + } + } + + let project = context.temp_dir.child("project"); + fs_err::create_dir_all(&project)?; + + let pyproject_toml = project.child("pyproject.toml"); + pyproject_toml.write_str(&formatdoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["abi3-package"] + + [tool.uv] + environments = [ + "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'", + ] + + [[tool.uv.index]] + name = "local" + url = "{}" + format = "flat" + "#, + index_dir.portable_display() + })?; + + uv_snapshot!(context.filters(), context.lock().current_dir(&project), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 2 packages in [TIME] + "); + + let lock = fs_err::read_to_string(project.join("uv.lock"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + resolution-markers = [ + "python_full_version < '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'", + ] + supported-markers = [ + "python_full_version < '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'", + ] + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "abi3-package" + version = "1.0.0" + source = { registry = "[TEMP_DIR]/local_index" } + wheels = [ + { path = "[TEMP_DIR]/local_index/abi3_package-1.0.0-cp37-abi3-manylinux_2_17_x86_64.whl" }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "abi3-package", marker = "python_full_version < '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'" }, + ] + + [package.metadata] + requires-dist = [{ name = "abi3-package" }] + "# + ); + }); + + // Re-run with `--locked`. + uv_snapshot!(context.filters(), context.lock().arg("--locked").current_dir(&project), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 2 packages in [TIME] + "); + + Ok(()) +} + /// If an index is filtered out (e.g., it's the second `default = true` index defined in the file), /// we should still consider the lockfile valid if it's referenced by name, regardless of whether /// it's defined in a dependency group or the top-level `project.dependencies` field.