Skip to content

Add initial support for abi3.abi3t tag from PEP 803 - #5193

Merged
jaraco merged 23 commits into
pypa:mainfrom
ngoldbaum:abi3.abi3t
Oct 19, 2026
Merged

jaraco merged 23 commits into
pypa:mainfrom
ngoldbaum:abi3.abi3t

Conversation

@ngoldbaum

@ngoldbaum ngoldbaum commented Mar 3, 2026 •

Copy link
Copy Markdown
Contributor

Summary of changes

Fixes #5205

PEP 803 proposes a free-threaded stable ABI. I'm working on ecosystem support for a free-threaded stable ABI and end-to-end tests for PEP 803. PEP 803 was accepted in March and there will be support for it in Python 3.15.

This PR is set up to require a free-threaded build to produce abi3.abi3t wheels. It does this by making builds where py_limited_api=True is set on the free-threaded build on 3.15 or newer produce abi3t extensions and abi3.abi3t wheels.

In principle it's possible to do this on the GIL-enabled build as well but it'll require at least a new Extension keyword argument py_target_abi3t=True maybe?

As far as I can tell, py_limited_api is undocumented, so this doesn't add new docs, although I'd be happy to explore that if you'd like to see docs for this.

Pull Request Checklist

@ngoldbaum

ngoldbaum commented Mar 6, 2026 •

Copy link
Copy Markdown
Contributor Author

I'll do another pass next week to get the tests passing here. In the meantime, I'd appreciate comments.

@ngoldbaum

ngoldbaum commented Mar 16, 2026 •

Copy link
Copy Markdown
Contributor Author

In order to make the diff coverage happy, I'll probably need to:

  • Add CI for Python 3.15
  • Add free-threaded CI coverage for 3.14 and 3.15
  • Add a new test extension that sets py_limited_api = True for an Extension object and then also sets py_limited_api=cp315 in setup.cfg.

I'm going to punt on that for now but I hope to come back to that. I also think free-threaded CI coverage is probably needed independent of the PEP 803 work.

@ngoldbaum

Copy link
Copy Markdown
Contributor Author

Actually, on second thought, I think I might be able to use unittest.mock to fake a free-threaded build.

@ngoldbaum

Copy link
Copy Markdown
Contributor Author

Fun! It looks like a more-itertools release broke the type checking test.

@ngoldbaum

Copy link
Copy Markdown
Contributor Author

I looked at Python 3.15 testing a bit today and ran into ronaldoussoren/pyobjc#671. Even if that isn't a problem on CI, the full pyobjc dependency tree takes quite a while to build, so any 3.15 testing will be Linux-only for now probably.

It probably does make sense to add an abi3t test extension that requires Python 3.15 or newer, but I'm going to punt on that for now.

Besides that issue, all code in this PR is tested.

@rgommers rgommers left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for working on this @ngoldbaum. Overall this PR looks solid, I only found one minor issue in one of the added tests in test_build_ext.py.

Comment thread setuptools/tests/test_build_ext.py Resolved
Comment thread setuptools/command/bdist_wheel.py Resolved
Extension(
name="extension",
sources=["extension.c"],
py_limited_api=True

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This change looks correct and is a bug fix for the existing test, which didn't quite make sense before - I think given the test_limited_abi test name, it was intended to test the Limited API indeed.

@ngoldbaum

Copy link
Copy Markdown
Contributor Author

I marked this ready for review and updated the PR description. @jaraco @abravalheri I'd love your opinions on this.

@mergify

mergify Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

@ngoldbaum

Copy link
Copy Markdown
Contributor Author

Hi @jaraco Python 3.15rc1 is out and it would really help downstream libraries to be able to test this.

@mergify

mergify Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

@ngoldbaum

Copy link
Copy Markdown
Contributor Author

Yay, CI is green! (except the continue-on-error job #5251 addresses).

@dalcinl

dalcinl commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

@ngoldbaum I think your idea of adding and additional py_target_abi3t=False argument (note: False as default) to Extension is the right way to go. Explicit is better than implicit.

Building for a target ABI should be decoupled from the runtime. The new abi3 vs abi3t situation is no really different than using for example Python 3.14 to build a cp310-abi3 wheel targeting Python 3.10+, which is something that Python headers and the current setuptools machinery support just fine.

@ngoldbaum

Copy link
Copy Markdown
Contributor Author

Before I write more code here I'd
appreciate getting some input from a maintainer about how they'd like this to look.

@dalcinl

dalcinl commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

@ngoldbaum While you want for maintainer feedback, in the meantime maybe you could take a look at the following for future consideration?

Please take a look at this code that decides the extension suffix:
https://github.com/pypa/setuptools/blob/main/setuptools/command/build_ext.py#L79

Up to Python 3.14, the code above was fine, it would return the first and only abi3 suffix, for example:

$ python3.14 -c 'from importlib.machinery import EXTENSION_SUFFIXES as es; print(es)'
['.cpython-314-x86_64-linux-gnu.so', '.abi3.so', '.so']

However, in Python 3.15, things look a bit different:
With a GIL-enabled Python

$ python3.15 -c 'from importlib.machinery import EXTENSION_SUFFIXES as es; print(es)'
['.cpython-315-x86_64-linux-gnu.so', '.abi3-x86_64-linux-gnu.so', '.abi3.so', '.abi3t-x86_64-linux-gnu.so', '.abi3t.so', '.so']

With a free-threaded Python

$ python3.15t -c 'from importlib.machinery import EXTENSION_SUFFIXES as es; print(es)'
['.cpython-315t-x86_64-linux-gnu.so', '.abi3t-x86_64-linux-gnu.so', '.abi3t.so', '.so']

Therefore, the get_abi3_suffix() code produces different results depending on you running on GIL or non-GIL Python. That cannot be right. As I commented before, the build-time interpreter should not influence the output, it is the user who should decide, for whatever mechanism, whether to build abi3 vs abi3t under Python 3.15+. This is something that this PR should eventually address, that is, the extension suffix should not depend on the GIL vs non-GIL build of the running, build-time interpreter.

@rgommers

Copy link
Copy Markdown
Contributor

As I commented before, the build-time interpreter should not influence the output, it is the user who should decide, for whatever mechanism, whether to build abi3 vs abi3t under Python 3.15+

That seems unworkable. For a free-threaded interpreter, there's only one supported value (abi3t). For a GIL-enabled interpreter, abi3 has to be the default for backwards compat reasons. Targeting abi3t from a GIL-enabled interpreter therefore has to be opt-in.

All build backends will do it like this I believe.

@dalcinl

dalcinl commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

That seems unworkable.

I did not phrase things very well. I was not trying to make a point about defaults, and I understand the backward compatibility implication.

Let me restate my comments this way: I'm a module author, I wrote my code such that it can build abi3t. I setup my build backend to request for abi3t (I hope build backends will have explicit ways to support such a request, that's why I wrote #5193 (comment) ). Therefore, if I asked for abi3t, I should get an abi3t extension module, no matter whether I'm running the build with a GIL or non-GIL interpreter. IOW, being forced to run a free-threaded interpreter to get a abi3t build seems a bit ridiculous to me, a quite backwards to the "unifying" and universal nature of the abi3t builds for Py 3.15+.

Targeting abi3t from a GIL-enabled interpreter therefore has to be opt-in.

Exactly! And that's what I was trying to point out. Currently, even with the current state of this PR, a GIL-enabled interpreter produces an abi3 module tag. Opt-in mechanisms are missing.

This PR is mostly about fixing the wheel tagging. The extension module suffix also needs some additional work.

Or maybe there is something I got fundamentally wrong, and a GIL-enabled interpreter cannot really produce an abi3t extension module that can run both on GIL-enabled and free-threaded interpreters?

@rgommers

Copy link
Copy Markdown
Contributor

Ah, that makes perfect sense now, thanks @dalcinl.

Or maybe there is something I got fundamentally wrong, and a GIL-enabled interpreter cannot really produce an abi3t extension module that can run both on GIL-enabled and free-threaded interpreters?

No, you got nothing wrong. I think that's still two: there should be UX to tell the build backend to produce abi3t on a GIL-enabled build also. So something like limited_api=True, target_abi3t=True or limited_api='abi3t'.

In meson-python we left that for step two; since it's opt-in anyway we can add that later in a backwards-compatible way. That's probably the case here too.

@ngoldbaum

Copy link
Copy Markdown
Contributor Author

Also just to clarify, I'm happy to add UI to support abi3t builds with a GIL-enabled interpreter, I just need a setuptools maintainer to give the OK and tell me what API they'd prefer.

@jameshilliard jameshilliard left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A few cross-compilation recommendations from the Buildroot case. Selecting target sysconfig data does not change the interpreter executing setuptools: sys.version_info, sys.implementation, and the import loader still describe the build machine. Validation, extension naming, and wheel tagging need to consume the same target metadata.

The validation and filename/tag examples below were reproduced with isolated PR functions and real or simulated target configuration, not full cross-builds. Buildroot normally uses matching host/target Python versions, so the different-version cases are broader backend correctness tests rather than a claim about its default configuration.

For complete cross-build safety, the pre-existing runtime-derived fallbacks also need auditing. For example, _is_32bit_interpreter() should not rewrite a target platform using the build interpreter's struct.calcsize("P"); target SIZEOF_VOID_P, where available, is the relevant value. Likewise, implementation/debug fallbacks should not silently substitute host information for an explicitly configured target. These are existing limitations, not all introduced by this PR.

raise ValueError(f"py-limited-api must match '{PY_LIMITED_API_PATTERN}'")

if sysconfig.get_config_var("Py_GIL_DISABLED"):
if sysconfig.get_config_var("Py_GIL_DISABLED") and sys.version_info < (3, 15):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could this compare the configured target Python version rather than sys.version_info? Py_GIL_DISABLED can come from target sysconfig, but sys.version_info always describes the running build interpreter. Exercising this validation function with independent host/target values gives:

  • Host 3.14, target 3.15t: rejects a target that supports the free-threaded stable ABI.
  • Host 3.15, target 3.14t: accepts a target that does not.

For CPython POSIX builds, sysconfig.get_config_var("VERSION") comes from the selected target build data. A target-version helper could use that or equivalent explicit target metadata, with platform-aware parsing and a native-build fallback rather than silently using the host for a known cross-build. The minimum version requested by py_limited_api is a separate value.

Avoid replacing this with sysconfig.get_python_version(), py_version_short, or py_version_nodot: CPython derives those from the running interpreter too. With a real AArch64 3.14 sysconfig loaded into a 3.15 build interpreter, I get VERSION="3.14" but py_version_short="3.15" and py_version_nodot="315".

def stable_abi_tag(impl_name, impl_version):
abi_tag = None
if impl_name == "cp" and impl_version[0] == '3':
if sysconfig.get_config_var("Py_GIL_DISABLED"):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reading the target's Py_GIL_DISABLED here is appropriate for choosing the default ABI, but extension naming needs to use the same target information. The existing get_abi3_suffix() still scans the build interpreter's EXTENSION_SUFFIXES. With a GIL-enabled x86-64 3.15 build interpreter and a simulated AArch64 3.15t target, this function produces abi3.abi3t while the filename helper returns .abi3-x86_64-linux-gnu.so. That filename cannot be imported by the target.

Please keep the ABI choice consistent across these paths and derive the filename from target configuration (Py_GIL_DISABLED, SOABI_PLATFORM, and EXT_SUFFIX where supported), preserving Windows and non-CPython behavior rather than assuming every platform uses .so. #5352 addresses the target-derived filename part.

This cross-build consistency issue is separate from the optional API discussed above for explicitly requesting abi3t against a GIL-enabled target; target configuration can provide the default without making it depend on the build interpreter.

(t.interpreter, t.abi, plat_name) for t in tags.sys_tags()
]
assert tag in supported_tags, (
sys_tags = (f"{t.interpreter}-{t.abi}-{plat_name}" for t in tags.sys_tags())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The supported-tag check also needs target inputs. tags.sys_tags() is defined in terms of the running interpreter; its CPython path defaults to sys.version_info[:2]. Replacing only each tag's platform with plat_name does not fix the interpreter version/ABI assumptions, so a target-correct wheel can still be rejected after fixing the earlier version check.

For the CPython path, could this use the documented tags.cpython_tags(python_version=target_version, abis=target_abis, platforms=[plat_name]) interface, with all three inputs derived from the configured target? Use the target interpreter's version-specific ABI to generate its supported tags, not the wheel's requested compressed abi3.abi3t tag. Preserve separate handling for other implementations.

The nearby tags.interpreter_name() / tags.interpreter_version() calls should consume the same target description as well: they otherwise use sys.implementation and the host-derived py_version_nodot/sys.version_info. This is a pre-existing limitation in this path, but changing the validation conditional alone would leave it in place.

Reference: https://packaging.pypa.io/en/stable/tags.html#packaging.tags.cpython_tags

"get_config_var",
lambda name: 1 if name == "Py_GIL_DISABLED" else get_config_var(name),
)
monkeypatch.setattr(sys, "version_info", (3, 14, 5))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could we add cross-build tests that vary build-interpreter information and target sysconfig independently? This test changes the running version and target free-threaded flag together, so it cannot establish that the version check is using target data.

Useful cases are a 3.14 build interpreter with a 3.15t target, the reverse version pairing, and same-version builds where GIL mode and architecture differ. Holding the target configuration fixed while changing host metadata should leave validation, extension filenames, and wheel tags unchanged.

It would also help to check the extension filename inside the resulting wheel, including --skip-build, and keep native/Windows/non-CPython coverage. That would catch a correct-looking wheel tag paired with a host-derived extension suffix.

@jaraco

jaraco commented Oct 19, 2026

Copy link
Copy Markdown
Member

Thanks for your patience here @ngoldbaum, and thanks @dalcinl @rgommers @jameshilliard for the design discussion. Here's where I've landed, including the API answer you've been waiting on.

How this relates to #5352

These are two halves of one problem, and they touch disjoint source files:

#5193 #5352
Source command/bdist_wheel.py command/build_ext.py
Fixes the wheel tag the extension filename
Helper stable_abi_tag() rewritten get_abi3_suffix()

An abi3t build needs both — cp315-abi3.abi3t-<plat> on the wheel and .abi3t.so on the file inside it — so neither PR alone produces a coherent artifact. They collide only in tests.

Both already select abi3 vs abi3t from Py_GIL_DISABLED, and because sysconfig honours _PYTHON_SYSCONFIGDATA_NAME, that is the target's flag under cross-compilation. So once both land, cross-builds agree with no new API. They only both say abi3 on a native GIL-enabled interpreter — exactly the gap @dalcinl identified.

Worth recording that @dalcinl diagnosed #5351 in this thread on 2026-08-28, seven weeks before it was filed, with the same EXTENSION_SUFFIXES listing and the conclusion "that cannot be right". #5352 is the fix for that, as @jameshilliard noted in his review above.

Plan

1. #5352 lands first. It is a regression against released 3.15 and is behavior-preserving for native builds — comparing the current and proposed implementations directly:

python 3.14.8   current='.abi3.so'          #5352='.abi3.so'          same
python 3.15.0   current='.abi3-darwin.so'   #5352='.abi3-darwin.so'   same

It diverges only when sysconfig describes a target other than the running interpreter.

2. Then rebase this PR on it, dropping test_stable_abi_filename and the build_ext_mod import. Please note a trap here: git merge-tree reports only one textual conflict (in test_bdist_wheel.py), and test_build_ext.py auto-merges cleanly but broken. test_stable_abi_filename patches build_ext_mod.EXTENSION_SUFFIXES, which #5352 deletes, and monkeypatch.setattr raises AttributeError on a missing attribute — so the test would error rather than silently pass. #5352's test_abi3_filename_cross already covers exactly what that test was reaching for: it is parametrized over abi_flags = ['', 't'] and asserts eggs.abi3{t}-aarch64-linux-gnu.so using sysconfig monkeypatching, which keeps working once EXTENSION_SUFFIXES is gone.

For the real conflict, keep this PR's EXAMPLES["abi3extension-dist"] restructure; #5352's skipif reads 'abi3extension-dist' not in EXAMPLES and survives it unchanged.

3. Two prerequisites before this merges.

The vendored packaging is 26.0, whose _abi3_applies(python_version, threading) returns False for threading, so sys_tags() yields neither abi3 nor abi3t on a free-threaded build and this PR's assert any(t in supported_tags for t in possible_tags) cannot pass there. packaging 26.3 added the tag (pypa/packaging#1099). #5256 does not help — it keeps packaging-26.0 and only rewrites that dist-info. I'm preparing a targeted refresh of just packaging.

The second is a 3.15t CI job. We currently run 3.14t and 3.15 but not 3.15t, which is why this gap is invisible and why green CI here is misleading. You raised this yourself in March; I agree it's needed, and it is the only thing that will actually exercise the abi3t path.

The API question

Ship this PR without the opt-in, as @rgommers suggested — free-threaded interpreter produces abi3t, GIL-enabled produces abi3, and the target-sysconfig default covers every cross-build once #5352 is in. That leaves only "native GIL-enabled interpreter targeting abi3t" uncovered, and it can be added compatibly later.

When we do add it, it should be a separate explicit option, not py_limited_api='abi3t'. py_limited_api already means two different things in setuptools — a bool on Extension and a cpNN tag on bdist_wheel — and giving it a third meaning would make that worse. @dalcinl's py_target_abi3t=False is the right shape; I'm open to bikeshedding the name on a follow-up issue.

Separately

@jameshilliard's review raises residual host-vs-target leaks that neither PR addresses: _validate_py_limited_api compares sys.version_info, and get_tag() relies on tags.sys_tags(), interpreter_name() and interpreter_version(), all derived from the running interpreter, so a target-correct wheel can still be rejected. I'll file that separately — it shouldn't block either PR, but it will affect the next cross-compiling user.

Reconciles with pypa#5352, which landed the target-sysconfig rewrite of
get_abi3_suffix() in build_ext.py. The two changes are complementary --
pypa#5352 fixes the extension filename, this branch fixes the wheel tag --
so the only collisions are in tests.

test_bdist_wheel.py: keep both imports; stable_abi_tag from here and
get_abi3_suffix from pypa#5352.

test_build_ext.py: git merges this file cleanly but leaves it broken.
test_stable_abi_filename patched build_ext.EXTENSION_SUFFIXES, and pypa#5352
removed that import, so monkeypatch.setattr raised AttributeError on both
parametrizations. Rather than drop the test (which would orphan the
check_stable_abi helper and lose native coverage), drive it from the
configured target: it now asserts abi3t on a free-threaded build and abi3
otherwise, which exercises the real code path instead of a simulated
suffix list. pypa#5352's test_abi3_filename_cross already covers cross-build
naming for both ABIs, so the simulated suffix fixtures are redundant.

Ref pypa#5193 (comment)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jameshilliard pushed a commit to jameshilliard/setuptools that referenced this pull request Oct 19, 2026
Refreshes only `packaging` and raises its declared floor to match, using
the technique from `tools/vendored.py` (uv pip install --target, pinned
to the minimum supported Python, binary-only) scoped to this one
dependency rather than re-vendoring everything.

packaging 26.3 adds the abi3t tag from PEP 803 (pypa/packaging#1099).
The vendored 26.0 could not emit it: its `_abi3_applies(python_version,
threading)` returns False for free-threaded builds, so `sys_tags()`
yields neither abi3 nor abi3t there. That blocks pypa#5193, whose supported
-tag assertion cannot pass on a free-threaded 3.15.

packaging has no runtime dependencies, so nothing else in _vendor moves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jaraco
jaraco merged commit 39a7c2e into pypa:main Oct 19, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FR] Support building extensions and wheels with the abi3t ABI (PEP 803)

6 participants