@@ -15,9 +15,9 @@ use pixi_build_backend::{
1515 traits:: ProjectModel ,
1616} ;
1717use 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 } ;
1919use recipe_stage0:: matchspec:: PackageDependency ;
20- use recipe_stage0:: recipe:: { Item , NoArchKind , Python , Script } ;
20+ use recipe_stage0:: recipe:: { Item , NoArchKind , Python , Script , Value } ;
2121use std:: collections:: HashSet ;
2222use 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
0 commit comments