diff --git a/guide/src/config.md b/guide/src/config.md index 4b5d29384..1308e6046 100644 --- a/guide/src/config.md +++ b/guide/src/config.md @@ -18,6 +18,13 @@ profile = "release" editable-profile = "release" # List of features to activate features = ["foo", "bar"] +# Features can also be conditional on the target Python version +# using PEP 440 version specifiers: +# features = [ +# "always-on-feature", +# { feature = "pyo3/abi3-py311", python-version = ">=3.11" }, +# { feature = "pyo3/abi3-py38", python-version = "<3.11" }, +# ] # Activate all available features all-features = false # Do not activate the `default` feature diff --git a/maturin.schema.json b/maturin.schema.json index d29123368..3c0236c98 100644 --- a/maturin.schema.json +++ b/maturin.schema.json @@ -75,13 +75,13 @@ } }, "features": { - "description": "Space or comma separated list of features to activate", + "description": "List of features to activate.\nEach entry can be a plain feature name string, or a conditional object\nwith `feature` and `python-version` keys.", "type": [ "array", "null" ], "items": { - "type": "string" + "$ref": "#/$defs/FeatureSpec" } }, "frozen": { @@ -308,6 +308,33 @@ "name" ] }, + "FeatureSpec": { + "description": "A cargo feature specification that can be either a plain feature name\nor a conditional feature that is only enabled for certain Python versions.\n\n# Examples\n\n```toml\n[tool.maturin]\nfeatures = [\n \"some-feature\",\n { feature = \"pyo3/abi3-py311\", python-version = \">=3.11\" },\n { feature = \"pyo3/abi3-py38\", python-version = \"<3.11\" },\n]\n```", + "anyOf": [ + { + "description": "A plain feature name, always enabled", + "type": "string" + }, + { + "description": "A feature enabled only when the target Python version matches", + "type": "object", + "properties": { + "feature": { + "description": "The cargo feature to enable", + "type": "string" + }, + "python-version": { + "description": "PEP 440 version specifier for the target Python version, e.g. \">=3.11\"", + "type": "string" + } + }, + "required": [ + "feature", + "python-version" + ] + } + ] + }, "Format": { "description": "The target format for the include or exclude [GlobPattern].\n\nSee [Formats].", "oneOf": [ diff --git a/src/build_context.rs b/src/build_context.rs index c3f7f400d..f308c7261 100644 --- a/src/build_context.rs +++ b/src/build_context.rs @@ -29,6 +29,7 @@ use fs_err as fs; use ignore::overrides::{Override, OverrideBuilder}; use lddtree::Library; use normpath::PathExt; +use pep440_rs::VersionSpecifiers; use platform_info::*; use regex::Regex; use sha2::{Digest, Sha256}; @@ -149,6 +150,8 @@ pub struct BuildContext { pub sbom: Option, /// Include the import library (.dll.lib) in the wheel on Windows pub include_import_lib: bool, + /// Cargo features conditionally enabled based on the target Python version + pub conditional_features: Vec<(String, VersionSpecifiers)>, } /// The wheel file location and its Python version tag (e.g. `py3`). diff --git a/src/build_options.rs b/src/build_options.rs index 0a15ce850..b7e4b071a 100644 --- a/src/build_options.rs +++ b/src/build_options.rs @@ -6,7 +6,7 @@ use crate::cross_compile::{ find_build_details, find_sysconfigdata, parse_build_details_json_file, parse_sysconfigdata, }; use crate::project_layout::ProjectResolver; -use crate::pyproject_toml::ToolMaturin; +use crate::pyproject_toml::{FeatureSpec, ToolMaturin}; use crate::python_interpreter::{InterpreterConfig, InterpreterKind}; use crate::target::{ detect_arch_from_python, detect_target_from_cross_python, is_arch_supported_by_pypi, @@ -890,6 +890,30 @@ impl BuildContextBuilder { let include_import_lib = pyproject .map(|p| p.include_import_lib()) .unwrap_or_default(); + // Extract conditional features from pyproject.toml if CLI features + // didn't override (i.e. pyproject features were actually used) + let conditional_features = if pyproject_toml_maturin_options.contains(&"features") { + pyproject_toml + .as_ref() + .and_then(|p| p.maturin()) + .and_then(|m| m.features.as_ref()) + .map(|specs| { + specs + .iter() + .filter_map(|spec| match spec { + FeatureSpec::Conditional { + feature, + python_version, + } => Some((feature.clone(), python_version.clone())), + FeatureSpec::Plain(_) => None, + }) + .collect() + }) + .unwrap_or_default() + } else { + Vec::new() + }; + Ok(BuildContext { target, compile_targets, @@ -916,6 +940,7 @@ impl BuildContextBuilder { pypi_validation, sbom, include_import_lib, + conditional_features, }) } } @@ -1726,10 +1751,16 @@ impl CargoOptions { } } - if let Some(features) = tool_maturin.features + if let Some(feature_specs) = tool_maturin.features && self.features.is_empty() { - self.features = features; + self.features = feature_specs + .into_iter() + .filter_map(|spec| match spec { + FeatureSpec::Plain(f) => Some(f), + FeatureSpec::Conditional { .. } => None, + }) + .collect(); args_from_pyproject.push("features"); } diff --git a/src/compile.rs b/src/compile.rs index 6d4424c97..c53724091 100644 --- a/src/compile.rs +++ b/src/compile.rs @@ -185,6 +185,8 @@ fn cargo_build_command( python_interpreter: Option<&PythonInterpreter>, compile_target: &CompileTarget, ) -> Result { + use crate::pyproject_toml::FeatureSpec; + let target = &context.target; let user_specified_target = if target.user_specified { @@ -192,10 +194,31 @@ fn cargo_build_command( } else { None }; - let mut cargo_rustc = context - .cargo_options - .clone() - .into_rustc_options(user_specified_target); + let mut cargo_options = context.cargo_options.clone(); + + // Resolve conditional features based on the target Python version + if let Some(interpreter) = python_interpreter { + let extra = FeatureSpec::resolve_conditional( + &context.conditional_features, + interpreter.major, + interpreter.minor, + ); + if !extra.is_empty() { + debug!( + "Enabling conditional features for Python {}.{}: {}", + interpreter.major, + interpreter.minor, + extra.join(", ") + ); + for feature in extra { + if !cargo_options.features.contains(&feature) { + cargo_options.features.push(feature); + } + } + } + } + + let mut cargo_rustc = cargo_options.into_rustc_options(user_specified_target); cargo_rustc.message_format = vec!["json-render-diagnostics".to_string()]; // Add `--crate-type cdylib` if available diff --git a/src/pyproject_toml.rs b/src/pyproject_toml.rs index 9723e77bb..53a382be0 100644 --- a/src/pyproject_toml.rs +++ b/src/pyproject_toml.rs @@ -4,7 +4,7 @@ use crate::PlatformTag; use crate::auditwheel::AuditWheelMode; use anyhow::{Context, Result}; use fs_err as fs; -use pep440_rs::Version; +use pep440_rs::{Version, VersionSpecifiers}; use pep508_rs::VersionOrUrl; use pyproject_toml::{BuildSystem, Project}; use serde::{Deserialize, Serialize}; @@ -231,6 +231,55 @@ pub struct SbomConfig { pub include: Option>, } +/// A cargo feature specification that can be either a plain feature name +/// or a conditional feature that is only enabled for certain Python versions. +/// +/// # Examples +/// +/// ```toml +/// [tool.maturin] +/// features = [ +/// "some-feature", +/// { feature = "pyo3/abi3-py311", python-version = ">=3.11" }, +/// { feature = "pyo3/abi3-py38", python-version = "<3.11" }, +/// ] +/// ``` +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(untagged)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub enum FeatureSpec { + /// A plain feature name, always enabled + Plain(String), + /// A feature enabled only when the target Python version matches + Conditional { + /// The cargo feature to enable + feature: String, + /// PEP 440 version specifier for the target Python version, e.g. ">=3.11" + #[serde(rename = "python-version")] + #[cfg_attr(feature = "schemars", schemars(with = "String"))] + python_version: VersionSpecifiers, + }, +} + +impl FeatureSpec { + /// Resolve which conditional features should be enabled for a given Python version. + /// + /// Returns the feature names whose `python-version` specifier matches the + /// given `(major, minor)` version. + pub fn resolve_conditional( + conditional_features: &[(String, VersionSpecifiers)], + major: usize, + minor: usize, + ) -> Vec { + let python_version = Version::new([major as u64, minor as u64]); + conditional_features + .iter() + .filter(|(_, specifiers)| specifiers.contains(&python_version)) + .map(|(feature, _)| feature.clone()) + .collect() + } +} + /// The `[tool.maturin]` section of a pyproject.toml #[derive(Serialize, Deserialize, Debug, Clone, Default)] #[serde(rename_all = "kebab-case")] @@ -279,8 +328,10 @@ pub struct ToolMaturin { pub profile: Option, /// Same as `profile` but for "editable" builds pub editable_profile: Option, - /// Space or comma separated list of features to activate - pub features: Option>, + /// List of features to activate. + /// Each entry can be a plain feature name string, or a conditional object + /// with `feature` and `python-version` keys. + pub features: Option>, /// Activate all available features pub all_features: Option, /// Do not activate the `default` feature @@ -552,7 +603,7 @@ impl PyProjectToml { mod tests { use crate::{ PyProjectToml, - pyproject_toml::{Format, Formats, GlobPattern, ToolMaturin}, + pyproject_toml::{FeatureSpec, Format, Formats, GlobPattern, ToolMaturin}, }; use expect_test::expect; use fs_err as fs; @@ -599,7 +650,10 @@ mod tests { assert_eq!(maturin.profile.as_deref(), Some("dev")); assert_eq!( maturin.features, - Some(vec!["foo".to_string(), "bar".to_string()]) + Some(vec![ + FeatureSpec::Plain("foo".to_string()), + FeatureSpec::Plain("bar".to_string()), + ]) ); assert!(maturin.all_features.is_none()); assert_eq!(maturin.no_default_features, Some(true)); @@ -813,4 +867,80 @@ mod tests { "#]]; expected.assert_eq(&inner_error.to_string()); } + + #[test] + fn test_resolve_conditional_features() { + let specs = vec![ + FeatureSpec::Conditional { + feature: "pyo3/abi3-py311".to_string(), + python_version: ">=3.11".parse().unwrap(), + }, + FeatureSpec::Conditional { + feature: "pyo3/abi3-py38".to_string(), + python_version: "<3.11".parse().unwrap(), + }, + FeatureSpec::Conditional { + feature: "fast-buffer".to_string(), + python_version: ">=3.11".parse().unwrap(), + }, + ]; + let conditional: Vec<_> = specs + .into_iter() + .filter_map(|spec| match spec { + FeatureSpec::Conditional { + feature, + python_version, + } => Some((feature, python_version)), + _ => None, + }) + .collect(); + + // Python 3.12 should match >=3.11 + let resolved = FeatureSpec::resolve_conditional(&conditional, 3, 12); + assert_eq!(resolved, vec!["pyo3/abi3-py311", "fast-buffer"]); + + // Python 3.11 should match >=3.11 + let resolved = FeatureSpec::resolve_conditional(&conditional, 3, 11); + assert_eq!(resolved, vec!["pyo3/abi3-py311", "fast-buffer"]); + + // Python 3.9 should match <3.11 + let resolved = FeatureSpec::resolve_conditional(&conditional, 3, 9); + assert_eq!(resolved, vec!["pyo3/abi3-py38"]); + + // Python 3.8 should match <3.11 + let resolved = FeatureSpec::resolve_conditional(&conditional, 3, 8); + assert_eq!(resolved, vec!["pyo3/abi3-py38"]); + } + + #[test] + fn test_feature_spec_deserialize_mixed() { + let toml_str = r#" + features = [ + "plain-feature", + { feature = "pyo3/abi3-py311", python-version = ">=3.11" }, + ] + "#; + let maturin: ToolMaturin = toml::from_str(toml_str).unwrap(); + assert_eq!( + maturin.features, + Some(vec![ + FeatureSpec::Plain("plain-feature".to_string()), + FeatureSpec::Conditional { + feature: "pyo3/abi3-py311".to_string(), + python_version: ">=3.11".parse().unwrap(), + }, + ]) + ); + } + + #[test] + fn test_feature_spec_deserialize_invalid_specifier() { + let toml_str = r#" + features = [ + { feature = "foo", python-version = "not-a-version" }, + ] + "#; + let result: Result = toml::from_str(toml_str); + assert!(result.is_err()); + } }