From 8fe68cf52d92de8d9c1b5e24094decca35747acd Mon Sep 17 00:00:00 2001 From: Martin Jansche Date: Wed, 1 Apr 2026 18:21:12 +0100 Subject: [PATCH] Support debug CPython ABI tags in environment compatibility (#18739) ## Summary This PR fixes a problem in `uv pip install`, which currently refuses to install debug wheels in virtual environments with debug CPythons. Before this change, wheel parsing already preserved debug ABI suffixes like cp313d and cp314d, but Tags::from_env only propagated free-threaded and legacy pymalloc variants. As a result, uv would detect a debug interpreter correctly during discovery while still generating cp313/cp314 environment tags, causing debug-built wheels to be rejected as incompatible. Fix this by accepting a debug_enabled flag in Tags::from_env, mapping it to CPythonAbiVariants::Debug, and passing the interpreter debug state from the production call sites in uv-python and uv pip resolution. Also update the affected tests and helpers, and add a regression test that verifies debug CPython 3.13 generates cp313-cp313d manylinux tags. ## Test Plan Tests run: - cargo test -p uv-platform-tags tags::tests::test_system_tags_debug_cpython -- --exact - cargo test -p uv-installer plan::tests::test_abi3_on_free_threaded_python_hint -- --exact - cargo test -p uv-installer plan::tests::test_gil_enabled_cpython_on_free_threaded_python_hint -- --exact - cargo test -p uv-installer plan::tests::test_abi3_on_regular_python_no_special_hint -- --exact - cargo test -p uv --test it pip_install::abi_compatibility_on_debug_python -- --exact --------- Co-authored-by: konstin --- crates/uv-bench/benches/uv.rs | 11 ++- crates/uv-installer/src/plan.rs | 29 +++--- crates/uv-platform-tags/src/lib.rs | 4 +- crates/uv-platform-tags/src/tags.rs | 125 ++++++++++++++++++++++---- crates/uv-python/src/interpreter.rs | 11 ++- crates/uv/src/commands/pip/mod.rs | 11 ++- crates/uv/tests/it/pip_install.rs | 133 ++++++++++++++++++++++++++++ 7 files changed, 284 insertions(+), 40 deletions(-) diff --git a/crates/uv-bench/benches/uv.rs b/crates/uv-bench/benches/uv.rs index d6ac82c3d..c49486a84 100644 --- a/crates/uv-bench/benches/uv.rs +++ b/crates/uv-bench/benches/uv.rs @@ -124,7 +124,7 @@ mod resolver { use uv_install_wheel::LinkMode; use uv_pep440::Version; use uv_pep508::{MarkerEnvironment, MarkerEnvironmentBuilder}; - use uv_platform_tags::{Arch, Os, Platform, Tags}; + use uv_platform_tags::{Arch, Os, Platform, Tags, TagsOptions}; use uv_preview::Preview; use uv_pypi_types::{Conflicts, ResolverMarkerEnvironment}; use uv_python::Interpreter; @@ -160,7 +160,14 @@ mod resolver { ); static TAGS: LazyLock = LazyLock::new(|| { - Tags::from_env(&PLATFORM, (3, 11), "cpython", (3, 11), false, false, false).unwrap() + Tags::from_env( + &PLATFORM, + (3, 11), + "cpython", + (3, 11), + TagsOptions::default(), + ) + .unwrap() }); pub(crate) async fn resolve( diff --git a/crates/uv-installer/src/plan.rs b/crates/uv-installer/src/plan.rs index f628bf328..8514bcb52 100644 --- a/crates/uv-installer/src/plan.rs +++ b/crates/uv-installer/src/plan.rs @@ -787,7 +787,7 @@ impl Plan { mod tests { use super::*; use std::str::FromStr; - use uv_platform_tags::{Arch, Os, Platform}; + use uv_platform_tags::{Arch, Os, Platform, TagsOptions}; #[test] fn test_abi3_on_free_threaded_python_hint() { @@ -804,9 +804,12 @@ mod tests { (3, 14), // python_version "cpython", // implementation_name (3, 14), // implementation_version - true, // manylinux_compatible - true, // gil_disabled (free-threaded) - false, // is_cross + TagsOptions { + manylinux_compatible: true, + gil_disabled: true, + debug_enabled: false, + is_cross: false, + }, ) .unwrap(); @@ -836,9 +839,12 @@ mod tests { (3, 14), // python_version "cpython", // implementation_name (3, 14), // implementation_version - true, // manylinux_compatible - true, // gil_disabled (free-threaded) - false, // is_cross + TagsOptions { + manylinux_compatible: true, + gil_disabled: true, + debug_enabled: false, + is_cross: false, + }, ) .unwrap(); @@ -868,9 +874,12 @@ mod tests { (3, 14), // python_version "cpython", // implementation_name (3, 14), // implementation_version - true, // manylinux_compatible - false, // gil_disabled (regular Python) - false, // is_cross + TagsOptions { + manylinux_compatible: true, + gil_disabled: false, + debug_enabled: false, + is_cross: false, + }, ) .unwrap(); diff --git a/crates/uv-platform-tags/src/lib.rs b/crates/uv-platform-tags/src/lib.rs index 0df8c7f62..5350310b9 100644 --- a/crates/uv-platform-tags/src/lib.rs +++ b/crates/uv-platform-tags/src/lib.rs @@ -2,7 +2,9 @@ pub use abi_tag::{AbiTag, CPythonAbiVariants, ParseAbiTagError}; pub use language_tag::{LanguageTag, ParseLanguageTagError}; pub use platform::{Arch, Os, Platform, PlatformError}; pub use platform_tag::{ParsePlatformTagError, PlatformTag}; -pub use tags::{BinaryFormat, IncompatibleTag, TagCompatibility, TagPriority, Tags, TagsError}; +pub use tags::{ + BinaryFormat, IncompatibleTag, TagCompatibility, TagPriority, Tags, TagsError, TagsOptions, +}; mod abi_tag; mod language_tag; diff --git a/crates/uv-platform-tags/src/tags.rs b/crates/uv-platform-tags/src/tags.rs index b0679a8eb..532d5aaed 100644 --- a/crates/uv-platform-tags/src/tags.rs +++ b/crates/uv-platform-tags/src/tags.rs @@ -23,6 +23,16 @@ pub enum TagsError { InvalidPriority(usize, #[source] std::num::TryFromIntError), #[error("Only CPython can be freethreading, not: {0}")] GilIsACPythonProblem(String), + #[error("Only CPython can be debug-enabled, not: {0}")] + DebugIsACPythonProblem(String), +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct TagsOptions { + pub manylinux_compatible: bool, + pub gil_disabled: bool, + pub debug_enabled: bool, + pub is_cross: bool, } #[derive(Debug, Eq, Ord, PartialEq, PartialOrd, Copy, Clone)] @@ -41,6 +51,10 @@ pub enum IncompatibleTag { Platform, } +/// Whether a wheel is compatible or incompatible with a set of tags, and which priority it has +/// compared to other wheels. +/// +/// A higher tag compatibility means higher priority. #[derive(Debug, Eq, PartialEq, Copy, Clone)] pub enum TagCompatibility { Incompatible(IncompatibleTag), @@ -136,12 +150,10 @@ impl Tags { python_version: (u8, u8), implementation_name: &str, implementation_version: (u8, u8), - manylinux_compatible: bool, - gil_disabled: bool, - is_cross: bool, + options: TagsOptions, ) -> Result { let mut variant = CPythonAbiVariants::default(); - if gil_disabled { + if options.gil_disabled { if implementation_name != "cpython" { return Err(TagsError::GilIsACPythonProblem( implementation_name.to_string(), @@ -149,6 +161,14 @@ impl Tags { } variant.insert(CPythonAbiVariants::Freethreading); } + if options.debug_enabled { + if implementation_name != "cpython" { + return Err(TagsError::DebugIsACPythonProblem( + implementation_name.to_string(), + )); + } + variant.insert(CPythonAbiVariants::Debug); + } // Sufficiently correct assumption, pre-3.8 Pythons were generally built with pymalloc. // https://docs.python.org/dev/whatsnew/3.8.html#build-and-c-api-changes // > the m flag for pymalloc became useless (builds with and without pymalloc are ABI @@ -162,7 +182,7 @@ impl Tags { // Determine the compatible tags for the current platform. let platform_tags = { let mut platform_tags = compatible_tags(platform)?; - if matches!(platform.os(), Os::Manylinux { .. }) && !manylinux_compatible { + if matches!(platform.os(), Os::Manylinux { .. }) && !options.manylinux_compatible { platform_tags.retain(|tag| !tag.is_manylinux()); } platform_tags @@ -178,6 +198,26 @@ impl Tags { platform_tag.clone(), )); } + // 1a. For CPython 3.8+, debug builds are ABI-compatible with release builds, so a debug + // interpreter also accept non-debug wheels. + if python_version >= (3, 8) + && let Implementation::CPython { variant } = implementation + && variant.contains(CPythonAbiVariants::Debug) + { + let mut non_debug_variant = variant; + non_debug_variant.remove(CPythonAbiVariants::Debug); + let debug_abi = AbiTag::CPython { + variant: non_debug_variant, + python_version, + }; + for platform_tag in &platform_tags { + tags.push(( + implementation.language_tag(python_version), + debug_abi, + platform_tag.clone(), + )); + } + } // 2. abi3/abi3t and no abi (e.g. executable binary) if let Implementation::CPython { variant } = implementation { // Emit `abi3t` everywhere we'd emit `abi3` for non-free-threaded builds. @@ -270,8 +310,8 @@ impl Tags { tags, platform.clone(), python_version, - is_cross, - gil_disabled, + options.is_cross, + options.gil_disabled, )) } @@ -1546,9 +1586,7 @@ mod tests { (3, 9), "cpython", (3, 9), - false, - false, - false, + TagsOptions::default(), ) .unwrap(); assert_snapshot!( @@ -1610,9 +1648,10 @@ mod tests { (3, 9), "cpython", (3, 9), - true, - false, - false, + TagsOptions { + manylinux_compatible: true, + ..TagsOptions::default() + }, ) .unwrap(); assert_snapshot!( @@ -2235,9 +2274,7 @@ mod tests { (3, 9), "cpython", (3, 9), - false, - false, - false, + TagsOptions::default(), ) .unwrap(); assert_snapshot!( @@ -2713,9 +2750,11 @@ mod tests { (3, 15), "cpython", (3, 15), - false, - true, - false, + TagsOptions { + manylinux_compatible: false, + gil_disabled: true, + ..TagsOptions::default() + }, ) .unwrap(); @@ -2776,4 +2815,52 @@ mod tests { " ); } + + #[test] + fn test_system_tags_debug_cpython() { + fn debug_compatibilities(debug_enabled: bool) -> (TagCompatibility, TagCompatibility) { + let tags = Tags::from_env( + &Platform::new( + Os::Manylinux { + major: 2, + minor: 28, + }, + Arch::X86_64, + ), + (3, 14), + "cpython", + (3, 14), + TagsOptions { + manylinux_compatible: true, + debug_enabled, + ..TagsOptions::default() + }, + ) + .unwrap(); + + let debug_compatibility = tags.compatibility( + &[LanguageTag::from_str("cp314").unwrap()], + &[AbiTag::from_str("cp314d").unwrap()], + &[PlatformTag::from_str("manylinux_2_28_x86_64").unwrap()], + ); + let non_debug_compatibility = tags.compatibility( + &[LanguageTag::from_str("cp314").unwrap()], + &[AbiTag::from_str("cp314").unwrap()], + &[PlatformTag::from_str("manylinux_2_28_x86_64").unwrap()], + ); + (debug_compatibility, non_debug_compatibility) + } + + // A regular CPython build is not compatible with debug wheels. + let (debug_compatibility, non_debug_compatibility) = debug_compatibilities(false); + assert!(!debug_compatibility.is_compatible()); + assert!(non_debug_compatibility.is_compatible()); + + // A debug CPython build is compatible with debug and non-debug wheels, preferring debug + // wheels. + let (debug_compatibility, non_debug_compatibility) = debug_compatibilities(true); + assert!(debug_compatibility.is_compatible()); + assert!(non_debug_compatibility.is_compatible()); + assert!(debug_compatibility > non_debug_compatibility); + } } diff --git a/crates/uv-python/src/interpreter.rs b/crates/uv-python/src/interpreter.rs index 94d79dd9d..251b7620d 100644 --- a/crates/uv-python/src/interpreter.rs +++ b/crates/uv-python/src/interpreter.rs @@ -25,7 +25,7 @@ use uv_install_wheel::Layout; use uv_pep440::Version; use uv_pep508::{MarkerEnvironment, StringVersion}; use uv_platform::{Arch, Libc, Os}; -use uv_platform_tags::{Platform, Tags, TagsError}; +use uv_platform_tags::{Platform, Tags, TagsError, TagsOptions}; use uv_pypi_types::{ResolverMarkerEnvironment, Scheme}; use crate::implementation::LenientImplementationName; @@ -253,9 +253,12 @@ impl Interpreter { self.python_tuple(), self.implementation_name(), self.implementation_tuple(), - self.manylinux_compatible, - self.gil_disabled, - false, + TagsOptions { + manylinux_compatible: self.manylinux_compatible, + gil_disabled: self.gil_disabled, + debug_enabled: self.debug_enabled, + is_cross: false, + }, )?; self.tags.set(tags).expect("tags should not be set"); } diff --git a/crates/uv/src/commands/pip/mod.rs b/crates/uv/src/commands/pip/mod.rs index 5b23a073c..4489a6c68 100644 --- a/crates/uv/src/commands/pip/mod.rs +++ b/crates/uv/src/commands/pip/mod.rs @@ -1,7 +1,7 @@ use std::borrow::Cow; use uv_configuration::TargetTriple; -use uv_platform_tags::{Tags, TagsError}; +use uv_platform_tags::{Tags, TagsError, TagsOptions}; use uv_pypi_types::ResolverMarkerEnvironment; use uv_python::{Interpreter, PythonVersion}; @@ -66,9 +66,12 @@ pub(crate) fn resolution_tags<'env>( version_tuple, interpreter.implementation_name(), interpreter.implementation_tuple(), - manylinux_compatible, - interpreter.gil_disabled(), - true, + TagsOptions { + manylinux_compatible, + gil_disabled: interpreter.gil_disabled(), + debug_enabled: interpreter.debug_enabled(), + is_cross: true, + }, )?; Ok(Cow::Owned(tags)) } diff --git a/crates/uv/tests/it/pip_install.rs b/crates/uv/tests/it/pip_install.rs index 31cc8af78..25c845b48 100644 --- a/crates/uv/tests/it/pip_install.rs +++ b/crates/uv/tests/it/pip_install.rs @@ -1,4 +1,5 @@ use std::io::Cursor; +use std::path::PathBuf; use std::process::Command; use anyhow::Result; @@ -14435,6 +14436,138 @@ fn abi_compatibility_on_freethreaded_python() { "); } +fn build_debug_wheel(context: &TestContext) -> PathBuf { + // Build a wheel with debug ABI tag (cp314d). + let package_dir = context.temp_dir.child("cpython_debug_package"); + package_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "cpython-debug-package" + version = "1.0.0" + + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + + [tool.hatch.build.hooks.custom] + "#}) + .unwrap(); + package_dir + .child("hatch_build.py") + .write_str(indoc! {r#" + from hatchling.builders.hooks.plugin.interface import BuildHookInterface + + class CustomBuildHook(BuildHookInterface): + def initialize(self, version, build_data): + build_data["tag"] = "cp314-cp314d-manylinux_2_17_x86_64" + build_data["pure_python"] = False + "#}) + .unwrap(); + package_dir + .child("src/cpython_debug_package/__init__.py") + .write_str("# Test package") + .unwrap(); + + context + .build() + .arg("--wheel") + .current_dir(&package_dir) + .assert() + .success(); + + package_dir.join("dist/cpython_debug_package-1.0.0-cp314-cp314d-manylinux_2_17_x86_64.whl") +} + +/// Since Python 3.8, a debug interpreter accepts both debug (`cp314d`) and non-debug (`cp314`) +/// wheels. +#[test] +#[cfg(feature = "test-python-managed")] +#[cfg(any(target_os = "macos", target_os = "linux"))] // PBS doesn't have debug builds for windows +fn abi_compatibility_on_debug_python() { + let context = uv_test::test_context_with_versions!(&[]) + .with_filtered_python_keys() + .with_managed_python_dirs() + .with_python_download_cache() + .with_filtered_python_install_bin() + .with_filtered_python_names() + .with_filtered_exe_suffix(); + + // Install debug CPython 3.14. + context + .python_install() + .arg("--preview") + .arg("3.14+debug") + .assert() + .success(); + + // Create a virtual environment with the debug Python. + context + .venv() + .arg("--python") + .arg("3.14+debug") + .assert() + .success(); + + // Check that non-debug wheels are supported. + let non_debug_wheel = context + .workspace_root + .join("test/links/cpython_package-1.0.0-cp314-cp314-manylinux_2_17_x86_64.whl"); + uv_snapshot!(context.filters(), context.pip_install() + .arg("--python-platform").arg("linux") + .arg(non_debug_wheel), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + cpython-package==1.0.0 (from file://[WORKSPACE]/test/links/cpython_package-1.0.0-cp314-cp314-manylinux_2_17_x86_64.whl) + "); + + // Check that debug wheels are supported. + let debug_wheel = build_debug_wheel(&context); + uv_snapshot!(context.filters(), context.pip_install() + .arg("--python-platform").arg("linux") + .arg(debug_wheel), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + cpython-debug-package==1.0.0 (from file://[TEMP_DIR]/cpython_debug_package/dist/cpython_debug_package-1.0.0-cp314-cp314d-manylinux_2_17_x86_64.whl) + "); +} + +/// Non-debug CPython cannot install wheels tagged `cp314d` — matching pip's behavior where only +/// the debug interpreter adds the non-debug ABI as a fallback, not vice versa. +#[test] +fn abi_compatibility_on_nondebug_python_with_debug_wheel() { + let context = uv_test::test_context!("3.14"); + + // Check that debug wheels are rejected with a helpful error message. + let debug_wheel = build_debug_wheel(&context); + uv_snapshot!(context.filters(), context.pip_install() + .arg("--python-platform").arg("linux") + .arg(debug_wheel), @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + error: Failed to determine installation plan + Caused by: A path dependency is incompatible with the current platform: cpython_debug_package/dist/cpython_debug_package-1.0.0-cp314-cp314d-manylinux_2_17_x86_64.whl + + hint: The wheel is compatible with CPython 3.14 (`cp314d`), but you're using CPython 3.14 (`cp314`) + "); +} + #[test] fn warn_on_bz2_wheel() { let context = uv_test::test_context!("3.14");