Skip to content

Enable compiling specific extensions against the Python 3.11 limited API - #18211

Closed
astrofrog wants to merge 7 commits into
astropy:mainfrom
astrofrog:partial-limited-api
Closed

astrofrog wants to merge 7 commits into
astropy:mainfrom
astrofrog:partial-limited-api

Conversation

@astrofrog

@astrofrog astrofrog commented Jun 3, 2025 •

Copy link
Copy Markdown
Member

As discussed in #18163, it is possible to build C/Cython extensions against a limited subset of the Python C API. If all extensions are build against this API, it is then possible to tag a wheel as abi3 which then can be built once on the oldest supported Python version and will then work with future versions.

This PR enables compilation of specific extensions against the limited API, to make sure we don't accidentally make those extensions incompatible with the limited API. Some extensions are not yet compatible and we can work over time on making those work too (in follow-up PRs).

I've included a couple of minor fixes that allow the FITS compression module and the VOTable module to build.

I don't think there is a downside to this PR - we are basically just restricting what symbols we can use in C extensions. Even if we decide to never build abi3 wheels, the present PR shouldn't do any harm.

In future if all our extensions work with the limited API, we can switch to using the feature of extension-helpers which will automatically add the settings to all the Extensions and remove the changes in this PR.

@github-actions

github-actions Bot commented Jun 3, 2025

Copy link
Copy Markdown
Contributor

Thank you for your contribution to Astropy! 🌌 This checklist is meant to remind the package maintainers who will review this pull request of some common things to look for.

  • Do the proposed changes actually accomplish desired goals?
  • Do the proposed changes follow the Astropy coding guidelines?
  • Are tests added/updated as required? If so, do they follow the Astropy testing guidelines?
  • Are docs added/updated as required? If so, do they follow the Astropy documentation guidelines?
  • Is rebase and/or squash necessary? If so, please provide the author with appropriate instructions. Also see instructions for rebase and squash.
  • Did the CI pass? If no, are the failures related? If you need to run daily and weekly cron jobs as part of the PR, please apply the "Extra CI" label. Codestyle issues can be fixed by the bot.
  • Is a change log needed? If yes, did the change log check pass? If no, add the "no-changelog-entry-needed" label. If this is a manual backport, use the "skip-changelog-checks" label unless special changelog handling is necessary.
  • Is this a big PR that makes a "What's new?" entry worthwhile and if so, is (1) a "what's new" entry included in this PR and (2) the "whatsnew-needed" label applied?
  • At the time of adding the milestone, if the milestone set requires a backport to release branch(es), apply the appropriate "backport-X.Y.x" label(s) before merge.

@astrofrog astrofrog added this to the v7.2.0 milestone Jun 3, 2025
@Cadair

Cadair commented Jun 3, 2025

Copy link
Copy Markdown
Member

perhaps we should add a note in the pyproject.toml about bumping these hex versions when we bump min python?

@astrofrog

Copy link
Copy Markdown
Member Author

@Cadair - yes that probably makes sense, though interestingly we don't have to update the hexes

@astrofrog

Copy link
Copy Markdown
Member Author

@neutrinoceros - just to check, based on your comment here - adding these options doesn't stop one from doing free-threaded builds, right? It's just that in those builds the limited API stuff will be ignored and compile as if it wasn't there?

@neutrinoceros

Copy link
Copy Markdown
Contributor

thanks for asking ! Sadly, it does seem to break free-threaded builds. Here's the detailed stderr from my local attempt:

