Skip to content

Commit 6f925d0

Browse files
committed
Fix abi3 run export handling for pixi-build-python
1 parent ccb7fca commit 6f925d0

7 files changed

Lines changed: 258 additions & 86 deletions

File tree

crates/pixi_build_backend/src/specs_conversion.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ pub fn from_targets_v1_to_conditional_requirements(targets: &Targets) -> Conditi
174174
host: host_items,
175175
run: run_items,
176176
run_constraints: run_constraints_items,
177+
..Default::default()
177178
}
178179
}
179180

crates/pixi_build_python/src/config.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ pub struct PythonBackendConfig {
3535
#[serde(default)]
3636
pub ignore_pypi_mapping: Option<bool>,
3737
/// Whether the package uses the Python Stable ABI (abi3).
38-
/// When true, adds `python_abi` to host requirements.
38+
/// When true, marks the package as version-independent, adds `python-abi3`
39+
/// to the host requirements, and suppresses CPython ABI run exports from
40+
/// `host: python`.
3941
/// Only meaningful for packages with compiled extensions (non-noarch).
4042
#[serde(default)]
4143
pub abi3: Option<bool>,

crates/pixi_build_python/src/main.rs

Lines changed: 149 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ use pixi_build_backend::{
1515
traits::ProjectModel,
1616
};
1717
use pyproject_toml::PyProjectToml;
18-
use rattler_conda_types::{ChannelUrl, Platform, Version, VersionBumpType, package::EntryPoint};
18+
use rattler_conda_types::{ChannelUrl, PackageName, Platform, Version, package::EntryPoint};
1919
use recipe_stage0::matchspec::PackageDependency;
20-
use recipe_stage0::recipe::{Item, NoArchKind, Python, Script};
20+
use recipe_stage0::recipe::{Item, NoArchKind, Python, Script, Value};
2121
use std::collections::HashSet;
2222
use std::{
2323
collections::{BTreeMap, BTreeSet},
@@ -32,16 +32,16 @@ use crate::pypi_mapping::{
3232
map_requirements_with_channels,
3333
};
3434

35-
/// Compute the `python_abi` version spec from an optional `requires-python`
35+
/// Compute the `python-abi3` version spec from an optional `requires-python`
3636
/// specifier string.
3737
///
3838
/// Extracts the lower bound (first `>=` specifier) and pins it to a single
3939
/// minor version:
40-
/// - `">=3.9"` → `">=3.9,<3.10.0a0"`
41-
/// - `">=3.9.3"` → `">=3.9.3,<3.10.0a0"`
42-
/// - `">=3.11,<4"` → `">=3.11,<3.12.0a0"`
43-
/// - `None` → `">=3.8,<3.9.0a0"` (default)
44-
fn python_abi_spec_from_requires_python(requires_python: Option<&str>) -> miette::Result<String> {
40+
/// - `">=3.9"` → `"3.9.*"`
41+
/// - `">=3.9.3"` → `"3.9.*"`
42+
/// - `">=3.11,<4"` → `"3.11.*"`
43+
/// - `None` → `"3.8.*"` (default)
44+
fn python_abi3_spec_from_requires_python(requires_python: Option<&str>) -> miette::Result<String> {
4545
let lower_bound = requires_python
4646
.and_then(|s| {
4747
let specifiers = pep440_rs::VersionSpecifiers::from_str(s).ok()?;
@@ -50,27 +50,34 @@ fn python_abi_spec_from_requires_python(requires_python: Option<&str>) -> miette
5050
.find(|spec| *spec.operator() == pep440_rs::Operator::GreaterThanEqual)
5151
.map(|spec| {
5252
let pep_version = spec.version();
53-
// Convert pep440 version to rattler Version via string round-trip
5453
Version::from_str(&pep_version.to_string())
5554
.expect("pep440 version should be a valid conda version")
5655
})
5756
})
5857
.unwrap_or_else(|| Version::from_str("3.8").expect("valid version"));
5958

60-
// Truncate to major.minor for the upper bound computation
59+
let segment_count = std::cmp::min(lower_bound.segment_count(), 2);
6160
let major_minor = lower_bound
62-
.clone()
63-
.with_segments(..std::cmp::min(lower_bound.segment_count(), 2))
61+
.with_segments(..segment_count)
6462
.ok_or_else(|| miette::miette!("failed to truncate version to major.minor"))?;
6563

66-
let upper_bound = major_minor
67-
.bump(VersionBumpType::Minor)
68-
.into_diagnostic()?
69-
.with_alpha()
70-
.remove_local()
71-
.into_owned();
64+
Ok(format!("{major_minor}.*"))
65+
}
7266

73-
Ok(format!(">={lower_bound},<{upper_bound}"))
67+
fn requirement_contains_package(
68+
requirements: &[Item<PackageDependency>],
69+
package_name: &str,
70+
) -> bool {
71+
requirements.iter().any(|item| match item {
72+
Item::Value(Value::Concrete(dep)) => dep.package_name().as_normalized() == package_name,
73+
Item::Value(Value::Template(spec)) => spec.split_whitespace().next() == Some(package_name),
74+
Item::Conditional(cond) => cond
75+
.then
76+
.0
77+
.iter()
78+
.chain(cond.else_value.0.iter())
79+
.any(|dep| dep.package_name().as_normalized() == package_name),
80+
})
7481
}
7582

7683
#[derive(Default, Clone)]
@@ -227,13 +234,24 @@ impl GenerateRecipe for PythonGenerator {
227234
);
228235
}
229236

230-
// Add python_abi host dependency when abi3 is enabled
237+
// ABI3 packages should not inherit CPython ABI pins from `host: python`.
231238
if config.abi3 == Some(true) {
232-
let requires_python_str = pyproject_metadata_provider.requires_python().ok().flatten();
233-
let abi_spec = python_abi_spec_from_requires_python(requires_python_str.as_deref())?;
234-
let python_abi_req: Item<PackageDependency> =
235-
format!("python_abi {abi_spec}").parse().into_diagnostic()?;
236-
requirements.host.push(python_abi_req);
239+
if !requirement_contains_package(&requirements.host, "python-abi3") {
240+
let requires_python_str = pyproject_metadata_provider.requires_python().ok().flatten();
241+
let abi3_spec = python_abi3_spec_from_requires_python(requires_python_str.as_deref())?;
242+
let python_abi3_req: Item<PackageDependency> =
243+
format!("python-abi3 {abi3_spec}").parse().into_diagnostic()?;
244+
requirements.host.push(python_abi3_req);
245+
}
246+
247+
let python_package = PackageName::from_str("python").into_diagnostic()?;
248+
if !requirements
249+
.ignore_run_exports
250+
.from_package
251+
.contains(&python_package)
252+
{
253+
requirements.ignore_run_exports.from_package.push(python_package);
254+
}
237255
}
238256

239257
// Use NoArch platform for mapping if this is a noarch package
@@ -1094,42 +1112,8 @@ build-backend = "hatchling.build"
10941112
);
10951113
}
10961114

