PEP 580 – The C call protocol
- Author:
- Jeroen Demeyer <J.Demeyer at UGent.be>
- BDFL-Delegate:
- Petr Viktorin
- Status:
- Rejected
- Type:
- Standards Track
- Created:
- 14-Jun-2018
- Python-Version:
- 3.8
- Post-History:
- 20-Jun-2018, 22-Jun-2018, 16-Jul-2018
Rejection Notice
This PEP is rejected in favor of PEP 590, which proposes a simpler public C API for callable objects.
Abstract
A new “C call” protocol is proposed. It is meant for classes representing functions or methods which need to implement fast calling. The goal is to generalize all existing optimizations for built-in functions to arbitrary extension types.
In the reference implementation,
this new protocol is used for the existing classes
builtin_function_or_method
and method_descriptor
.
However, in the future, more classes may implement it.
NOTE: This PEP deals only with the Python/C API, it does not affect the Python language or standard library.
Motivation
The standard function/method classes builtin_function_or_method
and method_descriptor
allow very efficiently calling C code.
However, they are not subclassable, making them unsuitable for many applications:
for example, they offer limited introspection support
(signatures only using __text_signature__
, no arbitrary __qualname__
,
no inspect.getfile()
).
It’s also not possible to store additional data to implement something like
functools.partial
or functools.lru_cache
.
So, there are many reasons why users would want to implement custom
function/method classes (in a duck-typing sense) in C.
Unfortunately, such custom classes are necessarily slower than
the standard CPython function classes:
the bytecode interpreter has various optimizations
which are specific to instances of
builtin_function_or_method
, method_descriptor
, method
and function
.
This PEP also allows to simplify existing code:
checks for builtin_function_or_method
and method_descriptor
could be replaced by simply checking for and using the C call protocol.
Future PEPs may implement the C call protocol for more classes,
enabling even further simplifications.
We also design the C call protocol such that it can easily be extended with new features in the future.
For more background and motivation, see PEP 579.
Overview
Currently, CPython has multiple optimizations for fast calling
for a few specific function classes.
A good example is the implementation of the opcode CALL_FUNCTION
,
which has the following structure
(see the actual code):
if (PyCFunction_Check(func)) {
return _PyCFunction_FastCallKeywords(func, stack, nargs, kwnames);
}
else if (Py_TYPE(func) == &PyMethodDescr_Type) {
return _PyMethodDescr_FastCallKeywords(func, stack, nargs, kwnames);
}
else {
if (PyMethod_Check(func) && PyMethod_GET_SELF(func) != NULL) {
/* ... */
}
if (PyFunction_Check(func)) {
return _PyFunction_FastCallKeywords(func, stack, nargs, kwnames);
}
else {
return _PyObject_FastCallKeywords(func, stack, nargs, kwnames);
}
}
Calling instances of these special-cased classes
using the tp_call
slot is slower than using the optimizations.
The basic idea of this PEP is to enable such optimizations
for user C code, both as caller and as callee.
The existing class builtin_function_or_method
and a few others
use a PyMethodDef
structure for describing the underlying C function and its signature.
The first concrete change is that this is replaced by a new structure PyCCallDef
.
This stores some of the same information as a PyMethodDef
,
but with one important addition:
the “parent” of the function (the class or module where it is defined).
Note that PyMethodDef
arrays are still used to construct
functions/methods but no longer for calling them.
Second, we want that every class can use such a PyCCallDef
for optimizing calls,
so the PyTypeObject
structure gains a tp_ccalloffset
field
giving an offset to a PyCCallDef *
in the object structure
and a flag Py_TPFLAGS_HAVE_CCALL
indicating that tp_ccalloffset
is valid.
Third, since we want to deal efficiently with unbound and bound methods too
(as opposed to only plain functions), we need to handle __self__
in the protocol:
after the PyCCallDef *
in the object structure,
there is a PyObject *self
field.
These two fields together are referred to as a PyCCallRoot
structure.
The new protocol for efficiently calling objects using these new structures is called the “C call protocol”.
NOTE: In this PEP, the phrases “unbound method” and “bound method”
refer to generic behavior, not to specific classes.
For example, an unbound method gets turned into a bound method
after applying __get__
.
New data structures
The PyTypeObject
structure gains a new field Py_ssize_t tp_ccalloffset
and a new flag Py_TPFLAGS_HAVE_CCALL
.
If this flag is set, then tp_ccalloffset
is assumed to be a valid
offset inside the object structure (similar to tp_dictoffset
and tp_weaklistoffset
).
It must be a strictly positive integer.
At that offset, a PyCCallRoot
structure appears:
typedef struct {
const PyCCallDef *cr_ccall;
PyObject *cr_self; /* __self__ argument for methods */
} PyCCallRoot;
The PyCCallDef
structure contains everything needed to describe how
the function can be called:
typedef struct {
uint32_t cc_flags;
PyCFunc cc_func; /* C function to call */
PyObject *cc_parent; /* class or module */
} PyCCallDef;
The reason for putting __self__
outside of PyCCallDef
is that PyCCallDef
is not meant to be changed after creating the function.
A single PyCCallDef
can be shared
by an unbound method and multiple bound methods.
This wouldn’t work if we would put __self__
inside that structure.
NOTE: unlike tp_dictoffset
we do not allow negative numbers
for tp_ccalloffset
to mean counting from the end.
There does not seem to be a use case for it and it would only complicate
the implementation.
Parent
The cc_parent
field (accessed for example by a __parent__
or __objclass__
descriptor from Python code) can be any Python
object, or NULL.
Custom classes are free to set cc_parent
to whatever they want.
It is only used by the C call protocol if the
CCALL_OBJCLASS
flag is set.
For methods of extension types, cc_parent
points to the class
that defines the method (which may be a superclass of type(self)
).
This is currently non-trivial to retrieve from a method’s code.
In the future, this can be used to access the module state via
the defining class. See the rationale of PEP 573 for details.
When the flag CCALL_OBJCLASS
is set (as it will be for methods of
extension types), cc_parent
is used for type checks like the following:
>>> list.append({}, "x")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: descriptor 'append' requires a 'list' object but received a 'dict'
For functions of modules, cc_parent
is set to the module.
Currently, this is exactly the same as __self__
.
However, using __self__
for the module is a quirk of the current implementation:
in the future, we want to allow functions which use __self__
in the normal way, for implementing methods.
Such functions can still use cc_parent
instead to refer to the module.
The parent would also typically be used to implement __qualname__
.
The new C API function PyCCall_GenericGetQualname()
does exactly that.
Using tp_print
We propose to replace the existing unused field tp_print
by tp_ccalloffset
.
Since Py_TPFLAGS_HAVE_CCALL
would not be added to
Py_TPFLAGS_DEFAULT
, this ensures full backwards compatibility for
existing extension modules setting tp_print
.
It also means that we can require that tp_ccalloffset
is a valid
offset when Py_TPFLAGS_HAVE_CCALL
is specified:
we do not need to check tp_ccalloffset != 0
.
In future Python versions, we may decide that tp_print
becomes tp_ccalloffset
unconditionally,
drop the Py_TPFLAGS_HAVE_CCALL
flag and instead check for
tp_ccalloffset != 0
.
NOTE: the exact layout of PyTypeObject
is not part of the stable ABI).
Therefore, changing the tp_print
field from a printfunc
(a function pointer)
to a Py_ssize_t
should not be a problem,
even if this changes the memory layout of the PyTypeObject
structure.
Moreover, on all systems for which binaries are commonly built
(Windows, Linux, macOS),
the size of printfunc
and Py_ssize_t
are the same,
so the issue of binary compatibility will not come up anyway.
The C call protocol
We say that a class implements the C call protocol
if it has the Py_TPFLAGS_HAVE_CCALL
flag set
(as explained above, it must then set tp_ccalloffset > 0
).
Such a class must implement __call__
as described in this section
(in practice, this just means setting tp_call
to PyCCall_Call
).
The cc_func
field is a C function pointer,
which plays the same role as the existing ml_meth
field of PyMethodDef
.
Its precise signature depends on flags.
The subset of flags influencing the signature of cc_func
is given by the bitmask CCALL_SIGNATURE
.
Below are the possible values for cc_flags & CCALL_SIGNATURE
together with the arguments that the C function takes.
The return value is always PyObject *
.
The following are analogous to the existing PyMethodDef
signature flags:
CCALL_VARARGS
:cc_func(PyObject *self, PyObject *args)
CCALL_VARARGS | CCALL_KEYWORDS
:cc_func(PyObject *self, PyObject *args, PyObject *kwds)
(kwds
is eitherNULL
or a dict; this dict must not be modified by the callee)CCALL_FASTCALL
:cc_func(PyObject *self, PyObject *const *args, Py_ssize_t nargs)
CCALL_FASTCALL | CCALL_KEYWORDS
:cc_func(PyObject *self, PyObject *const *args, Py_ssize_t nargs, PyObject *kwnames)
(kwnames
is eitherNULL
or a non-empty tuple of keyword names)CCALL_NOARGS
:cc_func(PyObject *self, PyObject *unused)
(second argument is alwaysNULL
)CCALL_O
:cc_func(PyObject *self, PyObject *arg)
The flag CCALL_DEFARG
may be combined with any of these.
If so, the C function takes an additional argument
as first argument before self
,
namely a const pointer to the PyCCallDef
structure used for this call.
For example, we have the following signature:
CCALL_DEFARG | CCALL_VARARGS
:cc_func(const PyCCallDef *def, PyObject *self, PyObject *args)
One exception is CCALL_DEFARG | CCALL_NOARGS
:
the unused
argument is dropped, so the signature becomes
CCALL_DEFARG | CCALL_NOARGS
:cc_func(const PyCCallDef *def, PyObject *self)
NOTE: unlike the existing METH_...
flags,
the CCALL_...
constants do not necessarily represent single bits.
So checking if (cc_flags & CCALL_VARARGS)
is not a valid way
for checking the signature.
There are also no guarantees of binary compatibility for these flags
between Python versions.
This allows the implementation to choose the most efficient
numerical values of the flags.
In the reference implementation,
the legal values for cc_flags & CCALL_SIGNATURE
form exactly the interval [0, …, 11].
This means that the compiler can easily
optimize a switch
statement for those cases using a computed goto.
Checking __objclass__
If the CCALL_OBJCLASS
flag is set and if cr_self
is NULL
(this is the case for unbound methods of extension types),
then a type check is done:
the function must be called with at least one positional argument
and the first (typically called self
) must be an instance of
cc_parent
(which must be a class).
If not, a TypeError
is raised.
Self slicing
If cr_self
is not NULL or if the flag CCALL_SELFARG
is not set in cc_flags
, then the argument passed as self
is simply cr_self
.
If cr_self
is NULL and the flag CCALL_SELFARG
is set,
then the first positional argument is removed from
args
and instead passed as self
argument to the C function.
Effectively, the first positional argument is treated as __self__
.
If there are no positional arguments, TypeError
is raised.
This process is called “self slicing” and a function is said to have self
slicing if cr_self
is NULL and CCALL_SELFARG
is set.
Note that a CCALL_NOARGS
function with self slicing effectively has
one argument, namely self
.
Analogously, a CCALL_O
function with self slicing has two arguments.
Descriptor behavior
Classes supporting the C call protocol must implement the descriptor protocol in a specific way.
This is required for an efficient implementation of bound methods:
if other code can make assumptions on what __get__
does,
it enables optimizations which would not be possible otherwise.
In particular, we want to allow sharing
the PyCCallDef
structure between bound and unbound methods.
We also need a correct implementation of _PyObject_GetMethod
which is used by the LOAD_METHOD
/CALL_METHOD
optimization.
First of all, if func
supports the C call protocol,
then func.__set__
and func.__delete__
must not be implemented.
Second, func.__get__
must behave as follows:
- If
cr_self
is not NULL, then__get__
must be a no-op in the sense thatfunc.__get__(obj, cls)(*args, **kwds)
behaves exactly the same asfunc(*args, **kwds)
. It is also allowed for__get__
to be not implemented at all. - If
cr_self
is NULL, thenfunc.__get__(obj, cls)(*args, **kwds)
(withobj
not None) must be equivalent tofunc(obj, *args, **kwds)
. In particular,__get__
must be implemented in this case. This is unrelated to self slicing:obj
may be passed asself
argument to the C function or it may be the first positional argument. - If
cr_self
is NULL, thenfunc.__get__(None, cls)(*args, **kwds)
must be equivalent tofunc(*args, **kwds)
.
There are no restrictions on the object func.__get__(obj, cls)
.
The latter is not required to implement the C call protocol for example.
We only specify what func.__get__(obj, cls).__call__
does.
For classes that do not care about __self__
and __get__
at all,
the easiest solution is to assign cr_self = Py_None
(or any other non-NULL value).
The __name__ attribute
The C call protocol requires that the function has a __name__
attribute which is of type str
(not a subclass).
Furthermore, the object returned by __name__
must be stored somewhere;
it cannot be a temporary object.
This is required because PyEval_GetFuncName
uses a borrowed reference to the __name__
attribute
(see also [2]).
Generic API functions
This section lists the new public API functions or macros dealing with the C call protocol.
int PyCCall_Check(PyObject *op)
: return true ifop
implements the C call protocol.
All the functions and macros below
apply to any instance supporting the C call protocol.
In other words, PyCCall_Check(func)
must be true.
PyObject *PyCCall_Call(PyObject *func, PyObject *args, PyObject *kwds)
: callfunc
with positional argumentsargs
and keyword argumentskwds
(kwds
may be NULL). This function is meant to be put in thetp_call
slot.PyObject *PyCCall_FastCall(PyObject *func, PyObject *const *args, Py_ssize_t nargs, PyObject *kwds)
: callfunc
withnargs
positional arguments given byargs[0]
, …,args[nargs-1]
. The parameterkwds
can be NULL (no keyword arguments), a dict withname:value
items or a tuple with keyword names. In the latter case, the keyword values are stored in theargs
array, starting atargs[nargs]
.
Macros to access the PyCCallRoot
and PyCCallDef
structures:
const PyCCallRoot *PyCCall_CCALLROOT(PyObject *func)
: pointer to thePyCCallRoot
structure insidefunc
.const PyCCallDef *PyCCall_CCALLDEF(PyObject *func)
: shorthand forPyCCall_CCALLROOT(func)->cr_ccall
.uint32_t PyCCall_FLAGS(PyObject *func)
: shorthand forPyCCall_CCALLROOT(func)->cr_ccall->cc_flags
.PyObject *PyCCall_SELF(PyOject *func)
: shorthand forPyCCall_CCALLROOT(func)->cr_self
.
Generic getters, meant to be put into the tp_getset
array:
PyObject *PyCCall_GenericGetParent(PyObject *func, void *closure)
: returncc_parent
. RaiseAttributeError
ifcc_parent
is NULL.PyObject *PyCCall_GenericGetQualname(PyObject *func, void *closure)
: return a string suitable for using as__qualname__
. This uses the__qualname__
ofcc_parent
if possible. It also uses the__name__
attribute.
Profiling
The profiling events
c_call
, c_return
and c_exception
are only generated
when calling actual instances of builtin_function_or_method
or method_descriptor
.
This is done for simplicity and also for backwards compatibility
(such that the profile function does not receive objects that it does not recognize).
In a future PEP, we may extend C-level profiling to arbitrary classes
implementing the C call protocol.
Changes to built-in functions and methods
The reference implementation of this PEP changes
the existing classes builtin_function_or_method
and method_descriptor
to use the C call protocol.
In fact, those two classes are almost merged:
the implementation becomes very similar, but they remain separate classes
(mostly for backwards compatibility).
The PyCCallDef
structure is simply stored
as part of the object structure.
Both classes use PyCFunctionObject
as object structure.
This is the new layout for both classes:
typedef struct {
PyObject_HEAD
PyCCallDef *m_ccall;
PyObject *m_self; /* Passed as 'self' arg to the C function */
PyCCallDef _ccalldef; /* Storage for m_ccall */
PyObject *m_name; /* __name__; str object (not NULL) */
PyObject *m_module; /* __module__; can be anything */
const char *m_doc; /* __text_signature__ and __doc__ */
PyObject *m_weakreflist; /* List of weak references */
} PyCFunctionObject;
For functions of a module and for unbound methods of extension types,
m_ccall
points to the _ccalldef
field.
For bound methods, m_ccall
points to the PyCCallDef
of the unbound method.
NOTE: the new layout of method_descriptor
changes it
such that it no longer starts with PyDescr_COMMON
.
This is purely an implementation detail and it should cause few (if any)
compatibility problems.
C API functions
The following function is added (also to the stable ABI):
PyObject * PyCFunction_ClsNew(PyTypeObject *cls, PyMethodDef *ml, PyObject *self, PyObject *module, PyObject *parent)
: create a new object with object structurePyCFunctionObject
and classcls
. The entries of thePyMethodDef
structure are used to construct the new object, but the pointer to thePyMethodDef
structure is not stored. The flags for the C call protocol are automatically determined in terms ofml->ml_flags
,self
andparent
.
The existing functions PyCFunction_New
, PyCFunction_NewEx
and
PyDescr_NewMethod
are implemented in terms of PyCFunction_ClsNew
.
The undocumented functions PyCFunction_GetFlags
and PyCFunction_GET_FLAGS
are deprecated.
They are still artificially supported by storing the original METH_...
flags in a bitfield inside cc_flags
.
Despite the fact that PyCFunction_GetFlags
is technically
part of the stable ABI,
it is highly unlikely to be used that way:
first of all, it is not even documented.
Second, the flag METH_FASTCALL
is not part of the stable ABI but it is very common
(because of Argument Clinic).
So, if one cannot support METH_FASTCALL
,
it is hard to imagine a use case for PyCFunction_GetFlags
.
The fact that PyCFunction_GET_FLAGS
and PyCFunction_GetFlags
are not used at all by CPython outside of Objects/call.c
further shows that these functions are not particularly useful.
Inheritance
Extension types inherit the type flag Py_TPFLAGS_HAVE_CCALL
and the value tp_ccalloffset
from the base class,
provided that they implement tp_call
and tp_descr_get
the same way as the base class.
Heap types never inherit the C call protocol because
that would not be safe (heap types can be changed dynamically).
Performance
This PEP should not impact the performance of existing code (in the positive or negative sense). It is meant to allow efficient new code to be written, not to make existing code faster.
Here are a few pointers to the python-dev
mailing list where
performance improvements are discussed:
Stable ABI
The function PyCFunction_ClsNew
is added to the stable ABI.
None of the functions, structures or constants dealing with the C call protocol are added to the stable ABI.
There are two reasons for this:
first of all, the most useful feature of the C call protocol is probably the
METH_FASTCALL
calling convention.
Given that this is not even part of the public API (see also PEP 579, issue 6),
it would be strange to add anything else from the C call protocol
to the stable ABI.
Second, we want the C call protocol to be extensible in the future. By not adding anything to the stable ABI, we are free to do that without restrictions.
Backwards compatibility
There is no difference at all for the Python interface, nor for the documented C API (in the sense that all functions remain supported with the same functionality).
The only potential breakage is with C code
which accesses the internals of PyCFunctionObject
and PyMethodDescrObject
.
We expect very few problems because of this.
Rationale
Why is this better than PEP 575?
One of the major complaints of PEP 575 was that is was coupling
functionality (the calling and introspection protocol)
with the class hierarchy:
a class could only benefit from the new features
if it was a subclass of base_function
.
It may be difficult for existing classes to do that
because they may have other constraints on the layout of the C object structure,
coming from an existing base class or implementation details.
For example, functools.lru_cache
cannot implement PEP 575 as-is.
It also complicated the implementation precisely because changes were needed both in the implementation details and in the class hierarchy.
The current PEP does not have these problems.
Why store the function pointer in the instance?
The actual information needed for calling an object
is stored in the instance (in the PyCCallDef
structure)
instead of the class.
This is different from the tp_call
slot or earlier attempts
at implementing a tp_fastcall
slot [1].
The main use case is built-in functions and methods. For those, the C function to be called does depend on the instance.
Note that the current protocol makes it easy to support the case
where the same C function is called for all instances:
just use a single static PyCCallDef
structure for every instance.
Why CCALL_OBJCLASS?
The flag CCALL_OBJCLASS
is meant to support various cases
where the class of a self
argument must be checked, such as:
>>> list.append({}, None)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: append() requires a 'list' object but received a 'dict'
>>> list.__len__({})
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: descriptor '__len__' requires a 'list' object but received a 'dict'
>>> float.__dict__["fromhex"](list, "0xff")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: descriptor 'fromhex' for type 'float' doesn't apply to type 'list'
In the reference implementation, only the first of these uses the new code. The other examples show that these kind of checks appear in multiple places, so it makes sense to add generic support for them.
Why CCALL_SELFARG?
The flag CCALL_SELFARG
and the concept of self slicing
are needed to support methods:
the C function should not care
whether it is called as unbound method or as bound method.
In both cases, there should be a self
argument
and this is simply the first positional argument of an unbound method call.
For example, list.append
is a METH_O
method.
Both the calls list.append([], 42)
and [].append(42)
should
translate to the C call list_append([], 42)
.
Thanks to the proposed C call protocol, we can support this in such a way
that both the unbound and the bound method share a PyCCallDef
structure (with the CCALL_SELFARG
flag set).
So, CCALL_SELFARG
has two advantages:
there is no extra layer of indirection for calling methods
and constructing bound methods does not require setting up a PyCCallDef
structure.
Another minor advantage is that we could make the error messages for a wrong call signature more uniform between Python methods and built-in methods. In the following example, Python is undecided whether a method takes 1 or 2 arguments:
>>> class List(list):
... def myappend(self, item):
... self.append(item)
>>> List().myappend(1, 2)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: myappend() takes 2 positional arguments but 3 were given
>>> List().append(1, 2)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: append() takes exactly one argument (2 given)
It is currently impossible for PyCFunction_Call
to know the actual number of user-visible arguments
since it cannot distinguish at runtime between
a function (without self
argument) and a bound method (with self
argument).
The CCALL_SELFARG
flag makes this difference explicit.
Why CCALL_DEFARG?
The flag CCALL_DEFARG
gives the callee access to the PyCCallDef *
.
There are various use cases for this:
- The callee can use the
cc_parent
field, which is useful for PEP 573. - Applications are free to extend the
PyCCallDef
structure with user-defined fields, which can then be accessed analogously. - In the case where the
PyCCallDef
structure is part of the object structure (this is true for example for PyCFunctionObject), an appropriate offset can be subtracted from thePyCCallDef
pointer to get a pointer to the callable object defining thatPyCCallDef
.
An earlier version of this PEP defined a flag CCALL_FUNCARG
instead of CCALL_DEFARG
which would pass the callable object
to the callee.
This had similar use cases, but there was some ambiguity for
bound methods: should the “callable object” be the bound method
object or the original function wrapped by the method?
By passing the PyCCallDef *
instead, this ambiguity is gone
since the bound method uses the PyCCallDef *
from the wrapped function.
Replacing tp_print
We repurpose tp_print
as tp_ccalloffset
because this makes
it easier for external projects to backport the C call protocol
to earlier Python versions.
In particular, the Cython project has shown interest in doing that
(see https://mail.python.org/pipermail/python-dev/2018-June/153927.html).
Alternative suggestions
PEP 576 is an alternative approach to solving the same problem as this PEP. See https://mail.python.org/pipermail/python-dev/2018-July/154238.html for comments on the difference between PEP 576 and PEP 580.
Discussion
Links to threads on the python-dev
mailing list
where this PEP has been discussed:
- https://mail.python.org/pipermail/python-dev/2018-June/153938.html
- https://mail.python.org/pipermail/python-dev/2018-June/153984.html
- https://mail.python.org/pipermail/python-dev/2018-July/154238.html
- https://mail.python.org/pipermail/python-dev/2018-July/154470.html
- https://mail.python.org/pipermail/python-dev/2018-July/154571.html
- https://mail.python.org/pipermail/python-dev/2018-September/155166.html
- https://mail.python.org/pipermail/python-dev/2018-October/155403.html
- https://mail.python.org/pipermail/python-dev/2019-March/156853.html
- https://mail.python.org/pipermail/python-dev/2019-March/156879.html
Reference implementation
The reference implementation can be found at https://github.com/jdemeyer/cpython/tree/pep580
For an example of using the C call protocol,
the following branch implements functools.lru_cache
using PEP 580:
https://github.com/jdemeyer/cpython/tree/lru580
References
Copyright
This document has been placed in the public domain.
Source: https://github.com/python/peps/blob/main/peps/pep-0580.rst
Last modified: 2023-09-09 17:39:29 GMT