Skip to content

RFC: implement PEP 793/820 multi-phase module init (wcs) - #20494

Draft
neutrinoceros wants to merge 1 commit into
astropy:mainfrom
neutrinoceros:bld/pep793
Draft

neutrinoceros wants to merge 1 commit into
astropy:mainfrom
neutrinoceros:bld/pep793

Conversation

@neutrinoceros

Copy link
Copy Markdown
Contributor

Description

This is the first of a couple modules needing this compatibility layer. Here I'm focusing on getting a single one of them done correctly before migrating all of them, which I expect will be simpler now that I got the gist of it.

Contributes to #19478

AI Disclosure

N/A

  • I certify that I am human and that I take full responsibility for this pull request including all interactions with reviewers.

Merge method

  • By checking this box, the PR author has requested that maintainers do NOT use the "Squash and Merge" button. Maintainers should respect this when possible; however, the final decision is at the discretion of the maintainer that merges the PR.

@github-actions

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

Copy link
Copy Markdown
Member

I will try and review this shortly but just to check, does astropy.wcs import correctly on an abi3t build with these changes?

@neutrinoceros

Copy link
Copy Markdown
Contributor Author

I'm not sure actually. At this stage I'm trying to resolve build-time errors one by one but as long as I can't build the whole package without error I'm missing runtime validation. It should be possible to build only this one module with cp315-abi3t as a target but it'd require more tinkering and scafolding, which there's already a fair share of since Cython and setuptools don't have releases with support for it yet.

@astrofrog

Copy link
Copy Markdown
Member

I wonder if it would make more sense to just have one big PR - I know it's normally nice to split thing up, but if we can't know if it will work at runtime it's a bit of guesswork as to whether a change is valid or not. I wouldn't mind reviewing a big PR if I knew it encapsulated all the changes required to get it working.

@neutrinoceros

Copy link
Copy Markdown
Contributor Author

Ok, then I'll draft it for now

@neutrinoceros
neutrinoceros marked this pull request as draft September 29, 2026 09:02
Comment thread astropy/wcs/src/astropy_wcs.c Outdated
#endif
};

#ifdef _Py_OPAQUE_PYOBJECT

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'd probably do

Suggested change
#ifdef _Py_OPAQUE_PYOBJECT
#if (defined(Py_LIMITED_API) && Py_LIMITED_API >= 0x030F0000)) || (!defined(Py_LIMITED_API) && PY_VERSION_HEX >= 0x030F0000)

