diff --git a/crates/pixi_build_python/src/config.rs b/crates/pixi_build_python/src/config.rs index 3da5565189..53cee271e2 100644 --- a/crates/pixi_build_python/src/config.rs +++ b/crates/pixi_build_python/src/config.rs @@ -64,7 +64,9 @@ pub struct PythonBackendConfig { #[serde(default)] pub ignore_pypi_mapping: Option, /// Whether the package uses the Python Stable ABI (abi3). - /// When true, adds `python_abi` to host requirements. + /// When true, marks the package as version-independent, adds `python-abi3` + /// to the host requirements, and suppresses CPython ABI run exports from + /// `host: python`. /// Only meaningful for packages with compiled extensions (non-noarch). #[serde(default)] pub abi3: Option, diff --git a/crates/pixi_build_python/src/main.rs b/crates/pixi_build_python/src/main.rs index 533679bae7..a895775a3c 100644 --- a/crates/pixi_build_python/src/main.rs +++ b/crates/pixi_build_python/src/main.rs @@ -21,7 +21,7 @@ use rattler_build_recipe::stage0::{ ConditionalList, Item, PythonBuild, Script, SerializableMatchSpec, Value, }; use rattler_conda_types::{ - ChannelUrl, NoArchType, Platform, Version, VersionBumpType, package::EntryPoint, + ChannelUrl, NoArchType, PackageName, Platform, Version, package::EntryPoint, }; use std::collections::HashSet; use std::{ @@ -39,16 +39,16 @@ use crate::pypi_mapping::{ const CYTHON_INPUT_GLOBS: &[&str] = &["**/*.{pyx,pxd,pxi}"]; -/// Compute the `python_abi` version spec from an optional `requires-python` +/// Compute the `python-abi3` version spec from an optional `requires-python` /// specifier string. /// /// Extracts the lower bound (first `>=` specifier) and pins it to a single /// minor version: -/// - `">=3.9"` → `">=3.9,<3.10.0a0"` -/// - `">=3.9.3"` → `">=3.9.3,<3.10.0a0"` -/// - `">=3.11,<4"` → `">=3.11,<3.12.0a0"` -/// - `None` → `">=3.8,<3.9.0a0"` (default) -fn python_abi_spec_from_requires_python(requires_python: Option<&str>) -> miette::Result { +/// - `">=3.9"` → `"3.9.*"` +/// - `">=3.9.3"` → `"3.9.*"` +/// - `">=3.11,<4"` → `"3.11.*"` +/// - `None` → `"3.9.*"` (default) +fn python_abi3_spec_from_requires_python(requires_python: Option<&str>) -> miette::Result { let lower_bound = requires_python .and_then(|s| { let specifiers = pep440_rs::VersionSpecifiers::from_str(s).ok()?; @@ -57,27 +57,65 @@ fn python_abi_spec_from_requires_python(requires_python: Option<&str>) -> miette .find(|spec| *spec.operator() == pep440_rs::Operator::GreaterThanEqual) .map(|spec| { let pep_version = spec.version(); - // Convert pep440 version to rattler Version via string round-trip Version::from_str(&pep_version.to_string()) .expect("pep440 version should be a valid conda version") }) }) - .unwrap_or_else(|| Version::from_str("3.8").expect("valid version")); + .unwrap_or_else(|| Version::from_str("3.9").expect("valid version")); - // Truncate to major.minor for the upper bound computation + let segment_count = std::cmp::min(lower_bound.segment_count(), 2); let major_minor = lower_bound - .clone() - .with_segments(..std::cmp::min(lower_bound.segment_count(), 2)) + .with_segments(..segment_count) .ok_or_else(|| miette::miette!("failed to truncate version to major.minor"))?; - let upper_bound = major_minor - .bump(VersionBumpType::Minor) - .into_diagnostic()? - .with_alpha() - .remove_local() - .into_owned(); + Ok(format!("{major_minor}.*")) +} + +fn requirement_contains_package( + requirements: &ConditionalList, + package_name: &str, +) -> bool { + requirements + .iter() + .any(|item| requirement_item_contains_package(item, package_name)) +} - Ok(format!(">={lower_bound},<{upper_bound}")) +fn requirement_item_contains_package( + item: &Item, + package_name: &str, +) -> bool { + match item { + Item::Value(value) => { + value + .as_concrete() + .and_then(|spec| spec.0.name.as_exact()) + .is_some_and(|name| name.as_normalized() == package_name) + || value.to_string().split_whitespace().next() == Some(package_name) + } + Item::Conditional(cond) => cond + .then + .iter() + .chain(cond.else_value.iter().flat_map(|items| items.iter())) + .any(|item| requirement_item_contains_package(item, package_name)), + } +} + +fn package_name_item_contains_package(item: &Item, package_name: &str) -> bool { + match item { + Item::Value(value) => { + value + .as_concrete() + .is_some_and(|name| name.as_normalized() == package_name) + || value + .as_template() + .is_some_and(|template| template.as_str() == package_name) + } + Item::Conditional(cond) => cond + .then + .iter() + .chain(cond.else_value.iter().flat_map(|items| items.iter())) + .any(|item| package_name_item_contains_package(item, package_name)), + } } /// Parse a string into an `Item` for use in requirements. @@ -260,13 +298,30 @@ impl GenerateRecipe for PythonGenerator { ); } - // Add python_abi host dependency when abi3 is enabled + // ABI3 packages should not inherit CPython ABI pins from `host: python`. if config.abi3 == Some(true) { - let requires_python_str = pyproject_metadata_provider.requires_python().ok().flatten(); - let abi_spec = python_abi_spec_from_requires_python(requires_python_str.as_deref())?; - let python_abi_req = - matchspec_item(&format!("python_abi {abi_spec}")).into_diagnostic()?; - requirements.host.push(python_abi_req); + if !requirement_contains_package(&requirements.host, "python-abi3") { + let requires_python_str = + pyproject_metadata_provider.requires_python().ok().flatten(); + let abi3_spec = + python_abi3_spec_from_requires_python(requires_python_str.as_deref())?; + let python_abi3_req = + matchspec_item(&format!("python-abi3 {abi3_spec}")).into_diagnostic()?; + requirements.host.push(python_abi3_req); + } + + let python_package = PackageName::from_str("python").into_diagnostic()?; + if !requirements + .ignore_run_exports + .from_package + .iter() + .any(|item| package_name_item_contains_package(item, "python")) + { + requirements + .ignore_run_exports + .from_package + .push(Item::Value(Value::new_concrete(python_package, None))); + } } // Use NoArch platform for mapping if this is a noarch package @@ -1287,42 +1342,8 @@ build-backend = "hatchling.build" ); } - #[test] - fn test_python_abi_spec_from_requires_python() { - // Basic lower bound - assert_eq!( - python_abi_spec_from_requires_python(Some(">=3.9")).unwrap(), - ">=3.9,<3.10.0a0" - ); - // With patch version - assert_eq!( - python_abi_spec_from_requires_python(Some(">=3.9.3")).unwrap(), - ">=3.9.3,<3.10.0a0" - ); - // Multiple specifiers - uses the >= bound - assert_eq!( - python_abi_spec_from_requires_python(Some(">=3.11,<4")).unwrap(), - ">=3.11,<3.12.0a0" - ); - // 3.8 lower bound - assert_eq!( - python_abi_spec_from_requires_python(Some(">=3.8")).unwrap(), - ">=3.8,<3.9.0a0" - ); - // None defaults to 3.8 - assert_eq!( - python_abi_spec_from_requires_python(None).unwrap(), - ">=3.8,<3.9.0a0" - ); - // Extra segments are preserved in lower bound but upper bound still pins to major.minor - assert_eq!( - python_abi_spec_from_requires_python(Some(">=3.9.3.4")).unwrap(), - ">=3.9.3.4,<3.10.0a0" - ); - } - #[tokio::test] - async fn test_abi3_adds_python_abi_to_host() { + async fn test_abi3_marks_recipe_version_independent_and_ignores_python_run_exports() { let project_model = project_fixture!({ "name": "foobar", "version": "0.1.0", @@ -1381,16 +1402,13 @@ build-backend = "setuptools.build_meta" .collect(); assert!( - host_deps.iter().any(|d| d.contains("python_abi")), - "host deps should contain python_abi when abi3=true, got: {host_deps:?}" + host_deps.iter().any(|d| d == "python-abi3 3.9.*"), + "host deps should contain python-abi3 3.9.* when abi3=true, got: {host_deps:?}" ); - // Check the version spec - let abi_dep = host_deps.iter().find(|d| d.contains("python_abi")).unwrap(); assert!( - abi_dep.contains(">=3.9") && abi_dep.contains("<3.10.0a0"), - "python_abi should have >=3.9,<3.10.0a0 spec, got: {abi_dep}" + !host_deps.iter().any(|d| d.contains("python_abi")), + "host deps should not contain python_abi when abi3=true, got: {host_deps:?}" ); - // Check version_independent is set assert!( generated_recipe .recipe @@ -1403,6 +1421,24 @@ build-backend = "setuptools.build_meta" == Some(true), "version_independent should be true when abi3=true" ); + + let ignored_packages = &generated_recipe + .recipe + .requirements + .ignore_run_exports + .from_package; + assert!( + ignored_packages + .iter() + .any(|item| package_name_item_contains_package(item, "python")), + "ignore_run_exports.from_package should contain python when abi3=true, got: {ignored_packages:?}" + ); + + let recipe_json = serde_json::to_string(&generated_recipe.recipe).unwrap(); + assert!( + recipe_json.contains("ignore_run_exports"), + "serialized recipe should include ignore_run_exports when abi3=true, got:\n{recipe_json}" + ); } #[tokio::test] @@ -1440,15 +1476,99 @@ build-backend = "setuptools.build_meta" .map(|item| item.to_string()) .collect(); - let abi_dep = host_deps.iter().find(|d| d.contains("python_abi")); assert!( - abi_dep.is_some(), - "host deps should contain python_abi, got: {host_deps:?}" + host_deps.iter().any(|d| d == "python-abi3 3.9.*"), + "host deps should contain python-abi3 3.9.* when abi3=true, got: {host_deps:?}" ); - let abi_dep = abi_dep.unwrap(); assert!( - abi_dep.contains(">=3.8") && abi_dep.contains("<3.9.0a0"), - "python_abi should default to >=3.8,<3.9.0a0, got: {abi_dep}" + !host_deps.iter().any(|d| d.contains("python_abi")), + "host deps should not contain python_abi when abi3=true, got: {host_deps:?}" + ); + assert!( + generated_recipe + .recipe + .requirements + .ignore_run_exports + .from_package + .iter() + .any(|item| package_name_item_contains_package(item, "python")), + "ignore_run_exports.from_package should contain python when abi3=true" + ); + } + + #[tokio::test] + async fn test_abi3_does_not_duplicate_explicit_python_abi3_dependency() { + let project_model = project_fixture!({ + "name": "foobar", + "version": "0.1.0", + "targets": { + "defaultTarget": { + "hostDependencies": { + "python-abi3": { + "binary": { + "version": "*" + } + } + } + } + } + }); + + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + fs::write( + temp_dir.path().join("pyproject.toml"), + r#"[project] +name = "foobar" +version = "0.1.0" +requires-python = ">=3.9" + +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" +"#, + ) + .await + .expect("Failed to write pyproject.toml"); + + let config = PythonBackendConfig { + abi3: Some(true), + noarch: Some(false), + compilers: Some(vec!["c".to_string()]), + ..Default::default() + }; + + let generated_recipe = PythonGenerator::default() + .generate_recipe( + &project_model, + &config, + temp_dir.path().to_path_buf(), + Platform::Linux64, + None, + &HashSet::new(), + vec![], + None, + None, + None, + None, + ) + .await + .expect("Failed to generate recipe"); + + let host_deps: Vec = generated_recipe + .recipe + .requirements + .host + .iter() + .map(|item| item.to_string()) + .collect(); + + assert_eq!( + host_deps + .iter() + .filter(|dep| dep.starts_with("python-abi3")) + .count(), + 1, + "host deps should contain exactly one python-abi3 entry when it is explicitly declared, got: {host_deps:?}" ); } diff --git a/docs/build/backends/pixi-build-python.md b/docs/build/backends/pixi-build-python.md index bca6456f33..43fd680bb6 100644 --- a/docs/build/backends/pixi-build-python.md +++ b/docs/build/backends/pixi-build-python.md @@ -199,9 +199,21 @@ compilers = ["c", "cxx"] - **Default**: `false` - **Target Merge Behavior**: `Overwrite` - Platform-specific setting takes precedence over base -Controls whether the package uses the [Python Stable ABI (abi3)](https://docs.python.org/3/c-api/stable.html). When set to `true`, a `python_abi` dependency is added to the host requirements with version bounds derived from `requires-python` in your `pyproject.toml`. +Controls whether the package uses the [Python Stable ABI (abi3)](https://docs.python.org/3/c-api/stable.html). When set to `true`, pixi: -The `python_abi` package has `run_exports` that automatically propagate the ABI constraint to the run environment, so only a host dependency is needed. +- marks the recipe as `build.python.version_independent: true` +- adds `python-abi3` to the host requirements +- suppresses the normal CPython ABI run exports from `host: python` + +This follows [CEP 20](https://github.com/conda/ceps/blob/main/cep-0020.md), which defines conda ecosystem support for `abi3` Python packages. + +The `python-abi3` version is derived from the lower bound of `requires-python`: + +- `requires-python = ">=3.9"` → `python-abi3 3.9.*` +- `requires-python = ">=3.11,<4"` → `python-abi3 3.11.*` +- If `requires-python` is not specified, defaults to `python-abi3 3.9.*`, the oldest available `python-abi3` package on conda-forge + +If `python-abi3` is already declared in your host requirements, pixi does not add a duplicate entry. ```toml [package.build.config] @@ -209,12 +221,6 @@ abi3 = true compilers = ["c"] ``` -The version bounds are computed from the lower bound of `requires-python`: - -- `requires-python = ">=3.9"` → `python_abi >=3.9,<3.10.0a0` -- `requires-python = ">=3.11,<4"` → `python_abi >=3.11,<3.12.0a0` -- If `requires-python` is not specified, defaults to `python_abi >=3.8,<3.9.0a0` - !!! warning "Incompatible with noarch" Setting `abi3 = true` with `noarch = true` will produce an error, since the stable ABI is only meaningful for packages with compiled extensions.