maturin 1.13.0

Build and publish crates with pyo3, cffi and uniffi bindings as well as rust binaries as python packages
Documentation
/// GitHub Actions CI generation
pub mod github;

use std::collections::BTreeMap;
use std::fmt;
use std::path::{Path, PathBuf};

use anyhow::Result;
use clap::{ArgAction, Parser, ValueEnum};
use fs_err as fs;
use pep440_rs::{Operator, VersionSpecifiers};

use crate::CargoOptions;
use crate::bridge::find_bridge;
use crate::project_layout::ProjectResolver;

/// CI providers
#[derive(Debug, Clone, Copy, ValueEnum)]
#[clap(rename_all = "lower")]
pub enum Provider {
    /// GitHub
    GitHub,
}

/// Platform
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
#[clap(rename_all = "lower")]
pub enum Platform {
    /// All
    All,
    /// Manylinux
    #[clap(alias = "linux")]
    ManyLinux,
    /// Musllinux
    Musllinux,
    /// Windows
    Windows,
    /// macOS
    Macos,
    /// Emscripten
    Emscripten,
    /// Android
    Android,
}

impl Platform {
    pub(crate) fn defaults() -> &'static [Self] {
        &[
            Platform::ManyLinux,
            Platform::Musllinux,
            Platform::Windows,
            Platform::Macos,
        ]
    }

    pub(crate) fn all() -> &'static [Self] {
        &[
            Platform::ManyLinux,
            Platform::Musllinux,
            Platform::Windows,
            Platform::Macos,
            Platform::Emscripten,
            Platform::Android,
        ]
    }

    pub(crate) fn default_runner(self, arch: &str) -> &'static str {
        match self {
            Platform::ManyLinux | Platform::Musllinux | Platform::Emscripten => "ubuntu-22.04",
            Platform::Android => "ubuntu-latest",
            Platform::Windows => {
                if arch == "aarch64" {
                    "windows-11-arm"
                } else {
                    "windows-latest"
                }
            }
            Platform::Macos => {
                if arch == "x86_64" {
                    "macos-15-intel"
                } else {
                    "macos-latest"
                }
            }
            Platform::All => "ubuntu-22.04",
        }
    }

    pub(crate) fn default_python_arch(self, arch: &str) -> Option<String> {
        match self {
            Platform::Windows => {
                if arch == "aarch64" {
                    Some("arm64".to_string())
                } else {
                    Some(arch.to_string())
                }
            }
            _ => None,
        }
    }

    pub(crate) fn default_targets(self) -> &'static [&'static str] {
        match self {
            Platform::ManyLinux => &["x86_64", "x86", "aarch64", "armv7", "s390x", "ppc64le"],
            Platform::Musllinux => &["x86_64", "x86", "aarch64", "armv7"],
            Platform::Windows => &["x64", "x86", "aarch64"],
            Platform::Macos => &["x86_64", "aarch64"],
            Platform::Emscripten => &["wasm32-unknown-emscripten"],
            Platform::Android => &["aarch64-linux-android", "x86_64-linux-android"],
            Platform::All => &[],
        }
    }

    pub(crate) fn default_manylinux(self) -> Option<&'static str> {
        match self {
            Platform::ManyLinux => Some("auto"),
            Platform::Musllinux => Some("musllinux_1_2"),
            _ => None,
        }
    }

    pub(crate) fn default_rust_toolchain(self) -> Option<&'static str> {
        match self {
            Platform::Emscripten => Some("nightly"),
            _ => None,
        }
    }
}

impl fmt::Display for Platform {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Platform::All => write!(f, "all"),
            Platform::ManyLinux => write!(f, "linux"),
            Platform::Musllinux => write!(f, "musllinux"),
            Platform::Windows => write!(f, "windows"),
            Platform::Macos => write!(f, "macos"),
            Platform::Emscripten => write!(f, "emscripten"),
            Platform::Android => write!(f, "android"),
        }
    }
}

/// A fully-resolved target for CI generation
#[derive(Debug, Clone)]
pub(crate) struct ResolvedTarget {
    pub runner: String,
    pub target: String,
    pub python_arch: Option<String>,
    pub manylinux: Option<String>,
    pub container: Option<String>,
    pub docker_options: Option<String>,
    pub rust_toolchain: Option<String>,
    pub rustup_components: Option<String>,
    pub before_script_linux: Option<String>,
    pub extra_args: Option<String>,
}

/// Resolved CI configuration after merging CLI args with pyproject.toml
#[derive(Debug)]
pub(crate) struct ResolvedCIConfig {
    pub pytest: bool,
    pub zig: bool,
    pub skip_attestation: bool,
    pub platform_targets: BTreeMap<Platform, Vec<ResolvedTarget>>,
}

