Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/pixi_build_backend/src/specs_conversion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ pub fn from_targets_v1_to_conditional_requirements(targets: &Targets) -> Conditi
host: host_items,
run: run_items,
run_constraints: run_constraints_items,
..Default::default()
}
}

Expand Down
4 changes: 3 additions & 1 deletion crates/pixi_build_python/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ pub struct PythonBackendConfig {
#[serde(default)]
pub ignore_pypi_mapping: Option<bool>,
/// 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<bool>,
Expand Down
232 changes: 159 additions & 73 deletions crates/pixi_build_python/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ use pixi_build_backend::{
traits::ProjectModel,
};
use pyproject_toml::PyProjectToml;
use rattler_conda_types::{ChannelUrl, Platform, Version, VersionBumpType, package::EntryPoint};
use rattler_conda_types::{ChannelUrl, PackageName, Platform, Version, package::EntryPoint};
use recipe_stage0::matchspec::PackageDependency;
use recipe_stage0::recipe::{Item, NoArchKind, Python, Script};
use recipe_stage0::recipe::{Item, NoArchKind, Python, Script, Value};
use std::collections::HashSet;
use std::{
collections::{BTreeMap, BTreeSet},
Expand All @@ -32,16 +32,16 @@ use crate::pypi_mapping::{
map_requirements_with_channels,
};

/// 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<String> {
/// - `">=3.9"` → `"3.9.*"`
/// - `">=3.9.3"` → `"3.9.*"`
/// - `">=3.11,<4"` → `"3.11.*"`
/// - `None` → `"3.8.*"` (default)
fn python_abi3_spec_from_requires_python(requires_python: Option<&str>) -> miette::Result<String> {
let lower_bound = requires_python
.and_then(|s| {
let specifiers = pep440_rs::VersionSpecifiers::from_str(s).ok()?;
Expand All @@ -50,27 +50,34 @@ 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"));

// 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}.*"))
}

Ok(format!(">={lower_bound},<{upper_bound}"))
fn requirement_contains_package(
requirements: &[Item<PackageDependency>],
package_name: &str,
) -> bool {
requirements.iter().any(|item| match item {
Item::Value(Value::Concrete(dep)) => dep.package_name().as_normalized() == package_name,
Item::Value(Value::Template(spec)) => spec.split_whitespace().next() == Some(package_name),
Item::Conditional(cond) => cond
.then
.0
.iter()
.chain(cond.else_value.0.iter())
.any(|dep| dep.package_name().as_normalized() == package_name),
})
}

#[derive(Default, Clone)]
Expand Down Expand Up @@ -227,13 +234,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: Item<PackageDependency> =
format!("python_abi {abi_spec}").parse().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: Item<PackageDependency> = format!("python-abi3 {abi3_spec}")
.parse()
.into_diagnostic()?;
requirements.host.push(python_abi3_req);
}

let python_package = PackageName::from_str("python").into_diagnostic()?;
if !requirements
.ignore_run_exports
.from_package
.contains(&python_package)
{
requirements
.ignore_run_exports
.from_package
.push(python_package);
}
}

// Use NoArch platform for mapping if this is a noarch package
Expand Down Expand Up @@ -1094,42 +1118,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",
Expand Down Expand Up @@ -1185,20 +1175,35 @@ 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.build.python.version_independent,
"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(|name| name.as_normalized() == "python"),
"ignore_run_exports.from_package should contain python when abi3=true, got: {ignored_packages:?}"
);

let recipe_yaml = generated_recipe.recipe.to_yaml_pretty().unwrap();
assert!(
recipe_yaml.contains("ignore_run_exports:"),
"serialized recipe should include ignore_run_exports when abi3=true, got:\n{recipe_yaml}"
);
}

#[tokio::test]
Expand Down Expand Up @@ -1236,15 +1241,96 @@ 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.8.*"),
"host deps should contain python-abi3 3.8.* when abi3=true, got: {host_deps:?}"
);
assert!(
!host_deps.iter().any(|d| d.contains("python_abi")),
"host deps should not contain python_abi 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}"
generated_recipe
.recipe
.requirements
.ignore_run_exports
.from_package
.iter()
.any(|name| name.as_normalized() == "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,
)
.await
.expect("Failed to generate recipe");

let host_deps: Vec<String> = 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:?}"
);
}

Expand Down
55 changes: 53 additions & 2 deletions crates/recipe_stage0/src/marked_yaml.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ use marked_yaml::{Node as MarkedNode, Span};
pub type MappingHash = LinkedHashMap<MarkedScalarNode, MarkedNode>;

use crate::recipe::{
About, Build, Conditional, ConditionalList, ConditionalRequirements, Extra, IntermediateRecipe,
Item, ListOrItem, Package, PackageContents, Source, Test, Value,
About, Build, Conditional, ConditionalList, ConditionalRequirements, Extra, IgnoreRunExports,
IntermediateRecipe, Item, ListOrItem, Package, PackageContents, Source, Test, Value,
};

// Trait for converting to marked YAML nodes
Expand Down Expand Up @@ -176,6 +176,57 @@ impl ToMarkedYaml for ConditionalRequirements {
);
}

if !self.ignore_run_exports.is_empty() {
mapping.insert(
MarkedScalarNode::new(Span::new_blank(), "ignore_run_exports"),
self.ignore_run_exports.to_marked_yaml(),
);
}

MarkedNode::Mapping(MarkedMappingNode::new(Span::new_blank(), mapping))
}
}

impl ToMarkedYaml for IgnoreRunExports {
fn to_marked_yaml(&self) -> MarkedNode {
let mut mapping = MappingHash::new();

if !self.by_name.is_empty() {
mapping.insert(
MarkedScalarNode::new(Span::new_blank(), "by_name"),
MarkedNode::Sequence(MarkedSequenceNode::new(
Span::new_blank(),
self.by_name
.iter()
.map(|name| {
MarkedNode::Scalar(MarkedScalarNode::new(
Span::new_blank(),
name.as_normalized().to_string(),
))
})
.collect(),
)),
);
}

if !self.from_package.is_empty() {
mapping.insert(
MarkedScalarNode::new(Span::new_blank(), "from_package"),
MarkedNode::Sequence(MarkedSequenceNode::new(
Span::new_blank(),
self.from_package
.iter()
.map(|name| {
MarkedNode::Scalar(MarkedScalarNode::new(
Span::new_blank(),
name.as_normalized().to_string(),
))
})
.collect(),
)),
);
}

MarkedNode::Mapping(MarkedMappingNode::new(Span::new_blank(), mapping))
}
}
Expand Down
Loading
Loading