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
39 changes: 25 additions & 14 deletions guide/src/bindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,25 +14,36 @@ maturin automatically detects pyo3 bindings when it's added as a dependency in `

### `Py_LIMITED_API`/abi3

The pyo3 bindings supports the Python stable ABI (`Py_LIMITED_API`/abi3/abi3t).
You can use it by enabling `"abi3"` and/or `"abi3t"` features. We suggest
picking a minimum supported Python version for both features:
The pyo3 bindings support the Python stable ABI (`Py_LIMITED_API`/abi3/abi3t).
You can use it by enabling both `abi3` and `abi3t` features with a minimum
supported Python version for each:

```toml
pyo3 = { version = "0.29.0", features = ["abi3-py310", "abi3t-py315"] }
```

When selecting a specific interpreter to build against, this will produce an
`abi3-py310` wheel for Python 3.14 and older and an `abi3.abi3t-py315` wheel on
Python 3.15 and newer. If you build with `--find-interpreters`, maturin will
produce an `abi3-py310`, `cp314t-cp314` and an `abi3.abi3t-py315` wheel. These
three wheels cover all non-EOL and non-experimental builds of CPython. Other
python implementations like RustPython may also target the abi3t ABI in the
future.

An `abi3-py310` wheel supports all GIL-enabled Python
versions from Python 3.10 to Python 3.14 and the `abi3.abi3t` wheel supports
Python 3.15 and all newer versions of CPython.
A single maturin build selects one stable ABI family. If you want to publish
both a GIL-enabled `abi3` wheel and an `abi3t` wheel, run separate wheel builds
explicitly, with compatible interpreters:

```console
maturin build --interpreter python3.10
maturin build --interpreter python3.15t
Comment on lines +30 to +31

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had problems with this --interpreter value/format. The example in question simply does not work for Windows builds. I found it worked better (cross-platform -- windows-latest, ubuntu-latest, macos-15-intel, macos-latest) when I used that same version syntax passed to the actions/setup-python action:

strategy:
  matrix:
    # note, '3.11' is min available build on `windows-11-arm` runner
    include:
      - python-version: '3.10'
        pyo3-feature: 'pyo3/abi3-py310'
      - python-version: '3.15t'
        pyo3-feature: 'pyo3/abi3t-py315'
steps:
  - uses: actions/checkout@v6
  - uses: actions/setup-python@v7
    with:
      python-version: ${{ matrix.python-version }}
      # 3.15 is not stable yet, so ...
      allow-prereleases: ${{ startsWith(matrix.python-version, '3.15') }}
  - uses: pyo3/maturin-action@v1
    with:
      args: >-
        --release
        --out dist
        --interpreter ${{ matrix.python-version }}
        --features ${{ matrix.pyo3-feature }}

FYI, passing the ${{ steps.python-setup.outputs.python-path }} is a bad idea for Linux builds (if pyo3/maturin-action manylinux input is not 'off').

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you have those problems using the version of Maturin from this PR or the released version?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked the CI logs...

Installing 'maturin' from tag 'v1.14.0'

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So then I think this PR will fix things for you, there is Windows CI running on this PR and the invocation in the docs is what the integration test I added here does.

@2bndy5 2bndy5 Jun 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But the windows CI logs show:

python_version=$(echo 3.15t-dev | sed -e s/-dev//)
echo "PYTHON_VERSION=$python_version" >> "${GITHUB_ENV}"

where that output is

python_version=3.15t
echo PYTHON_VERSION=3.15t

and then it is passed to maturin build:

cargo run build -i $PYTHON_VERSION -m test-crates/pyo3-pure/Cargo.toml

This coincides with my observation and seems contrary to the example in question.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I see what you're getting at now. Yes, the CI over here doesn't set allow-prereleases but I don't think that matters for the question you have? The maturin integration tests don't know about setup-python or its naming conventions.

So the place to handle the problem you're having is probably over in maturin-action. Maybe in the PR I have open over there?

PyO3/maturin-action#453

Also with this PR merged you won't need to explicitly pass in abi3 and abi3t features at build time anymore. You can just enable both features, like in the test crates in this PR.

I'm going to bed now so I won't deal with subsequent replies here until tomorrow.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I didn't think there was any pre-processing in pyo3/maturin-action's args input. I'll look at that PR too.

Also with this PR merged you won't need to explicitly pass in abi3 and abi3t features at build time anymore. You can just enable both features, like in the test crates in this PR.

The way this patch reads, I think I still need to specify each feature separately if I want a abi3 wheel and a abi3t wheel. My understanding is that it prevents building abi3t wheels for any version of python prior to 3.15t.

Anyway, I bid you well-deserved sweet dreams! Thanks again.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The way this patch reads, I think I still need to specify each feature separately if I want a abi3 wheel and a abi3t wheel. My understanding is that it prevents building abi3t wheels for any version of python prior to 3.15t.

One last comment:

PyO3 is perfectly happy to let you enable both features.

If you enable, say abi3-py310 and abi3t-py315, the wheel you get out of maturin depends on the Python version used to build the wheel:

  • 3.14 or older Gil-enabled build -> abi3 wheel targeting the Python 3.10 limited API
  • 3.14t -> cp314t wheel
  • 3.15 or newer, both builds -> abi3.abi3t wheel targeting the 3.15 limited API

The idea is you get a working build no matter what, with abi3t preferred and falling back to abi3 on the GIL-enabled build where abi3t can't work and a version-specific wheel on 3.14t whhere neither stable ABI is supported.

Please try it yourself by checking out this PR locally, building and installing it in your Python environment, and trying it with a test crate you come up with or one of the test crates I added in this PR.

```

