use crate::PlatformTag;
use crate::auditwheel::AuditWheelMode;
use anyhow::{Context, Result};
use fs_err as fs;
use pep440_rs::Version;
use pep508_rs::VersionOrUrl;
use pyproject_toml::{BuildSystem, Project};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::str::FromStr;
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
#[serde(rename_all = "kebab-case")]
pub struct Tool {
pub maturin: Option<ToolMaturin>,
}
#[derive(Copy, Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub enum Format {
Sdist,
Wheel,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(untagged)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub enum Formats {
Single(Format),
Multiple(Vec<Format>),
}
impl Formats {
pub fn targets(&self, format: Format) -> bool {
match self {
Self::Single(val) if val == &format => true,
Self::Multiple(formats) if formats.contains(&format) => true,
_ => false,
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(untagged)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub enum GlobPattern {
Path(String),
WithFormat {
path: String,
format: Formats,
},
WithOutDir {
path: String,
from: IncludeFrom,
to: String,
#[serde(default)]
crate_name: Option<String>,
},
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub enum IncludeFrom {
OutDir,
}
pub struct OutDirInclude<'a> {
pub path: &'a str,
pub to: &'a str,
pub crate_name: Option<&'a str>,
}
impl GlobPattern {
pub fn targets(&self, format: Format) -> Option<&str> {
match self {
Self::Path(glob) => Some(glob),
Self::WithFormat {
path,
format: formats,
} if formats.targets(format) => Some(path),
Self::WithOutDir { .. } => None,
_ => None,
}
}
pub fn as_out_dir_include(&self) -> Option<OutDirInclude<'_>> {
match self {
Self::WithOutDir {
path,
to,
crate_name,
..
} => Some(OutDirInclude {
path,
to,
crate_name: crate_name.as_deref(),
}),
_ => None,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
#[serde(rename_all = "kebab-case")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct CargoTarget {
pub name: String,
pub kind: Option<CargoCrateType>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub enum CargoCrateType {
#[serde(rename = "bin")]
Bin,
#[serde(rename = "cdylib")]
CDyLib,
#[serde(rename = "dylib")]
DyLib,
#[serde(rename = "lib")]
Lib,
#[serde(rename = "rlib")]
RLib,
#[serde(rename = "staticlib")]
StaticLib,
}
impl From<CargoCrateType> for cargo_metadata::CrateType {
fn from(value: CargoCrateType) -> Self {
match value {
CargoCrateType::Bin => cargo_metadata::CrateType::Bin,
CargoCrateType::CDyLib => cargo_metadata::CrateType::CDyLib,
CargoCrateType::DyLib => cargo_metadata::CrateType::DyLib,
CargoCrateType::Lib => cargo_metadata::CrateType::Lib,
CargoCrateType::RLib => cargo_metadata::CrateType::RLib,
CargoCrateType::StaticLib => cargo_metadata::CrateType::StaticLib,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "kebab-case")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct TargetConfig {
#[serde(alias = "macosx-deployment-target")]
pub macos_deployment_target: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default)]
#[serde(rename_all = "kebab-case")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub enum SdistGenerator {
#[default]
Cargo,
Git,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct SbomConfig {
pub rust: Option<bool>,
pub auditwheel: Option<bool>,
pub include: Option<Vec<PathBuf>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
#[serde(rename_all = "kebab-case")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ToolMaturin {
pub module_name: Option<String>,
pub include: Option<Vec<GlobPattern>>,
pub exclude: Option<Vec<GlobPattern>>,
pub bindings: Option<String>,
#[serde(alias = "manylinux")]
pub compatibility: Option<PlatformTag>,
pub auditwheel: Option<AuditWheelMode>,
#[serde(default)]
pub skip_auditwheel: bool,
#[serde(default)]
pub strip: bool,
#[serde(default)]
pub sdist_generator: SdistGenerator,
pub python_source: Option<PathBuf>,
pub python_packages: Option<Vec<String>>,
pub data: Option<PathBuf>,
pub targets: Option<Vec<CargoTarget>>,
#[serde(default, rename = "target")]
pub target_config: HashMap<String, TargetConfig>,
pub profile: Option<String>,
pub editable_profile: Option<String>,
pub features: Option<Vec<String>>,
pub all_features: Option<bool>,
pub no_default_features: Option<bool>,
pub manifest_path: Option<PathBuf>,
pub frozen: Option<bool>,
pub locked: Option<bool>,
pub config: Option<Vec<String>>,
pub unstable_flags: Option<Vec<String>>,
pub rustc_args: Option<Vec<String>>,
#[serde(default)]
pub use_base_python: bool,
pub sbom: Option<SbomConfig>,
#[serde(default)]
pub include_import_lib: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "kebab-case")]
pub struct PyProjectToml {
pub build_system: BuildSystem,
pub project: Option<Project>,
pub tool: Option<Tool>,
pub dependency_groups: Option<pyproject_toml::DependencyGroups>,
}
impl PyProjectToml {
pub fn new(pyproject_file: impl AsRef<Path>) -> Result<PyProjectToml> {
let path = pyproject_file.as_ref();
let contents = fs::read_to_string(path)?;
let pyproject = toml::from_str(&contents).with_context(|| {
format!(
"pyproject.toml at {} is invalid",
pyproject_file.as_ref().display()
)
})?;
Ok(pyproject)
}
pub fn project_name(&self) -> Option<&str> {
self.project.as_ref().map(|project| project.name.as_str())
}
#[inline]
pub fn maturin(&self) -> Option<&ToolMaturin> {
self.tool.as_ref()?.maturin.as_ref()
}
pub fn module_name(&self) -> Option<&str> {
self.maturin()?.module_name.as_deref()
}
pub fn include(&self) -> Option<&[GlobPattern]> {
self.maturin()?.include.as_ref().map(AsRef::as_ref)
}
pub fn exclude(&self) -> Option<&[GlobPattern]> {
self.maturin()?.exclude.as_ref().map(AsRef::as_ref)
}
pub fn bindings(&self) -> Option<&str> {
self.maturin()?.bindings.as_deref()
}
pub fn compatibility(&self) -> Option<PlatformTag> {
self.maturin()?.compatibility
}
pub fn auditwheel(&self) -> Option<AuditWheelMode> {
self.maturin()
.map(|maturin| maturin.auditwheel)
.unwrap_or_default()
}
pub fn skip_auditwheel(&self) -> bool {
self.maturin()
.map(|maturin| maturin.skip_auditwheel)
.unwrap_or_default()
}
pub fn strip(&self) -> bool {
self.maturin()
.map(|maturin| maturin.strip)
.unwrap_or_default()
}
pub fn sdist_generator(&self) -> SdistGenerator {
self.maturin()
.map(|maturin| maturin.sdist_generator)
.unwrap_or_default()
}
pub fn python_source(&self) -> Option<&Path> {
self.maturin()
.and_then(|maturin| maturin.python_source.as_deref())
}
pub fn python_packages(&self) -> Option<&[String]> {
self.maturin()
.and_then(|maturin| maturin.python_packages.as_deref())
}
pub fn data(&self) -> Option<&Path> {
self.maturin().and_then(|maturin| maturin.data.as_deref())
}
pub fn targets(&self) -> Option<Vec<CargoTarget>> {
self.maturin().and_then(|maturin| maturin.targets.clone())
}
pub fn target_config(&self, target: &str) -> Option<&TargetConfig> {
self.maturin()
.and_then(|maturin| maturin.target_config.get(target))
}
pub fn manifest_path(&self) -> Option<&Path> {
self.maturin()?.manifest_path.as_deref()
}
pub fn include_import_lib(&self) -> bool {
self.maturin()
.map(|maturin| maturin.include_import_lib)
.unwrap_or_default()
}
pub fn warn_bad_maturin_version(&self) -> bool {
let maturin = env!("CARGO_PKG_NAME");
let current_major = env!("CARGO_PKG_VERSION_MAJOR").parse::<usize>().unwrap();
let self_version = Version::from_str(env!("CARGO_PKG_VERSION")).unwrap();
let requires_maturin = self
.build_system
.requires
.iter()
.find(|x| x.name.as_ref() == maturin);
if let Some(requires_maturin) = requires_maturin {
match requires_maturin.version_or_url.as_ref() {
Some(VersionOrUrl::VersionSpecifier(version_specifier)) => {
if !version_specifier.contains(&self_version) {
eprintln!(
"⚠️ Warning: You specified {requires_maturin} in pyproject.toml under \
`build-system.requires`, but the current {maturin} version is {self_version}",
);
return false;
}
}
Some(VersionOrUrl::Url(_)) => {
}
None => {
eprintln!(
"⚠️ Warning: Please use {maturin} in pyproject.toml with a version constraint, \
e.g. `requires = [\"{maturin}>={current}.0,<{next}.0\"]`. \
This will become an error.",
maturin = maturin,
current = current_major,
next = current_major + 1,
);
return false;
}
}
}
true
}
pub fn warn_missing_build_backend(&self) -> bool {
let maturin = env!("CARGO_PKG_NAME");
if self.build_system.build_backend.as_deref() == Some(maturin) {
return true;
}
if std::env::var("MATURIN_NO_MISSING_BUILD_BACKEND_WARNING").is_ok() {
return false;
}
eprintln!(
"⚠️ Warning: `build-backend` in pyproject.toml is not set to `{maturin}`, \
packaging tools such as pip will not use maturin to build this project."
);
false
}
pub fn warn_invalid_version_info(&self) -> bool {
let Some(project) = &self.project else {
return true;
};
let has_static_version = project.version.is_some();
let has_dynamic_version = project
.dynamic
.as_ref()
.is_some_and(|d| d.iter().any(|s| s == "version"));
if has_static_version && has_dynamic_version {
eprintln!(
"⚠️ Warning: `project.dynamic` must not specify `version` when `project.version` is present in pyproject.toml"
);
return false;
}
if !has_static_version && !has_dynamic_version {
eprintln!(
"⚠️ Warning: `project.version` field is required in pyproject.toml unless it is present in the `project.dynamic` list"
);
return false;
}
true
}
}
#[cfg(test)]
mod tests {
use crate::{
PyProjectToml,
pyproject_toml::{Format, Formats, GlobPattern, ToolMaturin},
};
use expect_test::expect;
use fs_err as fs;
use indoc::indoc;
use pretty_assertions::assert_eq;
use std::path::Path;
use tempfile::TempDir;
#[test]
fn test_parse_tool_maturin() {
let tmp_dir = TempDir::new().unwrap();
let pyproject_file = tmp_dir.path().join("pyproject.toml");
fs::write(
&pyproject_file,
r#"[build-system]
requires = ["maturin"]
build-backend = "maturin"
[tool.maturin]
manylinux = "2010"
python-packages = ["foo", "bar"]
manifest-path = "Cargo.toml"
profile = "dev"
features = ["foo", "bar"]
no-default-features = true
locked = true
rustc-args = ["-Z", "unstable-options"]
[[tool.maturin.targets]]
name = "pyo3_pure"
kind = "lib"
bindings = "pyo3"
[tool.maturin.target."x86_64-apple-darwin"]
macos-deployment-target = "10.12"
"#,
)
.unwrap();
let pyproject = PyProjectToml::new(pyproject_file).unwrap();
assert_eq!(pyproject.manifest_path(), Some(Path::new("Cargo.toml")));
let maturin = pyproject.maturin().unwrap();
assert_eq!(maturin.profile.as_deref(), Some("dev"));
assert_eq!(
maturin.features,
Some(vec!["foo".to_string(), "bar".to_string()])
);
assert!(maturin.all_features.is_none());
assert_eq!(maturin.no_default_features, Some(true));
assert_eq!(maturin.locked, Some(true));
assert!(maturin.frozen.is_none());
assert_eq!(
maturin.rustc_args,
Some(vec!["-Z".to_string(), "unstable-options".to_string()])
);
assert_eq!(
maturin.python_packages,
Some(vec!["foo".to_string(), "bar".to_string()])
);
let targets = maturin.targets.as_ref().unwrap();
assert_eq!("pyo3_pure", targets[0].name);
let target_config = pyproject.target_config("x86_64-apple-darwin").unwrap();
assert_eq!(
target_config.macos_deployment_target.as_deref(),
Some("10.12")
);
}
#[test]
fn test_warn_missing_maturin_version() {
let with_constraint = PyProjectToml::new("test-crates/pyo3-pure/pyproject.toml").unwrap();
assert!(with_constraint.warn_bad_maturin_version());
let without_constraint_dir = TempDir::new().unwrap();
let pyproject_file = without_constraint_dir.path().join("pyproject.toml");
fs::write(
&pyproject_file,
r#"[build-system]
requires = ["maturin"]
build-backend = "maturin"
[tool.maturin]
manylinux = "2010"
"#,
)
.unwrap();
let without_constraint = PyProjectToml::new(pyproject_file).unwrap();
assert!(!without_constraint.warn_bad_maturin_version());
}
#[test]
fn test_warn_incorrect_maturin_version() {
let without_constraint_dir = TempDir::new().unwrap();
let pyproject_file = without_constraint_dir.path().join("pyproject.toml");
fs::write(
&pyproject_file,
r#"[build-system]
requires = ["maturin==0.0.1"]
build-backend = "maturin"
[tool.maturin]
manylinux = "2010"
"#,
)
.unwrap();
let without_constraint = PyProjectToml::new(pyproject_file).unwrap();
assert!(!without_constraint.warn_bad_maturin_version());
}
#[test]
fn test_warn_invalid_version_info_conflict() {
let conflict = toml::from_str::<PyProjectToml>(
r#"[build-system]
requires = ["maturin==1.0.0"]
[project]
name = "..."
version = "1.2.3"
dynamic = ['version']
"#,
)
.unwrap();
assert!(!conflict.warn_invalid_version_info());
}
#[test]
fn test_warn_invalid_version_info_missing() {
let missing = toml::from_str::<PyProjectToml>(
r#"[build-system]
requires = ["maturin==1.0.0"]
[project]
name = "..."
"#,
)
.unwrap();
assert!(!missing.warn_invalid_version_info());
}
#[test]
fn test_warn_invalid_version_info_ok() {
let static_ver = toml::from_str::<PyProjectToml>(
r#"[build-system]
requires = ["maturin==1.0.0"]
[project]
name = "..."
version = "1.2.3"
"#,
)
.unwrap();
assert!(static_ver.warn_invalid_version_info());
let dynamic_ver = toml::from_str::<PyProjectToml>(
r#"[build-system]
requires = ["maturin==1.0.0"]
[project]
name = "..."
dynamic = ['version']
"#,
)
.unwrap();
assert!(dynamic_ver.warn_invalid_version_info());
}
#[test]
fn deserialize_include_exclude() {
let single = r#"include = ["single"]"#;
assert_eq!(
toml::from_str::<ToolMaturin>(single).unwrap().include,
Some(vec![GlobPattern::Path("single".to_string())])
);
let multiple = r#"include = ["one", "two"]"#;
assert_eq!(
toml::from_str::<ToolMaturin>(multiple).unwrap().include,
Some(vec![
GlobPattern::Path("one".to_string()),
GlobPattern::Path("two".to_string())
])
);
let single_format = r#"include = [{path = "path", format="sdist"}]"#;
assert_eq!(
toml::from_str::<ToolMaturin>(single_format)
.unwrap()
.include,
Some(vec![GlobPattern::WithFormat {
path: "path".to_string(),
format: Formats::Single(Format::Sdist)
},])
);
let multiple_formats = r#"include = [{path = "path", format=["sdist", "wheel"]}]"#;
assert_eq!(
toml::from_str::<ToolMaturin>(multiple_formats)
.unwrap()
.include,
Some(vec![GlobPattern::WithFormat {
path: "path".to_string(),
format: Formats::Multiple(vec![Format::Sdist, Format::Wheel])
},])
);
let mixed = r#"include = ["one", {path = "two", format="sdist"}, {path = "three", format=["sdist", "wheel"]}]"#;
assert_eq!(
toml::from_str::<ToolMaturin>(mixed).unwrap().include,
Some(vec![
GlobPattern::Path("one".to_string()),
GlobPattern::WithFormat {
path: "two".to_string(),
format: Formats::Single(Format::Sdist),
},
GlobPattern::WithFormat {
path: "three".to_string(),
format: Formats::Multiple(vec![Format::Sdist, Format::Wheel])
}
])
);
}
#[test]
fn test_gh_1615() {
let source = indoc!(
r#"[build-system]
requires = [ "maturin>=0.14", "numpy", "wheel", "patchelf",]
build-backend = "maturin"
[project]
name = "..."
license-files = [ "license.txt",]
requires-python = ">=3.8"
requires-dist = [ "maturin>=0.14", "...",]
dependencies = [ "packaging", "...",]
zip-safe = false
version = "..."
readme = "..."
description = "..."
classifiers = [ "...",]
"#
);
let temp_dir = TempDir::new().unwrap();
let pyproject_toml = temp_dir.path().join("pyproject.toml");
fs::write(&pyproject_toml, source).unwrap();
let outer_error = PyProjectToml::new(&pyproject_toml).unwrap_err();
let inner_error = outer_error.source().unwrap();
let expected = expect![[r#"
TOML parse error at line 10, column 16
|
10 | dependencies = [ "packaging", "...",]
| ^^^^^^^^^^^^^^^^^^^^^^
URL requirement must be preceded by a package name. Add the name of the package before the URL (e.g., `package_name @ /path/to/file`).
...
^^^
"#]];
expected.assert_eq(&inner_error.to_string());
}
}