since _Py_OPAQUE_PYOBJECT is no longer the recommended way of detecting opaque objects (that's Py_TARGET_ABI3T). But also there's no reason not to do this on any 3.15+ build.

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.

I don't intend to support targeting a version of the limited API other than the one we're already building against, so, it seems to me that I could even simplify this condition to
#if PY_VERSION_HEX >= 0x030F0000
is that right ?

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.

That should be fine, but if it were my code I'd probably still go for the longer explicit version.

It's definitely a good rule when preparing your official wheels, but I've found it convenient to be able to build stuff locally on whatever version of Python you're developing on (in general rather than for Astropy specifically).

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.

fair enough. I encapsulated this condition into a _ASTROPY_MULTI_PHASE_MODULE_INIT pre-proc variable, and added an inline comment based on your explanation

{
PyObject* m;

wcs_errexc[0] = NULL; /* Success */

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.

All this is getting missed in the PEP-793 version. Which likely is important.

It probably wants to be in a Py_mod_exec slot.

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.

Thanks !
I just pushed a tentative implementation for it.

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.

An alternative to consider (that I think may be clearer) is to define module_exec unconditionally then define the old init function as:

PyMODINIT_FUNC
PyInit__wcs(void)
{
  PyObject* m;
  m = PyModule_Create(&moduledef);
  if (module_exec(m) != 0) {
    // Error
    Py_CLEAR(m);
  }
  return m;
}

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.

oh, indeed that's a lot better. I just pushed it. Thanks !

#endif

#ifdef _Py_OPAQUE_PYOBJECT
return 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.

The return value is guesswork: I'm assuming that 0 means "no exception" in this context, though the documentation is unclear
https://docs.python.org/3/c-api/module.html#c.Py_mod_exec

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.

Yeah it isn't clearly documented but I'm almost certain that 0 means "good"

@neutrinoceros neutrinoceros changed the title RFC: implement PEP 793/820 multi-stage module init (wcs) RFC: implement PEP 793/820 multi-phase module init (wcs) Sep 30, 2026
@neutrinoceros
neutrinoceros force-pushed the bld/pep793 branch 4 times, most recently from 44d4103 to a778bc8 Compare September 30, 2026 12:44
@neutrinoceros

Copy link
Copy Markdown
Contributor Author

I think I handled every point raised so far. @da-woods, let me know if you're happy with the current state before I proceed with other modules

@mhvk mhvk 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.

@neutrinoceros - I struggled similarly to you in #20183, but followed some of the CPython developer docs perhaps a little more closely (don't remember which, sadly). Anyway, some comments inline following that. See also #20183 itself.

Comment thread astropy/wcs/src/astropy_wcs.c Outdated
Comment thread astropy/wcs/src/astropy_wcs.c Outdated
Comment thread astropy/wcs/src/astropy_wcs.c Outdated
return 1;

import_array();
import_array1(1);

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 newer way to import numpy is

    if (PyArray_ImportNumPyAPI() < 0) {
        return NULL;
    }

though really that should still be in init, not exec.

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.

pushed. Though, to be clear, are you saying that PyArray_ImportNumPyAPI doesn't need to be called in the multi-phase init mode ?

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.

It does need to be called, but more easily in PyInit see comment further down: #20494 (comment)

Comment thread astropy/wcs/src/astropy_wcs.c Outdated
Comment thread astropy/wcs/src/astropy_wcs.c Outdated
PyInit__wcs(void)

{
PyObject* m;

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.

You don't need to create the module in the new version, rather something like,

PyMODINIT_FUNC PyInit__wcs(void)
{
    if (PyArray_ImportNumPyAPI() < 0) {
        return NULL;
    }
    return PyModuleDef_Init(&moduledef);
}

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.

I don't think that's correct: module_exec would not run.

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.

It does - you need something like what I have in #20183,

static PyModuleDef_Slot scaler_module_slots[] = {
    {Py_mod_exec, scaler_module_exec},
    {0, NULL},
};

and those slots get added to the module def struct.

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.

Maybe I'm misunderstanding something but it looks to me like the docs explicitly mention we cannot do that:

https://docs.python.org/3/c-api/module.html#c.PyModuleDef.m_slots

When using legacy single-phase initialization, m_slots must be NULL

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.

But we're not using legacy single-phase initialization any more! Or, at least, isn't that the point?

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 not just choose multi-phase? Might as well get it done and it works fine on older python (otherwise #20183 would not pass, nor would all those numpy pieces that have already been converted).

@da-woods da-woods Sep 30, 2026 •

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.

It is a fairly easy conversion. You add scaler_module_slots as @mhvk suggests, and then you change

PyMODINIT_FUNC
PyInit__wcs(void)
{
    // maybe initialize Numpy here too.
    return (PyObject*)&moduledef;
}

and you're done.

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.

Why not just choose multi-phase?

Oh I see what happened here: I read #20494 (comment)

also there's no reason not to do this on any 3.15+ build.

and concluded that there were (implicitly) reasons to keep single-phase init for Python 3.14 and older. Is it the case, really, and do we care ? It sounds like we don't, in which case I could just simplify this by a lot.

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.

No there wasn't any intended implicit meaning in that comment about Python 3.14 and older. I don't know any reason why you should deliberately keep single-phase init.

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.

For clarity, I think the init needs return PyModuleDef_Init(&moduledef);

@mhvk

mhvk commented Sep 30, 2026

Copy link
Copy Markdown
Contributor

p.s. Aside, nice to do this! If you need more examples, see PRs linked under numpy's overall effort to go to multi-phase initialization: numpy/numpy#29021

PyMODEXPORT_FUNC
PyModExport__wcs(void)
{
return module_slots;

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.

Suggested change
return module_slots;
if (PyArray_ImportNumPyAPI() < 0) {
return NULL;
}
return module_slots;

PyArray_ImportNumPyAPI definitely needs calling in both cases. I think @mhvk was proposing to put it there. I don't think it really matters where you put it provided it's called before you try to use Numpy. The documentation says it's fine to call multiple times.

This branch has not been deployed

No deployments
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.

4 participants