A GIL-enabled CPython 3.15 or newer interpreter can also build the `abi3t`
wheel when using PyO3 0.29 or newer.

An `abi3-py310` wheel supports all GIL-enabled CPython
versions from Python 3.10 and newer, while the `abi3.abi3t` wheel supports
Python 3.15 and all newer versions of CPython. Free-threaded CPython 3.14 does
not support the `abi3t` stable ABI, so maturin builds a version-specific
`cp314-cp314t` wheel for it instead.

Do not rely on a single build with both `abi3` and `abi3t` Cargo features to
produce both stable ABI wheels. maturin will choose at most one stable ABI
family for the build and emit version-specific fallback wheels for interpreters
that cannot use that family.

> **Note**: Read more about stable ABI support in [pyo3's
> documentation](https://pyo3.rs/latest/building-and-distribution#py_limited_apiabi3abi3t). You
Expand Down
55 changes: 35 additions & 20 deletions src/bridge/detection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
//! whether abi3 is enabled, and whether `generate-import-lib` is active.

use super::{
BridgeModel, PyO3, PyO3Crate, PyO3MetadataRaw, StableAbi, StableAbiKind, StableAbiVersion,
ABI3T_MINIMUM_PYTHON_MINOR, BridgeModel, PyO3, PyO3Crate, PyO3MetadataRaw, StableAbi,
StableAbiKind, StableAbiVersion,
};
use crate::PyProjectToml;
use crate::pyproject_toml::{FeatureConditionEnv, FeatureSpec};
Expand Down Expand Up @@ -123,7 +124,7 @@ pub fn find_bridge(cargo_metadata: &Metadata, bridge: Option<&str>) -> Result<Br
}
}

return if let Some(stable_abi) = has_stable_abi(&deps, &no_extra_features)? {
return if let Some(stable_abi) = has_stable_abi(&deps, &no_extra_features, &[])? {
let pyo3 = bridge.pyo3().expect("should be pyo3 bindings");
let bindings = PyO3 {
crate_name: lib,
Expand All @@ -141,33 +142,27 @@ pub fn find_bridge(cargo_metadata: &Metadata, bridge: Option<&str>) -> Result<Br
Ok(bridge)
}

/// Upgrade a bridge model to abi3 if conditional pyo3/pyo3-ffi features
/// from pyproject.toml match at least one of the given interpreters.
/// Select the stable ABI for a bridge model after interpreter resolution.
///
/// This is the second phase of bridge detection: [`find_bridge`] excludes
/// conditional features, then after interpreter resolution this function
/// re-checks whether any conditional abi3 feature applies.
/// conditional features and picks a conservative default, then after
/// interpreter resolution this function re-checks plain and conditional
/// stable ABI features and chooses the single stable ABI family this build
/// should attempt.
pub fn upgrade_bridge_stable_abi(
bridge: BridgeModel,
cargo_metadata: &Metadata,
pyproject: Option<&PyProjectToml>,
interpreters: &[crate::PythonInterpreter],
) -> Result<BridgeModel> {
// Only relevant for pyo3 bridges without abi3 already set
// Only relevant for pyo3 bridges
let Some(pyo3) = bridge.pyo3() else {
return Ok(bridge);
};
if pyo3.stable_abi.is_some() {
return Ok(bridge);
}

let extra_pyo3_features = pyo3_features_from_conditional(pyproject, interpreters);
if extra_pyo3_features.is_empty() {
return Ok(bridge);
}

let deps = current_crate_dependencies(cargo_metadata)?;
if let Some(stable_abi) = has_stable_abi(&deps, &extra_pyo3_features)? {
let extra_pyo3_features = pyo3_features_from_conditional(pyproject, interpreters);
if let Some(stable_abi) = has_stable_abi(&deps, &extra_pyo3_features, interpreters)? {
let upgraded = PyO3 {
stable_abi: Some(stable_abi),
..pyo3.clone()
Expand Down Expand Up @@ -222,12 +217,27 @@ pub fn has_windows_import_lib_support(cargo_metadata: &Metadata) -> Result<bool>
fn has_stable_abi(
deps: &HashMap<&str, &Node>,
extra_features: &HashMap<&str, Vec<String>>,
interpreters: &[crate::PythonInterpreter],
) -> Result<Option<StableAbi>> {
let abi3t = has_stable_abi_from_kind(deps, extra_features, StableAbiKind::Abi3t)?;
if abi3t.is_some() {
return Ok(abi3t);
}
has_stable_abi_from_kind(deps, extra_features, StableAbiKind::Abi3)
let abi3 = has_stable_abi_from_kind(deps, extra_features, StableAbiKind::Abi3)?;

let selected = [abi3t, abi3].into_iter().flatten().find(|stable_abi| {
interpreters.iter().any(|interpreter| {
interpreter.has_stable_api(stable_abi.kind)
&& stable_abi
.version
.min_version()
.is_none_or(|(major, minor)| {
(interpreter.major as u8, interpreter.minor as u8) >= (major, minor)
})
})
});

// If no resolved interpreter can use either stable ABI, keep abi3 as the
// conservative project marker when available; the build will fall back to
// version-specific wheels for the non-matching interpreters.
Ok(selected.or(abi3).or(abi3t))
}

/// pyo3 supports building stable abi wheels if the unstable-api feature is not selected
Expand Down Expand Up @@ -270,6 +280,11 @@ fn has_stable_abi_from_kind(
.min();
match min_stable_abi_version {
Some((major, minor)) => {
let (major, minor) = if abi_kind == StableAbiKind::Abi3t {
(major, minor).max((3, ABI3T_MINIMUM_PYTHON_MINOR))
} else {
(major, minor)
};
return Ok(Some(StableAbi {
kind: abi_kind,
version: StableAbiVersion::Version(major, minor),
Expand Down
33 changes: 17 additions & 16 deletions src/bridge/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ use crate::python_interpreter::{
PythonInterpreter,
};

/// First CPython minor version that supports PEP 803 stable ABI wheels.
pub const ABI3T_MINIMUM_PYTHON_MINOR: u8 = 15;

/// pyo3 binding crate
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum PyO3Crate {
Expand Down Expand Up @@ -207,7 +210,7 @@ pub struct PyO3 {
pub crate_name: PyO3Crate,
/// pyo3 bindings crate version
pub version: semver::Version,
/// abi3 support
/// Stable ABI support.
pub stable_abi: Option<StableAbi>,
/// pyo3 metadata
pub metadata: Option<PyO3Metadata>,
Expand Down Expand Up @@ -376,22 +379,20 @@ impl BridgeModel {
/// abi3 targets ≥ 3.11) return `false` so that `Py_LIMITED_API` is not
/// defined and interpreter‑specific linker names are used.
pub fn is_stable_abi_for_interpreter(&self, interpreter: &PythonInterpreter) -> bool {
self.pyo3()
.and_then(|pyo3| pyo3.stable_abi.as_ref())
.is_some_and(|stable_abi| {
if !interpreter.has_stable_api(stable_abi.kind) {
return false;
}
if matches!(stable_abi.kind, StableAbiKind::Abi3) && interpreter.gil_disabled {
return false;
};
match stable_abi.version.min_version() {
Some((major, minor)) => {
self.stable_abi_for_interpreter(interpreter).is_some()
}

/// Return the stable ABI kind this bridge can use for a specific interpreter.
pub fn stable_abi_for_interpreter(&self, interpreter: &PythonInterpreter) -> Option<StableAbi> {
self.pyo3()?.stable_abi.filter(|stable_abi| {
interpreter.has_stable_api(stable_abi.kind)
&& stable_abi
.version
.min_version()
.is_none_or(|(major, minor)| {
(interpreter.major as u8, interpreter.minor as u8) >= (major, minor)
}
None => true, // CurrentPython → compatible when stable ABI is supported
}
})
})
})
}

/// free-threaded Python support
Expand Down
42 changes: 16 additions & 26 deletions src/build_context/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,24 +111,13 @@ impl BuildContextBuilder {
})
});

// Check whether conditional pyo3/pyo3-ffi features exist in pyproject.toml
// AND pyproject features are actually active (not overridden by CLI --features).
// When CLI --features is set, pyproject features are ignored at compile time
// (see cargo_options.merge_with_pyproject_toml), so bridge inference must
// ignore them too to stay in sync.
let has_conditional_pyo3_features = pyproject
.and_then(|p| p.maturin())
.and_then(|m| m.features.as_ref())
.is_some_and(|specs| {
// Only consider conditional features when pyproject features
// were actually adopted (not overridden by CLI).
let cli_overrides = !cargo_options.features.is_empty()
&& !pyproject_toml_maturin_options.contains(&"features");
!cli_overrides
&& FeatureSpec::split(specs.clone()).1.iter().any(|c| {
c.feature.starts_with("pyo3/") || c.feature.starts_with("pyo3-ffi/")
})
});
let cli_overrides_pyproject_features = !cargo_options.features.is_empty()
&& !pyproject_toml_maturin_options.contains(&"features");
let pyproject_for_stable_abi = if cli_overrides_pyproject_features {
None
} else {
pyproject
};

// Detect bridge without conditional pyo3 features — those are
// evaluated after interpreter resolution via upgrade_bridge_stable_abi.
Expand Down Expand Up @@ -161,14 +150,15 @@ impl BuildContextBuilder {
&cargo_metadata,
)?;

// Upgrade bridge to abi3 if conditional pyo3 features
// (e.g. abi3-py311 gated on python-version>=3.11) match any
// of the resolved interpreters.
let bridge = if has_conditional_pyo3_features {
upgrade_bridge_stable_abi(bridge, &cargo_metadata, pyproject, &interpreter)?
} else {
bridge
};
// Select the stable ABI after interpreter resolution. This allows
// combined abi3/abi3t feature sets to choose the one stable ABI family
// this build can actually produce.
let bridge = upgrade_bridge_stable_abi(
bridge,
&cargo_metadata,
pyproject_for_stable_abi,
&interpreter,
)?;
debug!("Resolved bridge model: {:?}", bridge);
if let Some(stable_abi) = bridge.pyo3().and_then(|p| p.stable_abi.as_ref()) {
match stable_abi.version {
Expand Down
Loading
Loading