From 97df367c40f5aee6e85e69aac44d4764960f9969 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Mon, 30 Mar 2026 14:44:02 -0600 Subject: [PATCH 1/5] store CPython ABI metadata in a struct combining two enums --- src/binding_generator/pyo3_binding.rs | 74 ++++++++++-------- src/bridge/detection.rs | 67 +++++++++++----- src/bridge/mod.rs | 88 +++++++++++++++++---- src/build_options.rs | 4 +- src/build_orchestrator.rs | 107 ++++++++++++++------------ src/ci/github/tests.rs | 12 +-- src/lib.rs | 2 +- src/new_project.rs | 2 +- src/python_interpreter/discovery.rs | 8 +- src/python_interpreter/mod.rs | 4 +- src/python_interpreter/resolver.rs | 9 ++- 11 files changed, 246 insertions(+), 131 deletions(-) diff --git a/src/binding_generator/pyo3_binding.rs b/src/binding_generator/pyo3_binding.rs index cf5d60cd5..2bec49c53 100644 --- a/src/binding_generator/pyo3_binding.rs +++ b/src/binding_generator/pyo3_binding.rs @@ -5,21 +5,22 @@ use std::path::PathBuf; use std::process::Command; use std::rc::Rc; -use anyhow::Context; -use anyhow::Result; use anyhow::anyhow; use anyhow::bail; +use anyhow::Context; +use anyhow::Result; use pyo3_introspection::{introspect_cdylib, module_stub_files}; use tempfile::TempDir; use tracing::debug; +use crate::archive_source::ArchiveSource; +use crate::archive_source::GeneratedSourceData; +use crate::binding_generator::ArtifactTarget; use crate::BuildArtifact; use crate::BuildContext; use crate::PythonInterpreter; +use crate::StableAbiKind; use crate::Target; -use crate::archive_source::ArchiveSource; -use crate::archive_source::GeneratedSourceData; -use crate::binding_generator::ArtifactTarget; use super::BindingGenerator; use super::GeneratorOutput; @@ -37,22 +38,26 @@ pub struct Pyo3BindingGenerator<'a> { enum BindingType<'a> { Abi3(Option<&'a PythonInterpreter>), - NonAbi3(&'a PythonInterpreter), + VersionSpecific(&'a PythonInterpreter), } impl<'a> Pyo3BindingGenerator<'a> { pub fn new( - abi3: bool, + stable_abi: Option, interpreter: Option<&'a PythonInterpreter>, tempdir: Rc, ) -> Result { - let binding_type = match abi3 { - true => BindingType::Abi3(interpreter), - false => { - let interpreter = interpreter.ok_or_else(|| anyhow!( + let binding_type = match stable_abi { + Some(kind) => match kind { + StableAbiKind::Abi3 => BindingType::Abi3(interpreter), + }, + None => { + let interpreter = interpreter.ok_or_else(|| { + anyhow!( "A python interpreter is required for non-abi3 builds but one was not provided" - ))?; - BindingType::NonAbi3(interpreter) + ) + })?; + BindingType::VersionSpecific(interpreter) } }; Ok(Self { @@ -62,6 +67,29 @@ impl<'a> Pyo3BindingGenerator<'a> { } } +fn ext_suffix( + target: &Target, + interpreter: Option<&PythonInterpreter>, + ext_name: &str, + abi_name: &str, +) -> String { + if target.is_unix() { + if target.is_cygwin() { + format!("{ext_name}.{abi_name}.dll") + } else { + format!("{ext_name}.{abi_name}.so") + } + } else { + match interpreter { + Some(interpreter) if interpreter.is_windows_debug() => { + format!("{ext_name}_d.pyd") + } + // Apparently there is no tag for abi3 on windows + _ => format!("{ext_name}.pyd"), + } + } +} + impl<'a> BindingGenerator for Pyo3BindingGenerator<'a> { fn generate_bindings( &mut self, @@ -73,24 +101,8 @@ impl<'a> BindingGenerator for Pyo3BindingGenerator<'a> { let target = &context.project.target; let so_filename = match self.binding_type { - BindingType::Abi3(interpreter) => { - if target.is_unix() { - if target.is_cygwin() { - format!("{ext_name}.abi3.dll") - } else { - format!("{ext_name}.abi3.so") - } - } else { - match interpreter { - Some(interpreter) if interpreter.is_windows_debug() => { - format!("{ext_name}_d.pyd") - } - // Apparently there is no tag for abi3 on windows - _ => format!("{ext_name}.pyd"), - } - } - } - BindingType::NonAbi3(interpreter) => interpreter.get_library_name(ext_name), + BindingType::Abi3(interpreter) => ext_suffix(target, interpreter, ext_name, "abi3"), + BindingType::VersionSpecific(interpreter) => interpreter.get_library_name(ext_name), }; let artifact_target = ArtifactTarget::ExtensionModule(module.join(so_filename)); diff --git a/src/bridge/detection.rs b/src/bridge/detection.rs index 246341967..b1252396a 100644 --- a/src/bridge/detection.rs +++ b/src/bridge/detection.rs @@ -4,10 +4,12 @@ //! to determine which binding model (pyo3, cffi, uniffi, bin) to use, //! whether abi3 is enabled, and whether `generate-import-lib` is active. -use super::{Abi3Version, BridgeModel, PyO3, PyO3Crate, PyO3MetadataRaw}; -use crate::PyProjectToml; +use super::{ + BridgeModel, PyO3, PyO3Crate, PyO3MetadataRaw, StableAbi, StableAbiKind, StableAbiVersion, +}; use crate::pyproject_toml::FeatureSpec; -use anyhow::{Context, Result, bail}; +use crate::PyProjectToml; +use anyhow::{bail, Context, Result}; use cargo_metadata::{CrateType, Metadata, Node, PackageId, TargetKind}; use std::collections::{HashMap, HashSet}; @@ -122,13 +124,14 @@ pub fn find_bridge( } } - return if let Some(abi3_version) = has_abi3(&deps, &extra_pyo3_features)? { - eprintln!("🔗 Found {lib} bindings with abi3 support"); + return if let Some(stable_abi) = has_stable_abi(&deps, &extra_pyo3_features)? { + let kind = stable_abi.kind; + eprintln!("🔗 Found {lib} bindings with {kind} support"); let pyo3 = bridge.pyo3().expect("should be pyo3 bindings"); let bindings = PyO3 { crate_name: lib, version: pyo3.version.clone(), - abi3: Some(abi3_version), + stable_abi: Some(stable_abi), metadata: pyo3.metadata.clone(), }; Ok(BridgeModel::PyO3(bindings)) @@ -173,11 +176,23 @@ pub fn is_generating_import_lib(cargo_metadata: &Metadata) -> Result { Ok(false) } -/// pyo3 supports building abi3 wheels if the unstable-api feature is not selected -fn has_abi3( +fn has_stable_abi( + deps: &HashMap<&str, &Node>, + extra_features: &HashMap<&str, Vec>, +) -> Result> { + let abi3 = has_stable_abi_from_kind(deps, extra_features, StableAbiKind::Abi3)?; + if abi3.is_some() { + return Ok(abi3); + } + Ok(None) +} + +/// pyo3 supports building stable abi wheels if the unstable-api feature is not selected +fn has_stable_abi_from_kind( deps: &HashMap<&str, &Node>, extra_features: &HashMap<&str, Vec>, -) -> Result> { + abi_kind: StableAbiKind, +) -> Result> { for &lib in PYO3_BINDING_CRATES.iter() { let lib = lib.as_str(); if let Some(&pyo3_crate) = deps.get(lib) { @@ -191,24 +206,38 @@ fn has_abi3( .chain(extra.into_iter().flatten().map(String::as_str)) .collect(); - let abi3_selected = all_features.contains(&"abi3"); + let abi_str = format!("{abi_kind}"); + let search_str = format!("{abi_kind}-py"); + let stable_abi_selected = all_features.contains(&abi_str.as_str()); + let offset = search_str.len(); + let filter_len = offset + 2; - let min_abi3_version = all_features + let min_stable_abi_version = all_features .iter() - .filter(|&&x| x.starts_with("abi3-py") && x.len() >= "abi3-pyxx".len()) + .filter(|&&x| x.starts_with(search_str.as_str()) && x.len() >= filter_len) .map(|x| { Ok(( - (x.as_bytes()[7] as char).to_string().parse::()?, - x[8..].parse::()?, + (x.as_bytes()[offset] as char).to_string().parse::()?, + x[offset + 1..].parse::()?, )) }) .collect::>>() .context(format!("Bogus {lib} cargo features"))? .into_iter() .min(); - match min_abi3_version { - Some((major, minor)) => return Ok(Some(Abi3Version::Version(major, minor))), - None if abi3_selected => return Ok(Some(Abi3Version::CurrentPython)), + match min_stable_abi_version { + Some((major, minor)) => { + return Ok(Some(StableAbi { + kind: abi_kind, + version: StableAbiVersion::Version(major, minor), + })); + } + None if stable_abi_selected => { + return Ok(Some(StableAbi { + kind: abi_kind, + version: StableAbiVersion::CurrentPython, + })); + } None => {} } } @@ -238,7 +267,7 @@ fn find_pyo3_bindings( Ok(Some(PyO3 { crate_name: PyO3Crate::PyO3, version, - abi3: None, + stable_abi: None, metadata, })) } else if deps.get("pyo3-ffi").is_some() { @@ -252,7 +281,7 @@ fn find_pyo3_bindings( Ok(Some(PyO3 { crate_name: PyO3Crate::PyO3Ffi, version, - abi3: None, + stable_abi: None, metadata, })) } else { diff --git a/src/bridge/mod.rs b/src/bridge/mod.rs index 241f74aa1..72f92357b 100644 --- a/src/bridge/mod.rs +++ b/src/bridge/mod.rs @@ -114,16 +114,70 @@ impl TryFrom for PyO3Metadata { } } -/// Python version to use as the abi3 target. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum Abi3Version { - /// abi3 wheels will have a minimum Python version matching the version of - /// the current Python interpreter +/// struct describing ABI layout to use for build +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct StableAbi { + /// The "kind" of stable ABI. Either abi3 or abi3t currently. + pub kind: StableAbiKind, + /// The minimum Python version to build for. + pub version: StableAbiVersion, +} + +impl StableAbi { + /// Create a StableAbi instance from a known abi3 version + pub fn from_abi3_version(major: u8, minor: u8) -> StableAbi { + StableAbi { + kind: StableAbiKind::Abi3, + version: StableAbiVersion::Version(major, minor), + } + } +} + +/// Python version to use as the abi3/abi3t target. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StableAbiVersion { + /// Stable ABI wheels will have a minimum Python version matching the + /// version of the current Python interpreter CurrentPython, - /// abi3 wheels will have a fixed minimum Python version + /// Stable ABI wheels will have a fixed user-specified minimum Python + /// version Version(u8, u8), } +impl StableAbiVersion { + /// Convert `StableAbiVersion` into an Option, where CurrentPython maps None + pub fn min_version(&self) -> Option<(u8, u8)> { + match self { + StableAbiVersion::CurrentPython => None, + StableAbiVersion::Version(min, max) => Some((*min, *max)), + } + } +} + +/// The "kind" of stable ABI. Either abi3 or abi3t currently. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StableAbiKind { + /// The original stable ABI, supporting Python 3.2 and up + Abi3, +} + +impl fmt::Display for StableAbiKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + StableAbiKind::Abi3 => write!(f, "abi3"), + } + } +} + +impl StableAbiKind { + /// The tag to use for wheel building + pub fn wheel_tag(&self) -> &str { + match self { + StableAbiKind::Abi3 => "abi3", + } + } +} + /// The name and version of the pyo3 bindings crate #[derive(Clone, Debug, PartialEq, Eq)] pub struct PyO3 { @@ -132,7 +186,7 @@ pub struct PyO3 { /// pyo3 bindings crate version pub version: semver::Version, /// abi3 support - pub abi3: Option, + pub stable_abi: Option, /// pyo3 metadata pub metadata: Option, } @@ -150,8 +204,12 @@ impl PyO3 { } else { MINIMUM_PYTHON_MINOR }; - if let Some(Abi3Version::Version(_, abi3_minor)) = self.abi3.as_ref() { - min_minor.max(*abi3_minor as usize) + if let Some(stable_abi) = self.stable_abi.as_ref() { + if let StableAbiVersion::Version(_, abi3_minor) = stable_abi.version { + min_minor.max(abi3_minor as usize) + } else { + min_minor + } } else { min_minor } @@ -279,10 +337,14 @@ impl BridgeModel { /// Is using abi3 pub fn is_abi3(&self) -> bool { - match self.pyo3() { - Some(pyo3) => pyo3.abi3.is_some(), - None => false, - } + self.pyo3() + .and_then(|pyo3| match pyo3.stable_abi { + Some(stable_abi) => match stable_abi.kind { + StableAbiKind::Abi3 => Some(true), + }, + None => None, + }) + .is_some_and(|x| x) } /// free-threaded Python support diff --git a/src/build_options.rs b/src/build_options.rs index 29310c125..bb5162818 100644 --- a/src/build_options.rs +++ b/src/build_options.rs @@ -155,7 +155,7 @@ impl BuildOptions { #[cfg(test)] mod tests { use super::*; - use crate::bridge::{Abi3Version, PyO3, PyO3Crate, find_bridge}; + use crate::bridge::{PyO3, PyO3Crate, StableAbi, find_bridge}; use crate::python_interpreter::InterpreterResolver; use crate::test_utils::test_crate_path; use crate::{BridgeModel, Target}; @@ -192,7 +192,7 @@ mod tests { let bridge = BridgeModel::PyO3(PyO3 { crate_name: PyO3Crate::PyO3, version: semver::Version::new(0, 28, 2), - abi3: Some(Abi3Version::Version(3, 7)), + stable_abi: Some(StableAbi::from_abi3_version(3, 7)), metadata: Some(PyO3Metadata { cpython: PyO3VersionMetadata { min_minor: 7, diff --git a/src/build_orchestrator.rs b/src/build_orchestrator.rs index 908f2b3d7..53254f005 100644 --- a/src/build_orchestrator.rs +++ b/src/build_orchestrator.rs @@ -3,7 +3,6 @@ use crate::binding_generator::{ BinBindingGenerator, BindingGenerator, CffiBindingGenerator, Pyo3BindingGenerator, UniFfiBindingGenerator, generate_binding, }; -use crate::bridge::Abi3Version; use crate::compile::warn_missing_py_init; use crate::module_writer::{ModuleWriter, WheelWriter, add_data, write_pth}; use crate::pgo::{PgoContext, PgoPhase}; @@ -12,8 +11,8 @@ use crate::source_distribution::source_distribution; use crate::target::validate_wheel_filename_for_pypi; use crate::util::zip_mtime; use crate::{ - BridgeModel, BuildArtifact, BuildContext, BuiltWheelMetadata, PythonInterpreter, VirtualWriter, - compile, pyproject_toml::Format, + BridgeModel, BuildArtifact, BuildContext, BuiltWheelMetadata, PythonInterpreter, StableAbi, + StableAbiKind, StableAbiVersion, VirtualWriter, compile, pyproject_toml::Format, }; use anyhow::{Context, Result, anyhow, bail}; use cargo_metadata::CrateType; @@ -53,7 +52,10 @@ impl<'a> BuildOrchestrator<'a> { if let Some(pgo_command) = &self.context.artifact.pgo_command { let needs_per_interpreter_pgo = matches!( self.context.project.bridge(), - BridgeModel::PyO3(crate::PyO3 { abi3: None, .. }) + BridgeModel::PyO3(crate::PyO3 { + stable_abi: None, + .. + }) ); eprintln!("🚀 Starting PGO build..."); @@ -217,11 +219,8 @@ impl<'a> BuildOrchestrator<'a> { let wheels = match self.context.project.bridge() { BridgeModel::Bin(None) => self.build_bin_wheel(None, &sbom_data)?, BridgeModel::Bin(Some(..)) => self.build_bin_wheels(&interpreters, &sbom_data)?, - BridgeModel::PyO3(crate::PyO3 { abi3, .. }) => match abi3 { - Some(Abi3Version::Version(major, minor)) => { - self.build_abi3_wheels(Some((*major, *minor)), &sbom_data)? - } - Some(Abi3Version::CurrentPython) => self.build_abi3_wheels(None, &sbom_data)?, + BridgeModel::PyO3(crate::PyO3 { stable_abi, .. }) => match stable_abi { + Some(stable_abi) => self.build_stable_abi_wheels(stable_abi, &sbom_data)?, None => self.build_pyo3_wheels(&interpreters, &sbom_data)?, }, BridgeModel::Cffi => self.build_cffi_wheel(&sbom_data)?, @@ -279,33 +278,37 @@ impl<'a> BuildOrchestrator<'a> { /// Return the tags of the wheel that this build context builds. pub fn tags_from_bridge(&self) -> Result> { let tags = match self.context.project.bridge() { - BridgeModel::PyO3(bindings) | BridgeModel::Bin(Some(bindings)) => match bindings.abi3 { - Some(Abi3Version::Version(major, minor)) => { - let platform = self - .context - .project - .get_platform_tag(&[PlatformTag::Linux])?; - vec![format!("cp{major}{minor}-abi3-{platform}")] - } - Some(Abi3Version::CurrentPython) => { - let interp = &self.context.python.interpreter[0]; - let platform = self - .context - .project - .get_platform_tag(&[PlatformTag::Linux])?; - vec![format!( - "cp{major}{minor}-abi3-{platform}", - major = interp.major, - minor = interp.minor - )] + BridgeModel::PyO3(bindings) | BridgeModel::Bin(Some(bindings)) => { + let platform = self + .context + .project + .get_platform_tag(&[PlatformTag::Linux])?; + let interp = &self.context.python.interpreter[0]; + match bindings.stable_abi { + Some(stable_abi) => { + let wheel_tag = stable_abi.kind.wheel_tag(); + + match stable_abi.version { + StableAbiVersion::Version(major, minor) => { + vec![format!("cp{major}{minor}-{wheel_tag}-{platform}")] + } + StableAbiVersion::CurrentPython => { + vec![format!( + "cp{major}{minor}-{wheel_tag}-{platform}", + major = interp.major, + minor = interp.minor + )] + } + } + } + None => { + vec![ + self.context.python.interpreter[0] + .get_tag(&self.context.project, &[PlatformTag::Linux])?, + ] + } } - None => { - vec![ - self.context.python.interpreter[0] - .get_tag(&self.context.project, &[PlatformTag::Linux])?, - ] - } - }, + } BridgeModel::Bin(None) | BridgeModel::Cffi | BridgeModel::UniFfi => { vec![self.get_universal_tag(&[PlatformTag::Linux])?] } @@ -377,12 +380,13 @@ impl<'a> BuildOrchestrator<'a> { /// Split interpreters into abi3-capable and non-abi3 groups, build the /// appropriate wheel type for each group, and return all built wheels. #[instrument(skip_all)] - pub(crate) fn build_abi3_wheels( + pub(crate) fn build_stable_abi_wheels( &self, - min_version: Option<(u8, u8)>, + stable_abi: &StableAbi, sbom_data: &Option, ) -> Result> { - let abi3_interps: Vec<_> = self + let min_version = stable_abi.version.min_version(); + let stable_abi_interps: Vec<_> = self .context .python .interpreter @@ -394,7 +398,7 @@ impl<'a> BuildOrchestrator<'a> { }) }) .collect(); - let non_abi3_interps: Vec<_> = self + let version_specific_abi_interps: Vec<_> = self .context .python .interpreter @@ -433,17 +437,18 @@ impl<'a> BuildOrchestrator<'a> { } let mut built_wheels = Vec::new(); - if let Some(first) = abi3_interps.first() { + if let Some(first) = stable_abi_interps.first() { let (major, minor) = min_version.unwrap_or((first.major as u8, first.minor as u8)); - built_wheels.extend(self.build_pyo3_wheel_abi3( - &abi3_interps, + built_wheels.extend(self.build_pyo3_wheel_stable_abi( + &stable_abi_interps, + stable_abi.kind, major, minor, sbom_data, )?); } - if !non_abi3_interps.is_empty() { - let interp_names: HashSet<_> = non_abi3_interps + if !version_specific_abi_interps.is_empty() { + let interp_names: HashSet<_> = version_specific_abi_interps .iter() .map(|interp| interp.to_string()) .collect(); @@ -451,7 +456,7 @@ impl<'a> BuildOrchestrator<'a> { "⚠️ Warning: {} does not yet support abi3 so the build artifacts will be version-specific.", interp_names.iter().join(", ") ); - built_wheels.extend(self.build_pyo3_wheels(&non_abi3_interps, sbom_data)?); + built_wheels.extend(self.build_pyo3_wheels(&version_specific_abi_interps, sbom_data)?); } Ok(built_wheels) } @@ -523,9 +528,10 @@ impl<'a> BuildOrchestrator<'a> { /// For abi3 we only need to build a single wheel and we don't even need a python interpreter /// for it #[instrument(skip_all)] - pub(crate) fn build_pyo3_wheel_abi3( + pub(crate) fn build_pyo3_wheel_stable_abi( &self, interpreters: &[&PythonInterpreter], + stable_abi_kind: StableAbiKind, major: u8, min_minor: u8, sbom_data: &Option, @@ -544,7 +550,8 @@ impl<'a> BuildOrchestrator<'a> { let platform_tags = self.resolve_platform_tags(&policy); let platform = self.context.project.get_platform_tag(&platform_tags)?; - let tag = format!("cp{major}{min_minor}-abi3-{platform}"); + let abi_tag = stable_abi_kind.wheel_tag(); + let tag = format!("cp{major}{min_minor}-{abi_tag}-{platform}"); let wheel_path = self.write_wheel( &tag, @@ -552,7 +559,7 @@ impl<'a> BuildOrchestrator<'a> { &[external_libs], |temp_dir| { Ok(Box::new( - Pyo3BindingGenerator::new(true, python_interpreter, temp_dir) + Pyo3BindingGenerator::new(Some(stable_abi_kind), python_interpreter, temp_dir) .context("Failed to initialize PyO3 binding generator")?, )) }, @@ -561,7 +568,7 @@ impl<'a> BuildOrchestrator<'a> { )?; eprintln!( - "📦 Built wheel for abi3 Python ≥ {}.{} to {}", + "📦 Built wheel for {stable_abi_kind} Python ≥ {}.{} to {}", major, min_minor, wheel_path.display() @@ -589,7 +596,7 @@ impl<'a> BuildOrchestrator<'a> { &[ext_libs], |temp_dir| { Ok(Box::new( - Pyo3BindingGenerator::new(false, Some(python_interpreter), temp_dir) + Pyo3BindingGenerator::new(None, Some(python_interpreter), temp_dir) .context("Failed to initialize PyO3 binding generator")?, )) }, diff --git a/src/ci/github/tests.rs b/src/ci/github/tests.rs index 4d734e8a3..bc684b4a5 100644 --- a/src/ci/github/tests.rs +++ b/src/ci/github/tests.rs @@ -4,7 +4,7 @@ use semver::Version; use super::{generate_github, generate_github_from_cli, resolve_config}; use crate::ci::{GenerateCI, Platform}; use crate::pyproject_toml::{CIConfigOverrides, GitHubCIConfig, PlatformCIConfig, TargetCIConfig}; -use crate::{Abi3Version, BridgeModel, PyO3, bridge::PyO3Crate}; +use crate::{BridgeModel, PyO3, StableAbi, bridge::PyO3Crate}; const PROJECT_NAME: &str = "example"; @@ -40,11 +40,11 @@ fn assert_snapshot(output: &str, snapshot: &str) { } } -fn pyo3_bridge(abi3: Option) -> BridgeModel { +fn pyo3_bridge(stable_abi: Option) -> BridgeModel { BridgeModel::PyO3(PyO3 { crate_name: PyO3Crate::PyO3, version: Version::new(0, 23, 0), - abi3, + stable_abi, metadata: None, }) } @@ -83,7 +83,7 @@ fn test_generate_github_abi3() { let conf = generate_github_from_cli( &GenerateCI::default(), PROJECT_NAME, - &pyo3_bridge(Some(Abi3Version::Version(3, 7))), + &pyo3_bridge(Some(StableAbi::from_abi3_version(3, 7))), false, ) .unwrap(); @@ -99,7 +99,7 @@ fn test_generate_github_no_attestations() { let conf = generate_github_from_cli( &cli, PROJECT_NAME, - &pyo3_bridge(Some(Abi3Version::Version(3, 7))), + &pyo3_bridge(Some(StableAbi::from_abi3_version(3, 7))), false, ) .unwrap(); @@ -398,7 +398,7 @@ fn test_generate_github_min_python_minor() { // Since 14 <= 14, free-threaded remains 3.14t if it was abi3 // But this bridge is NOT abi3, so no free-threaded wheels. - let abi3_bridge = pyo3_bridge(Some(Abi3Version::Version(3, 7))); + let abi3_bridge = pyo3_bridge(Some(StableAbi::from_abi3_version(3, 7))); let conf_abi3 = generate_github(&cli, &resolved, PROJECT_NAME, &abi3_bridge, false, Some(15)).unwrap(); assert!(conf_abi3.contains("python-version: 3.15")); diff --git a/src/lib.rs b/src/lib.rs index 877360a0c..eaf3f8e75 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,7 +23,7 @@ #![deny(missing_docs)] -pub use crate::bridge::{Abi3Version, BridgeModel, PyO3, PyO3Crate}; +pub use crate::bridge::{BridgeModel, PyO3, PyO3Crate, StableAbi, StableAbiKind, StableAbiVersion}; pub use crate::build_context::{ ArtifactContext, BuildContext, BuiltWheelMetadata, ProjectContext, PythonContext, }; diff --git a/src/new_project.rs b/src/new_project.rs index 7dd76f1fb..301afe296 100644 --- a/src/new_project.rs +++ b/src/new_project.rs @@ -56,7 +56,7 @@ impl ProjectGenerator<'_> { _ => BridgeModel::PyO3(PyO3 { crate_name: bindings.parse()?, version: Version::new(0, 23, 1), - abi3: None, + stable_abi: None, metadata: None, }), }; diff --git a/src/python_interpreter/discovery.rs b/src/python_interpreter/discovery.rs index fa4d30419..597f37b54 100644 --- a/src/python_interpreter/discovery.rs +++ b/src/python_interpreter/discovery.rs @@ -664,7 +664,7 @@ mod tests { Some(&BridgeModel::PyO3(PyO3 { crate_name: PyO3Crate::PyO3, version: semver::Version::new(0, 23, 0), - abi3: None, + stable_abi: None, metadata: None, })), ) @@ -742,7 +742,7 @@ mod tests { Some(&BridgeModel::PyO3(PyO3 { crate_name: PyO3Crate::PyO3, version: semver::Version::new(0, 23, 0), - abi3: None, + stable_abi: None, metadata: None, })), ) @@ -779,7 +779,7 @@ mod tests { let bridge = BridgeModel::PyO3(PyO3 { crate_name: PyO3Crate::PyO3, version: semver::Version::new(0, 26, 0), - abi3: None, + stable_abi: None, metadata: None, }); @@ -905,7 +905,7 @@ mod tests { let bridge = BridgeModel::PyO3(PyO3 { crate_name: PyO3Crate::PyO3, version: semver::Version::new(0, 26, 0), - abi3: None, + stable_abi: None, metadata: None, }); diff --git a/src/python_interpreter/mod.rs b/src/python_interpreter/mod.rs index 678078928..cd3982d24 100644 --- a/src/python_interpreter/mod.rs +++ b/src/python_interpreter/mod.rs @@ -117,7 +117,9 @@ impl PythonInterpreter { } else { match self.interpreter_kind { // Free-threaded python does not have stable api support yet - InterpreterKind::CPython => !self.config.gil_disabled, + InterpreterKind::CPython => { + !(self.config.gil_disabled && self.config.major == 3 && self.config.minor < 15) + } InterpreterKind::PyPy | InterpreterKind::GraalPy => false, } } diff --git a/src/python_interpreter/resolver.rs b/src/python_interpreter/resolver.rs index 37193ef2b..ffe110240 100644 --- a/src/python_interpreter/resolver.rs +++ b/src/python_interpreter/resolver.rs @@ -25,7 +25,7 @@ use std::env; use std::path::{Path, PathBuf}; use tracing::debug; -use crate::bridge::{Abi3Version, PyO3}; +use crate::bridge::{PyO3, StableAbiVersion}; /// How a candidate Python interpreter was discovered. /// @@ -244,8 +244,11 @@ impl<'a> InterpreterResolver<'a> { return Ok((vec![PythonInterpreter::from_config(config)], None)); } - let fixed_abi3 = match &pyo3.abi3 { - Some(Abi3Version::Version(major, minor)) => Some((*major, *minor)), + let fixed_abi3 = match &pyo3.stable_abi { + Some(stable_abi) => match stable_abi.version { + StableAbiVersion::Version(major, minor) => Some((major, minor)), + _ => None, + }, _ => None, }; From 7bf186ed82c46e8ba706517fec5d7a252971740e Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Mon, 30 Mar 2026 14:56:38 -0600 Subject: [PATCH 2/5] fix formatting --- src/binding_generator/pyo3_binding.rs | 10 +++++----- src/bridge/detection.rs | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/binding_generator/pyo3_binding.rs b/src/binding_generator/pyo3_binding.rs index 2bec49c53..c109d2c30 100644 --- a/src/binding_generator/pyo3_binding.rs +++ b/src/binding_generator/pyo3_binding.rs @@ -5,22 +5,22 @@ use std::path::PathBuf; use std::process::Command; use std::rc::Rc; -use anyhow::anyhow; -use anyhow::bail; use anyhow::Context; use anyhow::Result; +use anyhow::anyhow; +use anyhow::bail; use pyo3_introspection::{introspect_cdylib, module_stub_files}; use tempfile::TempDir; use tracing::debug; -use crate::archive_source::ArchiveSource; -use crate::archive_source::GeneratedSourceData; -use crate::binding_generator::ArtifactTarget; use crate::BuildArtifact; use crate::BuildContext; use crate::PythonInterpreter; use crate::StableAbiKind; use crate::Target; +use crate::archive_source::ArchiveSource; +use crate::archive_source::GeneratedSourceData; +use crate::binding_generator::ArtifactTarget; use super::BindingGenerator; use super::GeneratorOutput; diff --git a/src/bridge/detection.rs b/src/bridge/detection.rs index b1252396a..4f0b58508 100644 --- a/src/bridge/detection.rs +++ b/src/bridge/detection.rs @@ -7,9 +7,9 @@ use super::{ BridgeModel, PyO3, PyO3Crate, PyO3MetadataRaw, StableAbi, StableAbiKind, StableAbiVersion, }; -use crate::pyproject_toml::FeatureSpec; use crate::PyProjectToml; -use anyhow::{bail, Context, Result}; +use crate::pyproject_toml::FeatureSpec; +use anyhow::{Context, Result, bail}; use cargo_metadata::{CrateType, Metadata, Node, PackageId, TargetKind}; use std::collections::{HashMap, HashSet}; From 2a342a9835258c370654dc3b2e677c9049621ca6 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Mon, 30 Mar 2026 15:01:23 -0600 Subject: [PATCH 3/5] fix merge error --- src/build_orchestrator.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/build_orchestrator.rs b/src/build_orchestrator.rs index 53254f005..d90d4f3ad 100644 --- a/src/build_orchestrator.rs +++ b/src/build_orchestrator.rs @@ -406,7 +406,7 @@ impl<'a> BuildOrchestrator<'a> { .filter(|interp| !interp.has_stable_api()) .collect(); - if abi3_interps.is_empty() && non_abi3_interps.is_empty() { + if stable_abi_interps.is_empty() && version_specific_abi_interps.is_empty() { let interp_names: Vec<_> = self .context .python From 6a816821547f8fe550582d9ef2a0b68223ff700f Mon Sep 17 00:00:00 2001 From: messense Date: Tue, 31 Mar 2026 08:07:07 +0800 Subject: [PATCH 4/5] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: messense --- src/bridge/detection.rs | 3 ++- src/bridge/mod.rs | 2 +- src/build_orchestrator.rs | 3 ++- src/python_interpreter/mod.rs | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/bridge/detection.rs b/src/bridge/detection.rs index 4f0b58508..b8dcac078 100644 --- a/src/bridge/detection.rs +++ b/src/bridge/detection.rs @@ -198,7 +198,8 @@ fn has_stable_abi_from_kind( if let Some(&pyo3_crate) = deps.get(lib) { let extra = extra_features.get(lib); // Find the minimal abi3 python version. If there is none, abi3 hasn't been selected - // This parser abi3-py{major}{minor} and returns the minimal (major, minor) tuple +// Find the minimal stable abi python version. If there is none, stable abi hasn't been selected +// This parses abi3-py{major}{minor} and returns the minimal (major, minor) tuple let all_features: Vec<&str> = pyo3_crate .features .iter() diff --git a/src/bridge/mod.rs b/src/bridge/mod.rs index 72f92357b..cd45f35ad 100644 --- a/src/bridge/mod.rs +++ b/src/bridge/mod.rs @@ -149,7 +149,7 @@ impl StableAbiVersion { pub fn min_version(&self) -> Option<(u8, u8)> { match self { StableAbiVersion::CurrentPython => None, - StableAbiVersion::Version(min, max) => Some((*min, *max)), + StableAbiVersion::Version(major, minor) => Some((*major, *minor)), } } } diff --git a/src/build_orchestrator.rs b/src/build_orchestrator.rs index d90d4f3ad..8d76ec2df 100644 --- a/src/build_orchestrator.rs +++ b/src/build_orchestrator.rs @@ -453,7 +453,8 @@ impl<'a> BuildOrchestrator<'a> { .map(|interp| interp.to_string()) .collect(); eprintln!( - "⚠️ Warning: {} does not yet support abi3 so the build artifacts will be version-specific.", + "⚠️ Warning: {} does not yet support {} so the build artifacts will be version-specific.", + stable_abi.kind, interp_names.iter().join(", ") ); built_wheels.extend(self.build_pyo3_wheels(&version_specific_abi_interps, sbom_data)?); diff --git a/src/python_interpreter/mod.rs b/src/python_interpreter/mod.rs index cd3982d24..e31996067 100644 --- a/src/python_interpreter/mod.rs +++ b/src/python_interpreter/mod.rs @@ -116,7 +116,7 @@ impl PythonInterpreter { false } else { match self.interpreter_kind { - // Free-threaded python does not have stable api support yet + // Free-threaded python does not have stable api support until 3.15 InterpreterKind::CPython => { !(self.config.gil_disabled && self.config.major == 3 && self.config.minor < 15) } From 05911c5a8aa03171dc0cbb6dcab1dd62d1d29e18 Mon Sep 17 00:00:00 2001 From: messense Date: Tue, 31 Mar 2026 08:09:03 +0800 Subject: [PATCH 5/5] fix format --- src/bridge/detection.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bridge/detection.rs b/src/bridge/detection.rs index b8dcac078..447074956 100644 --- a/src/bridge/detection.rs +++ b/src/bridge/detection.rs @@ -198,8 +198,8 @@ fn has_stable_abi_from_kind( if let Some(&pyo3_crate) = deps.get(lib) { let extra = extra_features.get(lib); // Find the minimal abi3 python version. If there is none, abi3 hasn't been selected -// Find the minimal stable abi python version. If there is none, stable abi hasn't been selected -// This parses abi3-py{major}{minor} and returns the minimal (major, minor) tuple + // Find the minimal stable abi python version. If there is none, stable abi hasn't been selected + // This parses abi3-py{major}{minor} and returns the minimal (major, minor) tuple let all_features: Vec<&str> = pyo3_crate .features .iter()