Stderr
[stderr]
      no previously-included directories found matching 'docs/_build'
      no previously-included directories found matching 'build'
      warning: no previously-included files matching '*.o' found anywhere in distribution
      astropy/wcs/src/wcslib_prjprm_wrap.c:616:15: warning: unused variable 'value' [-Wunused-variable]
        616 |     PyObject* value = NULL;
            |               ^~~~~
      1 warning generated.
      wcsbth.c:30561:13: warning: code will never be executed [-Wunreachable-code]
       30561 |                                         if ( ! yyg->yy_did_buffer_switch_on_eof )
             |                                                ^~~
      1 warning generated.
      wcspih.c:25016:13: warning: code will never be executed [-Wunreachable-code]
       25016 |                                         if ( ! yyg->yy_did_buffer_switch_on_eof )
             |                                                ^~~
      wcspih.l:2611:9: warning: variable 'ndis' set but not used [-Wunused-but-set-variable]
       2611 |     int ndis = 0;
            |         ^
      2 warnings generated.
      wcsulex.c:8880:13: warning: code will never be executed [-Wunreachable-code]
       8880 |                                         if ( ! yyg->yy_did_buffer_switch_on_eof )
            |                                                ^~~
      1 warning generated.
      wcsutrn.c:5209:13: warning: code will never be executed [-Wunreachable-code]
       5209 |                                         if ( ! yyg->yy_did_buffer_switch_on_eof )
            |                                                ^~~
      1 warning generated.
      ld: warning: search path 'Modules/_hacl' not found
      In file included from astropy/utils/src/compiler.c:1:
      /Users/clm/.local/share/uv/python/cpython-3.13.3+freethreaded-macos-aarch64-none/include/python3.13t/Python.h:51:4: error:
      "The limited API is not currently supported in the free-threaded build"
         51 | #  error "The limited API is not currently supported in the free-threaded build"
            |    ^
      In file included from astropy/utils/src/compiler.c:1:
      In file included from
      /Users/clm/.local/share/uv/python/cpython-3.13.3+freethreaded-macos-aarch64-none/include/python3.13t/Python.h:72:
      /Users/clm/.local/share/uv/python/cpython-3.13.3+freethreaded-macos-aarch64-none/include/python3.13t/object.h:213:5:
      error: unknown type name 'PyMutex'
        213 |     PyMutex ob_mutex;           // per-object lock
            |     ^
      /Users/clm/.local/share/uv/python/cpython-3.13.3+freethreaded-macos-aarch64-none/include/python3.13t/object.h:320:22:
      error: call to undeclared function '_Py_atomic_load_uint32_relaxed'; ISO C99 and later do not support implicit function
      declarations [-Wimplicit-function-declaration]
        320 |     uint32_t local = _Py_atomic_load_uint32_relaxed(&ob->ob_ref_local);
            |                      ^
      /Users/clm/.local/share/uv/python/cpython-3.13.3+freethreaded-macos-aarch64-none/include/python3.13t/object.h:324:25:
      error: call to undeclared function '_Py_atomic_load_ssize_relaxed'; ISO C99 and later do not support implicit function
      declarations [-Wimplicit-function-declaration]
        324 |     Py_ssize_t shared = _Py_atomic_load_ssize_relaxed(&ob->ob_ref_shared);
            |                         ^
      /Users/clm/.local/share/uv/python/cpython-3.13.3+freethreaded-macos-aarch64-none/include/python3.13t/object.h:358:13:
      error: call to undeclared function '_Py_atomic_load_uint32_relaxed'; ISO C99 and later do not support implicit function
      declarations [-Wimplicit-function-declaration]
        358 |     return (_Py_atomic_load_uint32_relaxed(&op->ob_ref_local) ==
            |             ^
      /Users/clm/.local/share/uv/python/cpython-3.13.3+freethreaded-macos-aarch64-none/include/python3.13t/object.h:358:63:
      warning: comparison of integers of different signs: 'int' and 'unsigned int' [-Wsign-compare]
        358 |     return (_Py_atomic_load_uint32_relaxed(&op->ob_ref_local) ==
            |             ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ^
        359 |             _Py_IMMORTAL_REFCNT_LOCAL);
            |             ~~~~~~~~~~~~~~~~~~~~~~~~~
      /Users/clm/.local/share/uv/python/cpython-3.13.3+freethreaded-macos-aarch64-none/include/python3.13t/object.h:396:9:
      error: call to undeclared function '_Py_IsOwnedByCurrentThread'; ISO C99 and later do not support implicit function
      declarations [-Wimplicit-function-declaration]
        396 |     if (_Py_IsOwnedByCurrentThread(ob)) {
            |         ^
      /Users/clm/.local/share/uv/python/cpython-3.13.3+freethreaded-macos-aarch64-none/include/python3.13t/object.h:436:5:
      error: call to undeclared function '_Py_atomic_store_ssize_relaxed'; ISO C99 and later do not support implicit function
      declarations [-Wimplicit-function-declaration]
        436 |     _Py_atomic_store_ssize_relaxed(&ob->ob_size, size);
            |     ^
      /Users/clm/.local/share/uv/python/cpython-3.13.3+freethreaded-macos-aarch64-none/include/python3.13t/object.h:810:22:
      error: call to undeclared function '_Py_atomic_load_uint32_relaxed'; ISO C99 and later do not support implicit function
      declarations [-Wimplicit-function-declaration]
        810 |     uint32_t local = _Py_atomic_load_uint32_relaxed(&op->ob_ref_local);
            |                      ^
      /Users/clm/.local/share/uv/python/cpython-3.13.3+freethreaded-macos-aarch64-none/include/python3.13t/object.h:816:9:
      error: call to undeclared function '_Py_IsOwnedByCurrentThread'; ISO C99 and later do not support implicit function
      declarations [-Wimplicit-function-declaration]
        816 |     if (_Py_IsOwnedByCurrentThread(op)) {
            |         ^
      /Users/clm/.local/share/uv/python/cpython-3.13.3+freethreaded-macos-aarch64-none/include/python3.13t/object.h:817:9:
      error: call to undeclared function '_Py_atomic_store_uint32_relaxed'; ISO C99 and later do not support implicit function
      declarations [-Wimplicit-function-declaration]
        817 |         _Py_atomic_store_uint32_relaxed(&op->ob_ref_local, new_local);
            |         ^
      /Users/clm/.local/share/uv/python/cpython-3.13.3+freethreaded-macos-aarch64-none/include/python3.13t/object.h:820:9:
      error: call to undeclared function '_Py_atomic_add_ssize'; ISO C99 and later do not support implicit function declarations
      [-Wimplicit-function-declaration]
        820 |         _Py_atomic_add_ssize(&op->ob_ref_shared, (1 << _Py_REF_SHARED_SHIFT));
            |         ^
      /Users/clm/.local/share/uv/python/cpython-3.13.3+freethreaded-macos-aarch64-none/include/python3.13t/object.h:904:22:
      error: call to undeclared function '_Py_atomic_load_uint32_relaxed'; ISO C99 and later do not support implicit function
      declarations [-Wimplicit-function-declaration]
        904 |     uint32_t local = _Py_atomic_load_uint32_relaxed(&op->ob_ref_local);
            |                      ^
      /Users/clm/.local/share/uv/python/cpython-3.13.3+freethreaded-macos-aarch64-none/include/python3.13t/object.h:909:9:
      error: call to undeclared function '_Py_IsOwnedByCurrentThread'; ISO C99 and later do not support implicit function
      declarations [-Wimplicit-function-declaration]
        909 |     if (_Py_IsOwnedByCurrentThread(op)) {
            |         ^
      /Users/clm/.local/share/uv/python/cpython-3.13.3+freethreaded-macos-aarch64-none/include/python3.13t/object.h:911:9:
      error: call to undeclared function '_Py_atomic_store_uint32_relaxed'; ISO C99 and later do not support implicit function
      declarations [-Wimplicit-function-declaration]
        911 |         _Py_atomic_store_uint32_relaxed(&op->ob_ref_local, local);
            |         ^
      /Users/clm/.local/share/uv/python/cpython-3.13.3+freethreaded-macos-aarch64-none/include/python3.13t/object.h:913:13:
      error: call to undeclared function '_Py_MergeZeroLocalRefcount'; ISO C99 and later do not support implicit function
      declarations [-Wimplicit-function-declaration]
        913 |             _Py_MergeZeroLocalRefcount(op);
            |             ^
      /Users/clm/.local/share/uv/python/cpython-3.13.3+freethreaded-macos-aarch64-none/include/python3.13t/object.h:917:9:
      error: call to undeclared function '_Py_DecRefShared'; ISO C99 and later do not support implicit function declarations
      [-Wimplicit-function-declaration]
        917 |         _Py_DecRefShared(op);
            |         ^
      1 warning and 16 errors generated.
      error: command '/usr/bin/cc' failed with exit code 1

@Cadair

Cadair commented Jun 3, 2025

Copy link
Copy Markdown
Member

That seems to be something they are going to have to support at some point?

@Cadair

Cadair commented Jun 3, 2025

Copy link
Copy Markdown
Member

I guess to support both we will need a way to conditionally add the PY_LIMITED_API if we aren't trying to do a free threaded build?

Personally limited API > free threading in that it's more immediately useful, though it would be nice to not break testing free threading.

@astrofrog

Copy link
Copy Markdown
Member Author

I guess to support both we will need a way to conditionally add the PY_LIMITED_API if we aren't trying to do a free threaded build?

Agree this seems the best approach going forward if we can - @neutrinoceros is there a way to know if one is currently doing a free-threaded build?

I guess the question is whether we will manage to make astropy use the limited API or successfully build and work for free-threaded Python first 😅

@pllim pllim added Python 3.14 Python 3.11 Build all wheels Run all the wheel builds rather than just a selection Extra CI Run cron CI as part of PR and removed Python 3.14 labels Jun 3, 2025
@pllim

pllim commented Jun 3, 2025

Copy link
Copy Markdown
Member

Thanks!

Are the exotic arch failures related?

@astrofrog

Copy link
Copy Markdown
Member Author

The exotic arch failures do seem related

@neutrinoceros

Copy link
Copy Markdown
Contributor

Agree this seems the best approach going forward if we can - @neutrinoceros is there a way to know if one is currently doing a free-threaded build?

yes. Use sysconfig.get_config_var("Py_GIL_DISABLED"), just like we do in pyerfa:
https://github.com/liberfa/pyerfa/blob/9d60f157989d1d847ad05a1a4a642f25c0840332/setup.py#L30-L32

@astrofrog

Copy link
Copy Markdown
Member Author

Just for the record, we haven't released this yet but in the main branch of the openastronomy workflows we properly support tox environments such as py313t to indicate the free-threaded version, so we could use that to set up the CI job.

@neutrinoceros

Copy link
Copy Markdown
Contributor

I know the tests won't pass yet

I would actually expect them to pass, as long as we keep the job single-threaded !

@astrofrog

Copy link
Copy Markdown
Member Author

Ok well then we should definitely add a CI job. I'll make a new release of the workflows.

@astrofrog

Copy link
Copy Markdown
Member Author

@neutrinoceros - done: https://github.com/OpenAstronomy/github-actions-workflows/releases/tag/v1.17.0

Would you have time to set up a new CI job? It might be as simple as just adding py313t-test in the list of jobs to run, and maybe a tweak to tox.ini to make sure that is recognized.

@neutrinoceros

Copy link
Copy Markdown
Contributor

You got it. I'll do this in an hour (maybe less) !

@Cadair

Cadair commented Jun 5, 2025

Copy link
Copy Markdown
Member

GAH, GIVE ME THE GITHUB MERGE/REBASE BUTTON ASTROPY WHY DO YOU HATE ME.

Comment thread astropy/convolution/setup_package.py Outdated
@astrofrog
astrofrog force-pushed the partial-limited-api branch from c4d2003 to 9215891 Compare June 5, 2025 13:33
@Cadair

Cadair commented Jun 5, 2025

Copy link
Copy Markdown
Member

Well it successfully failed.

@pllim

pllim commented Jun 5, 2025

Copy link
Copy Markdown
Member

Re: rebase option -- You mean this?

Screenshot 2025-06-05 102303

@Cadair

Cadair commented Jun 5, 2025

Copy link
Copy Markdown
Member

yes

@pllim

pllim commented Jun 5, 2025

Copy link
Copy Markdown
Member

OK I enabled it. 🙈

Comment thread astropy/convolution/setup_package.py Outdated
kwargs = {}
if USE_PY_LIMITED_API:
kwargs["py_limited_api"] = True
kwargs['define_macros'] = [("Py_LIMITED_API", "0x030B0000")]

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 version hex should in general be both

  • kept in sync with our python-requires setting
  • match the runtime version used for the build, because it is not actually safe to build, e.g. cp311-abi3 wheels under Python 3.12

The general solution is to compute the hex dynamically.
Feel free to copy my implementation of this https://gist.github.com/neutrinoceros/eafa1cbcda6d770f8a2604476994a76a

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@neutrinoceros - ok so I think I see the logic here, but just to be clear, if we do this - e.g. define the version hex for Python 3.13 and build a wheel on Python 3.13, and if we've said the wheel should be tagged as abi3 in pyproject.toml, then we'll effectively produce an abi3 wheel that doesn't actually work on Python 3.11 for example?

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.

yes. Wheels always hold a python version tag (e.g. cp311), even if they also have the abi3 tag. A cp313-abi3 wheel will work for Python 3.13 and 3.14, but not 3.11 or 3.12

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.

... which reminds me, and for completion setuptools doesn't natively modify the final tag.
See https://cibuildwheel.pypa.io/en/stable/faq/#abi3 and the linked example repo
Of course, we don't need to set this up in this PR, or as long as our wheels are not 100% abi3-compliant.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I don't get it though - in the past I've always just set the hex to the minimum version and it worked fine. The Python docs also say you should set it to the minimum supported version:

https://docs.python.org/3/c-api/stable.html#c.Py_LIMITED_API

Why is it not safe to build with Py_LIMITED_API set to the lowest supported version?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Just to rephrase this another way:

Let's say you have py-limited-api set to cp311 in pyproject.toml and Py_LIMITED_API set the 3.11 hex. If you build a wheel on Python 3.11 it will work in Python 3.11+. If you build a wheel on Python 3.13, it generates a cp311-abi3 wheel but it may be that because of a subtle change (e.g. argument type) in the API in 3.13, the wheel doesn't work properly on 3.11.

Now let's say you have py-limited-api set to cp311 in pyproject.toml and Py_LIMITED_API set the to be dependent on the version being used. If you build a wheel on Python 3.11 it will work in Python 3.11+. If you build a wheel on Python 3.13, it generates a cp311-abi3 wheel but this time you may even have used functions that didn't exist in Python 3.11 but the build will work fine and still be tagged cp311-abi3.

So in fact, setting Py_LIMITED_API actually increases your chances of issues if you were to build a wheel on Python 3.13 and try it out on 3.11.

In actual practice, I don't think any typical workflows would result in the scenario of building on Python 3.13 and using the wheel on 3.11, but in case it did happen, it seems that setting Py_LIMITED_API to 3.11 will minimize issues (even if the probability of issues is non-zero).

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.

Yet another argument for setting the hex dynamically is that abi3 will likely not be around for ever, and abi4 might be coming as soon as next year. When that happens, we'll need to compile at least two wheels per arch (supposedly cp312-abi3 + cp315-abi4).

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.

Now let's say you have py-limited-api set to cp311 in pyproject.toml

How would that work when abi4 is introduced ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I'm totally on board with setting the hex as well as the wheel tag dynamically now that I understand about abi3 and abi4. I'll have a think about how we can adjust extension-helpers to allow this and also whether we can avoid repeating boilerplate code here until we are able to simply turn on the limited API globally.

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 general solution is to compute the hex dynamically.
Feel free to copy my implementation of this

btw, I just found out that sys.hexversion exists, so there is a much simpler solution to the one I proposed earlier:

import sys 
ABI3_TARGET_HEX = hex(sys.hexversion & 0xFFFF00F0)

will dynamically produce the hex of the final x.y.0 version, where x and y are the major and minor version numbers at runtime.

if not sys.platform.startswith("win"):
extra_compile_args.append("-fPIC")

USE_PY_LIMITED_API = not 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.

I have a slight preference for making this option opt-in, namely by making it conditional on an env var (ASTROPY_LIMITED_API ?), because consumers who build from source generally don't need the portability, and should optimize for performance instead.

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.

and it's easy enough to set such an env var within our cibuildwheel config, that won't affect downstream consumers

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

We can make this opt-in for now since we aren't actually tagging the wheel abi3, but just to check, how do you envisage this working once we add the py-limited-api option to pyproject.toml? (in the sense that either the option is there or it isn't, it's difficult to control conditionally)

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.

Why does it need to be declared statistically ? None of the setuptools-based projects I worked on had this constraint.

@astrofrog astrofrog Jun 6, 2025 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Ah interesting, how do they declare it?

@neutrinoceros neutrinoceros Jun 6, 2025 •

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.

Dynamically, by subclassing wheel.bdist_wheel.bdist_wheel

See the example I linked in #18211 (comment)

@dhomeier

dhomeier commented Jun 5, 2025 •

Copy link
Copy Markdown
Contributor

The exotic arch failures do seem related

The three failing ones are all compiling for 3.12, but against system /usr/include/python3.13/object.h headers; looks like something in the build process in general is buggy at least for those archs.
(Compile-args e.g. -I/usr/lib/s390x-linux-gnu/python3-numpy/numpy/_core/include -I/home/runner/work/astropy/astropy/tests/include -I/usr/include/python3.13, but I am not sure of that is all the output of np.get_include or the last 2 are added somehow later).

@astrofrog

Copy link
Copy Markdown
Member Author

See #18236 for a much simpler alternative to this PR

@astrofrog

Copy link
Copy Markdown
Member Author

Closing in favor of #18236

@astrofrog astrofrog closed this Jun 9, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants