C API for coroutines, iterators, generators etc with native implementations

PyO3 (Rust bindings for Python) has a longstanding feature request for Rust’s async fn to be usable to make cross-language asynchronous code.

To do this in a way that is agnostic to the async runtime (on either the Python or Rust side) I think any framework-level support in PyO3 for async fn will create Python callables which return awaitable objects.

I was wondering, how would Python core devs want these awaitable objects to be implemented? Some ideas:

  • They could be PyCoroObject. PyCoro_New currently takes PyFrameObject for the first argument.
    • Could there be an alternative constructor which takes a function pointer as an argument?
    • Could there be a way to create a “native frame object” to supply as the PyFrameObject (i.e. one which contains a function pointer rather than Python bytecode)?
  • They could be bespoke awaitable types implemented by PyO3 (let’s name it pyo3_runtime.Coroutine). The challenge is then how does this type get distributed:
    • Do I distribute a pyo3_runtime package in PyPI and ask packagers of PyO3 extensions to depend on this package?
      • Cython and pybind11 (for example) don’t ship runtime packages so presumably don’t find this necessary.
    • Should PyO3 store pyo3_runtime.Coroutine inside the interpreter state (e.g. in PyInterpreterState_GetDict)?
      • This avoids the challenges of distribution but I think creates new problems like how is a user expected to e.g. from pyo3_runtime import Coroutine.
    • Should PyO3 create a unique pyo3_runtime.Coroutine type for each extension using it?

Would be glad for any opinions you have on this. I’m currently leaning towards the idea of publishing pyo3_runtime because I think it makes it possible for PyO3 to create the best API for Python end users, but it does come at the cost of a lot of distribution of compiled wheels!

Interesting question. I tried to understand the O3 feature request but gave up (too much Rust specific jargon). I think it doesn’t matter, the answer will be independent of that.

I skimmed the (CPython side) code of PyCoroObject a bit and it looks like the assumption it has a frame object is pretty hard. However you could see what Cython does, I think they have some hacks to construct a frame object without attaching a python code object to it, or perhaps with a dummy code object (whose name and line number are used for tracebacks).

If you decide to create your own awaitable type, that would be more portable; AFAICT the questions are then purely for you and your users to decide.

IMO, if you come up with a cross-language (language-agnostic) type, we could add it to CPython (CPython 3.13 at this point). But we’d be much happier if it was already tested in real-world code.

To me it would make sense to have a separate Coroutine type on each module, created on demand when the first coroutine is created. Modules are generally the right place for “global” state.

If you go with PyInterpreterState or a PyPI package, you’ll run into issues around versioning and compatibility of the API/ABI you provide. Solvable, but I don’t think it’s worth the effort.

By the way, in December 2022, I asked the Steering Council for a PEP 387 exception to remove 4 functions which cannot be used :slight_smile: Request PEP 387 exception to remove 4 functions broken since Python 3.11: Py_GenNew() · Issue #156 · python/steering-council · GitHub

  • PyAsyncGen_New()
  • PyCoro_New(),
  • PyGen_New()
  • PyGen_NewWithQualName()

Cython does use bespoke types, but uses a hybrid of these approaches. Every module has the C code to create the types compiled in, but they coordinate to avoid duplicating type objects. A module (_cython_3_0_3 for Cython 3.0.3 for instance) is created and used to stash all the types. Other Cython modules can fetch that when they’re imported instead of creating it themselves. Maybe PyO3 could do something similar.

3 Likes

Ah, thank you all. It sounds like from the threads linked by @vstinner probably I cannot use PyCoro_New or build my own frame objects for now, so the custom types is the way to go.

@TeamSpen210 thanks for the Cython tip, PyO3 can probably do similar. Do you stash this module in sys.modules, so other users can theoretically import these types?

I ask because there is only one type which users might want to import: PanicException. It models a Rust panic, which is a fatal error which unwinds the whole stack. I model this by inheriting from BaseException and sometimes users are annoyed by the fact their application exits. :see_no_evil:

I defend that it’s a cleaner abort and that’s really what Rust intends it to be, but I understand why some people want to catch it and unsurprisingly catching BaseException as a fallback for being unable to import PanicException is generally a dissatisfying answer!

They are in sys.modules so users can get to them, although I don’t think there’s a lot that user’s can do with them. The constructors are mostly/all(?) in C and take things like C function pointers so users wouldn’t be able to instantiate the classes.

The main benefit for Cython doing it this way is that it has some fast-paths for these classes internally (based on knowing the structure). That lets it use the fast paths on functions/generators/co-routines from another Cython module. I believe everything should still work if these fast paths are missed (which would happen when mixing modules generated with different Cython versions) - it’ll just be a little slower.

2 Likes

You probably want that exception type to be shared across all versions of PyO3 – unlike a coroutine type.
You might want to do something like this on startup:

  • Create an empty sys.modules['pyo3'] if it doesn’t exist yet
  • Add sys.modules['pyo3'].PanicException – a simple BaseException subclass – if it doesn’t exist yet
  • If sys.modules['pyo3._v0_15'] if it doesn’t exist yet, create it and fill it with version-specific helpers

Also: register pyo3 on PyPI, so you have a better claim to the name, and put in a pure-Python pyo3 module with a PanicException ;‍)

Agreed, though I was going to release the module as pyo3_runtime instead of pyo3, just to distinguish the bindings framework from the redistributable.

Actually already reserved it on PyPI, just haven’t quite committed to this approach yet: pyo3-runtime · PyPI