1097-
#[test]
1098-
fn test_python_abi_spec_from_requires_python() {
1099-
// Basic lower bound
1100-
assert_eq!(
1101-
python_abi_spec_from_requires_python(Some(">=3.9")).unwrap(),
1102-
">=3.9,<3.10.0a0"
1103-
);
1104-
// With patch version
1105-
assert_eq!(
1106-
python_abi_spec_from_requires_python(Some(">=3.9.3")).unwrap(),
1107-
">=3.9.3,<3.10.0a0"
1108-
);
1109-
// Multiple specifiers - uses the >= bound
1110-
assert_eq!(
1111-
python_abi_spec_from_requires_python(Some(">=3.11,<4")).unwrap(),
1112-
">=3.11,<3.12.0a0"
1113-
);
1114-
// 3.8 lower bound
1115-
assert_eq!(
1116-
python_abi_spec_from_requires_python(Some(">=3.8")).unwrap(),
1117-
">=3.8,<3.9.0a0"
1118-
);
1119-
// None defaults to 3.8
1120-
assert_eq!(
1121-
python_abi_spec_from_requires_python(None).unwrap(),
1122-
">=3.8,<3.9.0a0"
1123-
);
1124-
// Extra segments are preserved in lower bound but upper bound still pins to major.minor
1125-
assert_eq!(
1126-
python_abi_spec_from_requires_python(Some(">=3.9.3.4")).unwrap(),
1127-
">=3.9.3.4,<3.10.0a0"
1128-
);
1129-
}
1130-
11311115
#[tokio::test]
1132-
async fn test_abi3_adds_python_abi_to_host() {
1116+
async fn test_abi3_marks_recipe_version_independent_and_ignores_python_run_exports() {
11331117
let project_model = project_fixture!({
11341118
"name": "foobar",
11351119
"version": "0.1.0",
@@ -1185,20 +1169,31 @@ build-backend = "setuptools.build_meta"
11851169
.collect();
11861170

11871171
assert!(
1188-
host_deps.iter().any(|d| d.contains("python_abi")),
1189-
"host deps should contain python_abi when abi3=true, got: {host_deps:?}"
1172+
host_deps.iter().any(|d| d == "python-abi3 3.9.*"),
1173+
"host deps should contain python-abi3 3.9.* when abi3=true, got: {host_deps:?}"
11901174
);
1191-
// Check the version spec
1192-
let abi_dep = host_deps.iter().find(|d| d.contains("python_abi")).unwrap();
11931175
assert!(
1194-
abi_dep.contains(">=3.9") && abi_dep.contains("<3.10.0a0"),
1195-
"python_abi should have >=3.9,<3.10.0a0 spec, got: {abi_dep}"
1176+
!host_deps.iter().any(|d| d.contains("python_abi")),
1177+
"host deps should not contain python_abi when abi3=true, got: {host_deps:?}"
11961178
);
1197-
// Check version_independent is set
11981179
assert!(
11991180
generated_recipe.recipe.build.python.version_independent,
12001181
"version_independent should be true when abi3=true"
12011182
);
1183+
1184+
let ignored_packages = &generated_recipe.recipe.requirements.ignore_run_exports.from_package;
1185+
assert!(
1186+
ignored_packages
1187+
.iter()
1188+
.any(|name| name.as_normalized() == "python"),
1189+
"ignore_run_exports.from_package should contain python when abi3=true, got: {ignored_packages:?}"
1190+
);
1191+
1192+
let recipe_yaml = generated_recipe.recipe.to_yaml_pretty().unwrap();
1193+
assert!(
1194+
recipe_yaml.contains("ignore_run_exports:"),
1195+
"serialized recipe should include ignore_run_exports when abi3=true, got:\n{recipe_yaml}"
1196+
);
12021197
}
12031198

12041199
#[tokio::test]
@@ -1236,15 +1231,96 @@ build-backend = "setuptools.build_meta"
12361231
.map(|item| item.to_string())
12371232
.collect();
12381233

1239-
let abi_dep = host_deps.iter().find(|d| d.contains("python_abi"));
12401234
assert!(
1241-
abi_dep.is_some(),
1242-
"host deps should contain python_abi, got: {host_deps:?}"
1235+
host_deps.iter().any(|d| d == "python-abi3 3.8.*"),
1236+
"host deps should contain python-abi3 3.8.* when abi3=true, got: {host_deps:?}"
12431237
);
1244-
let abi_dep = abi_dep.unwrap();
12451238
assert!(
1246-
abi_dep.contains(">=3.8") && abi_dep.contains("<3.9.0a0"),
1247-
"python_abi should default to >=3.8,<3.9.0a0, got: {abi_dep}"
1239+
!host_deps.iter().any(|d| d.contains("python_abi")),
1240+
"host deps should not contain python_abi when abi3=true, got: {host_deps:?}"
1241+
);
1242+
assert!(
1243+
generated_recipe
1244+
.recipe
1245+
.requirements
1246+
.ignore_run_exports
1247+
.from_package
1248+
.iter()
1249+
.any(|name| name.as_normalized() == "python"),
1250+
"ignore_run_exports.from_package should contain python when abi3=true"
1251+
);
1252+
}
1253+
1254+
#[tokio::test]
1255+
async fn test_abi3_does_not_duplicate_explicit_python_abi3_dependency() {
1256+
let project_model = project_fixture!({
1257+
"name": "foobar",
1258+
"version": "0.1.0",
1259+
"targets": {
1260+
"defaultTarget": {
1261+
"hostDependencies": {
1262+
"python-abi3": {
1263+
"binary": {
1264+
"version": "*"
1265+
}
1266+
}
1267+
}
1268+
}
1269+
}
1270+
});
1271+
1272+
let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
1273+
fs::write(
1274+
temp_dir.path().join("pyproject.toml"),
1275+
r#"[project]
1276+
name = "foobar"
1277+
version = "0.1.0"
1278+
requires-python = ">=3.9"
1279+
1280+
[build-system]
1281+
requires = ["setuptools"]
1282+
build-backend = "setuptools.build_meta"
1283+
"#,
1284+
)
1285+
.await
1286+
.expect("Failed to write pyproject.toml");
1287+
1288+
let config = PythonBackendConfig {
1289+
abi3: Some(true),
1290+
noarch: Some(false),
1291+
compilers: Some(vec!["c".to_string()]),
1292+
..Default::default()
1293+
};
1294+
1295+
let generated_recipe = PythonGenerator::default()
1296+
.generate_recipe(
1297+
&project_model,
1298+
&config,
1299+
temp_dir.path().to_path_buf(),
1300+
Platform::Linux64,
1301+
None,
1302+
&HashSet::new(),
1303+
vec![],
1304+
None,
1305+
)
1306+
.await
1307+
.expect("Failed to generate recipe");
1308+
1309+
let host_deps: Vec<String> = generated_recipe
1310+
.recipe
1311+
.requirements
1312+
.host
1313+
.iter()
1314+
.map(|item| item.to_string())
1315+
.collect();
1316+
1317+
assert_eq!(
1318+
host_deps
1319+
.iter()
1320+
.filter(|dep| dep.starts_with("python-abi3"))
1321+
.count(),
1322+
1,
1323+
"host deps should contain exactly one python-abi3 entry when it is explicitly declared, got: {host_deps:?}"
12481324
);
12491325
}
12501326

crates/recipe_stage0/src/marked_yaml.rs

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ use marked_yaml::{Node as MarkedNode, Span};
55
pub type MappingHash = LinkedHashMap<MarkedScalarNode, MarkedNode>;
66

77
use crate::recipe::{
8-
About, Build, Conditional, ConditionalList, ConditionalRequirements, Extra, IntermediateRecipe,
9-
Item, ListOrItem, Package, PackageContents, Source, Test, Value,
8+
About, Build, Conditional, ConditionalList, ConditionalRequirements, Extra,
9+
IgnoreRunExports, IntermediateRecipe, Item, ListOrItem, Package, PackageContents, Source,
10+
Test, Value,
1011
};
1112

1213
// Trait for converting to marked YAML nodes
@@ -176,6 +177,57 @@ impl ToMarkedYaml for ConditionalRequirements {
176177
);
177178
}
178179

180+
if !self.ignore_run_exports.is_empty() {
181+
mapping.insert(
182+
MarkedScalarNode::new(Span::new_blank(), "ignore_run_exports"),
183+
self.ignore_run_exports.to_marked_yaml(),
184+
);
185+
}
186+
187+
MarkedNode::Mapping(MarkedMappingNode::new(Span::new_blank(), mapping))
188+
}
189+
}
190+
191+
impl ToMarkedYaml for IgnoreRunExports {
192+
fn to_marked_yaml(&self) -> MarkedNode {
193+
let mut mapping = MappingHash::new();
194+
195+
if !self.by_name.is_empty() {
196+
mapping.insert(
197+
MarkedScalarNode::new(Span::new_blank(), "by_name"),
198+
MarkedNode::Sequence(MarkedSequenceNode::new(
199+
Span::new_blank(),
200+
self.by_name
201+
.iter()
202+
.map(|name| {
203+
MarkedNode::Scalar(MarkedScalarNode::new(
204+
Span::new_blank(),
205+
name.as_normalized().to_string(),
206+
))
207+
})
208+
.collect(),
209+
)),
210+
);
211+
}
212+
213+
if !self.from_package.is_empty() {
214+
mapping.insert(
215+
MarkedScalarNode::new(Span::new_blank(), "from_package"),
216+
MarkedNode::Sequence(MarkedSequenceNode::new(
217+
Span::new_blank(),
218+
self.from_package
219+
.iter()
220+
.map(|name| {
221+
MarkedNode::Scalar(MarkedScalarNode::new(
222+
Span::new_blank(),
223+
name.as_normalized().to_string(),
224+
))
225+
})
226+
.collect(),
227+
)),
228+
);
229+
}
230+
179231
MarkedNode::Mapping(MarkedMappingNode::new(Span::new_blank(), mapping))
180232
}
181233
}

0 commit comments

Comments
 (0)