/// Generate CI configuration
#[derive(Debug, Parser)]
pub struct GenerateCI {
    /// CI provider
    #[arg(value_enum, value_name = "CI")]
    pub ci: Provider,
    /// Path to Cargo.toml
    #[arg(short = 'm', long, value_name = "PATH")]
    pub manifest_path: Option<PathBuf>,
    /// Output path
    #[arg(short = 'o', long, value_name = "PATH", default_value = "-")]
    pub output: PathBuf,
    /// Platform support [deprecated: use [tool.maturin.generate-ci.github] in pyproject.toml]
    #[arg(
        id = "platform",
        long,
        action = ArgAction::Append,
        num_args = 1..,
    )]
    pub platforms: Vec<Platform>,
    /// Enable pytest [deprecated: use [tool.maturin.generate-ci.github] in pyproject.toml]
    #[arg(long)]
    pub pytest: bool,
    /// Use zig to do cross compilation [deprecated: use [tool.maturin.generate-ci.github] in pyproject.toml]
    #[arg(long)]
    pub zig: bool,
    /// Skip artifact attestation [deprecated: use [tool.maturin.generate-ci.github] in pyproject.toml]
    #[arg(long)]
    pub skip_attestation: bool,
}

impl Default for GenerateCI {
    fn default() -> Self {
        Self {
            ci: Provider::GitHub,
            manifest_path: None,
            output: PathBuf::from("-"),
            platforms: Vec::new(),
            pytest: false,
            zig: false,
            skip_attestation: false,
        }
    }
}

/// Extract the minimum Python 3 minor version from a `requires-python` specifier.
///
/// For example, `>=3.13` → `Some(13)`, `>=3.12,<4` → `Some(12)`.
/// Returns `None` if there is no lower bound or it is not a Python 3.x specifier.
fn min_python3_minor(requires_python: &VersionSpecifiers) -> Option<u8> {
    requires_python
        .iter()
        .filter(|spec| {
            matches!(
                spec.operator(),
                Operator::GreaterThanEqual | Operator::GreaterThan | Operator::Equal
            )
        })
        .filter(|spec| spec.version().release().first() == Some(&3))
        .filter_map(|spec| {
            let minor = *spec.version().release().get(1)?;
            let minor = if matches!(spec.operator(), Operator::GreaterThan) {
                // >3.12 means minimum is 3.13
                minor + 1
            } else {
                minor
            };
            u8::try_from(minor).ok()
        })
        .max()
}

impl GenerateCI {
    /// Execute this command
    pub fn execute(&self) -> Result<()> {
        let conf = self.generate()?;
        self.print(&conf)
    }

    /// Generate CI configuration
    pub fn generate(&self) -> Result<String> {
        // Emit deprecation warnings for CLI options
        self.warn_deprecated_cli_options();

        let cargo_options = CargoOptions {
            manifest_path: self.manifest_path.clone(),
            ..Default::default()
        };
        let ProjectResolver {
            cargo_metadata,
            pyproject_toml,
            project_layout,
            ..
        } = ProjectResolver::resolve(self.manifest_path.clone(), cargo_options, false, None)?;
        let pyproject = pyproject_toml.as_ref();
        let bridge = find_bridge(
            &cargo_metadata,
            pyproject.and_then(|x| x.bindings()),
            pyproject,
        )?;
        let project_name = pyproject
            .and_then(|project| project.project_name())
            .unwrap_or(&project_layout.extension_name);
        let sdist = pyproject_toml.is_some();

        // Read pyproject CI config
        let github_config = pyproject
            .and_then(|p| p.generate_ci())
            .and_then(|ci| ci.github.as_ref());

        // Extract minimum Python 3 minor version from requires-python
        let min_python_minor = pyproject
            .and_then(|p| p.project.as_ref())
            .and_then(|proj| proj.requires_python.as_ref())
            .and_then(min_python3_minor);

        match self.ci {
            Provider::GitHub => {
                let resolved = github::resolve_config(self, github_config, &bridge)?;
                github::generate_github(
                    self,
                    &resolved,
                    project_name,
                    &bridge,
                    sdist,
                    min_python_minor,
                )
            }
        }
    }

    fn warn_deprecated_cli_options(&self) {
        let hint = "Use [tool.maturin.generate-ci.github] in pyproject.toml instead.";
        if !self.platforms.is_empty() {
            eprintln!("⚠️  Warning: --platform is deprecated for `maturin generate-ci`. {hint}");
        }
        if self.pytest {
            eprintln!("⚠️  Warning: --pytest is deprecated for `maturin generate-ci`. {hint}");
        }
        if self.zig {
            eprintln!("⚠️  Warning: --zig is deprecated for `maturin generate-ci`. {hint}");
        }
        if self.skip_attestation {
            eprintln!(
                "⚠️  Warning: --skip-attestation is deprecated for `maturin generate-ci`. {hint}"
            );
        }
    }

    fn print(&self, conf: &str) -> Result<()> {
        if self.output == Path::new("-") {
            print!("{conf}");
        } else {
            fs::write(&self.output, conf)?;
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_min_python3_minor() {
        let parse = |s: &str| s.parse::<VersionSpecifiers>().unwrap();

        assert_eq!(min_python3_minor(&parse(">=3.12")), Some(12));
        assert_eq!(min_python3_minor(&parse(">=3.13")), Some(13));
        assert_eq!(min_python3_minor(&parse(">=3.12,<4")), Some(12));
        assert_eq!(min_python3_minor(&parse(">3.12")), Some(13));
        assert_eq!(min_python3_minor(&parse(">=3.8")), Some(8));
        assert_eq!(min_python3_minor(&parse("<4")), None);
        assert_eq!(min_python3_minor(&parse("!=3.5")), None);
    }
}