Skip to content
Merged
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
7 changes: 7 additions & 0 deletions guide/src/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 29 additions & 2 deletions maturin.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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": [
Expand Down
3 changes: 3 additions & 0 deletions src/build_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -149,6 +150,8 @@ pub struct BuildContext {
pub sbom: Option<SbomConfig>,
/// 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`).
Expand Down
37 changes: 34 additions & 3 deletions src/build_options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -916,6 +940,7 @@ impl BuildContextBuilder {
pypi_validation,
sbom,
include_import_lib,
conditional_features,
})
}
}
Expand Down Expand Up @@ -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");
}

Expand Down
31 changes: 27 additions & 4 deletions src/compile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,17 +185,40 @@ fn cargo_build_command(
python_interpreter: Option<&PythonInterpreter>,
compile_target: &CompileTarget,
) -> Result<Command> {
use crate::pyproject_toml::FeatureSpec;

let target = &context.target;

let user_specified_target = if target.user_specified {
Some(target.target_triple().to_string())
} 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);
}
}
}
Comment thread
messense marked this conversation as resolved.
}

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
Expand Down
140 changes: 135 additions & 5 deletions src/pyproject_toml.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -231,6 +231,55 @@ pub struct SbomConfig {
pub include: Option<Vec<PathBuf>>,
}

/// 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<String> {
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")]
Expand Down Expand Up @@ -279,8 +328,10 @@ pub struct ToolMaturin {
pub profile: Option<String>,
/// Same as `profile` but for "editable" builds
pub editable_profile: Option<String>,
/// Space or comma separated list of features to activate
pub features: Option<Vec<String>>,
/// 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<Vec<FeatureSpec>>,
/// Activate all available features
pub all_features: Option<bool>,
/// Do not activate the `default` feature
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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"]);
Comment thread
messense marked this conversation as resolved.
}

#[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<ToolMaturin, _> = toml::from_str(toml_str);
assert!(result.is_err());
}
}
Loading