PEP 489 – 多阶段扩展模块初始化
- 作者:
- Petr Viktorin <encukou at gmail.com>, Stefan Behnel <stefan_ml at behnel.de>, Alyssa Coghlan <ncoghlan at gmail.com>
- BDFL-代表:
- Eric Snow <ericsnowcurrently at gmail.com>
- 讨论地址:
- Import-SIG 邮件列表
- 状态:
- 最终版
- 类型:
- 标准追踪
- 创建日期:
- 2013-08-11
- Python 版本:
- 3.5
- 历史记录:
- 2013-08-23, 2015-02-20, 2015-04-16, 2015-05-07, 2015-05-18
- 决议:
- Python-Dev 消息
摘要
本 PEP 提案重新设计了内置模块和扩展模块与导入机制交互的方式。此提案最后一次修改是在 Python 3.0 中,通过 PEP 3121 进行的,但当时并未解决所有问题。目标是通过使扩展模块更接近 Python 模块的行为方式来解决与导入相关的 문제점; 特别是,将扩展模块整合到 PEP 451 中引入的基于 ModuleSpec 的加载机制。
该提案借鉴了 PEP 384 中的 PyType_Spec,允许扩展作者仅定义他们需要的功能,并允许将来为扩展模块声明添加功能。
扩展模块以两步方式创建,更好地融入 ModuleSpec 架构,类似于类的 __new__ 和 __init__。
扩展模块可以安全地将任意的 C 级每个模块状态存储在模块中,该状态由正常的垃圾收集覆盖,并支持重新加载和子解释器。鼓励扩展作者在使用新的 API 时考虑这些问题。
该提案还允许使用非 ASCII 名称的扩展模块。
本提案并未解决 PEP 3121 中解决的所有问题。特别是,运行时模块查找(PyState_FindModule)的问题将留待未来的 PEP 解决。
动机
Python 模块和扩展模块的设置方式不同。对于 Python 模块,首先创建和设置模块对象,然后执行模块代码(PEP 302)。一个 ModuleSpec 对象(PEP 451)用于保存有关模块的信息,并传递给相关钩子。
对于扩展(即共享库)和内置模块,模块初始化函数会立即执行,并且同时进行创建和初始化。初始化函数不会传递 ModuleSpec 或其中包含的任何信息,例如 __file__ 或完全限定名称。这阻碍了相对导入和资源加载。
在 Py3 中,模块也不会被添加到 sys.modules 中,这意味着对模块的(可能具有传递性的)重新导入将真正尝试重新导入它,因此在再次执行模块初始化函数时会陷入无限循环。在没有完全限定模块名称的情况下,将模块正确添加到 sys.modules 也不容易。这对于 Cython 生成的模块来说是一个特别的问题,因为这些模块的模块初始化代码的复杂程度通常与任何“常规”Python 模块的代码相同。此外,缺乏 __file__ 和 __name__ 信息阻碍了“__init__.py”模块(即包)的编译,尤其是在模块初始化时使用相对导入的情况下。
此外,大多数现有的扩展模块都存在子解释器支持和/或解释器重新加载问题,虽然使用当前的基础设施可以支持这些功能,但既不简单也不高效。解决这些问题是 PEP 3121 的目标,但许多扩展,包括标准库中的一些扩展,采用了最简单的移植到 Python 3 的方法,导致这些问题没有解决。本 PEP 保持向后兼容性,这应该可以减轻压力,并为扩展作者提供充足的时间来在移植时考虑这些问题。
当前流程
目前,扩展模块和内置模块导出一个名为“PyInit_modulename”的初始化函数,该函数以共享库的文件名命名。此函数由导入机制执行,必须返回一个完全初始化的模块对象。该函数不接收任何参数,因此它无法了解其导入上下文。
在执行期间,模块初始化函数基于一个 PyModuleDef 对象创建一个模块对象。然后它继续通过向模块字典添加属性、创建类型等来初始化它。
在后台,共享库加载器会记录它加载的最后一个模块的完全限定模块名称,当创建一个具有匹配名称的模块时,该全局变量用于确定模块对象的完全限定名称。这并不完全安全,因为它依赖于模块初始化函数首先创建自己的模块对象,但这种假设在实践中通常成立。
提案
初始化函数(PyInit_modulename)将被允许返回一个指向 PyModuleDef 对象的指针。导入机制将负责构建模块对象,在初始化的相关阶段(如下所述)调用 PyModuleDef 中提供的钩子。
这种多阶段初始化是一种额外的可能性。单阶段初始化,即当前返回完全初始化的模块对象的做法,仍将被接受,因此现有代码将保持不变,包括二进制兼容性。
PyModuleDef 结构将被更改为包含一个槽列表,类似于 PEP 384 中的 PyType_Spec 用于类型。为了保持二进制兼容性,并且避免引入新的结构(这将引入额外的支持函数和每个模块存储),PyModuleDef 的当前未使用的 m_reload 指针将被更改为保存这些槽。这些结构定义如下:
typedef struct {
int slot;
void *value;
} PyModuleDef_Slot;
typedef struct PyModuleDef {
PyModuleDef_Base m_base;
const char* m_name;
const char* m_doc;
Py_ssize_t m_size;
PyMethodDef *m_methods;
PyModuleDef_Slot *m_slots; /* changed from `inquiry m_reload;` */
traverseproc m_traverse;
inquiry m_clear;
freefunc m_free;
} PyModuleDef;
m_slots 成员必须为 NULL,或者指向一个 PyModuleDef_Slot 结构数组,以一个 id 设置为 0 的槽结束(即 {0, NULL}
)。
要指定一个槽,必须提供一个唯一的槽 ID。新的 Python 版本可能会引入新的槽 ID,但槽 ID 永远不会被回收。槽可能会被弃用,但将在整个 Python 3.x 中继续得到支持。
槽的值指针不能为 NULL,除非在槽的文档中另有说明。
以下槽当前可用,并在后面进行描述:
- Py_mod_create
- Py_mod_exec
未知的槽 ID 将导致导入失败,并出现 SystemError。
当使用多阶段初始化时,PyModuleDef 的 m_name 字段在导入过程中不会被使用;模块名称将从 ModuleSpec 中获取。
在从 PyInit_* 返回之前,必须使用新添加的 PyModuleDef_Init 函数初始化 PyModuleDef 对象。这将设置对象类型(在某些编译器上无法静态完成)、引用计数和内部簿记数据(m_index)。例如,扩展模块“example”将被导出为:
static PyModuleDef example_def = {...}
PyMODINIT_FUNC
PyInit_example(void)
{
return PyModuleDef_Init(&example_def);
}
PyModuleDef 对象必须在从它创建的模块的生命周期内可用——通常,它将被静态声明。
伪代码概述
以下是修改后的导入器如何运行的概述。日志记录或错误处理和无效状态处理等细节被省略,C 代码以简洁的类 Python 语法呈现。
调用导入器的框架在 PEP 451 中进行了说明。
importlib/_bootstrap.py
class BuiltinImporter: def create_module(self, spec): module = _imp.create_builtin(spec) def exec_module(self, module): _imp.exec_dynamic(module) def load_module(self, name): # use a backwards compatibility shim _load_module_shim(self, name)
importlib/_bootstrap_external.py
class ExtensionFileLoader: def create_module(self, spec): module = _imp.create_dynamic(spec) def exec_module(self, module): _imp.exec_dynamic(module) def load_module(self, name): # use a backwards compatibility shim _load_module_shim(self, name)
Python/import.c(_imp 模块)
def create_dynamic(spec): name = spec.name path = spec.origin # Find an already loaded module that used single-phase init. # For multi-phase initialization, mod is NULL, so a new module # is always created. mod = _PyImport_FindExtensionObject(name, name) if mod: return mod return _PyImport_LoadDynamicModuleWithSpec(spec) def exec_dynamic(module): if not isinstance(module, types.ModuleType): # non-modules are skipped -- PyModule_GetDef fails on them return def = PyModule_GetDef(module) state = PyModule_GetState(module) if state is NULL: PyModule_ExecDef(module, def) def create_builtin(spec): name = spec.name # Find an already loaded module that used single-phase init. # For multi-phase initialization, mod is NULL, so a new module # is always created. mod = _PyImport_FindExtensionObject(name, name) if mod: return mod for initname, initfunc in PyImport_Inittab: if name == initname: m = initfunc() if isinstance(m, PyModuleDef): def = m return PyModule_FromDefAndSpec(def, spec) else: # fall back to single-phase initialization module = m _PyImport_FixupExtensionObject(module, name, name) return module
Python/importdl.c
def _PyImport_LoadDynamicModuleWithSpec(spec): path = spec.origin package, dot, name = spec.name.rpartition('.') # see the "Non-ASCII module names" section for export_hook_name hook_name = export_hook_name(name) # call platform-specific function for loading exported function # from shared library exportfunc = _find_shared_funcptr(hook_name, path) m = exportfunc() if isinstance(m, PyModuleDef): def = m return PyModule_FromDefAndSpec(def, spec) module = m # fall back to single-phase initialization ....
Objects/moduleobject.c
def PyModule_FromDefAndSpec(def, spec): name = spec.name create = None for slot, value in def.m_slots: if slot == Py_mod_create: create = value if create: m = create(spec, def) else: m = PyModule_New(name) if isinstance(m, types.ModuleType): m.md_state = None m.md_def = def if def.m_methods: PyModule_AddFunctions(m, def.m_methods) if def.m_doc: PyModule_SetDocString(m, def.m_doc) def PyModule_ExecDef(module, def): if isinstance(module, types.module_type): if module.md_state is NULL: # allocate a block of zeroed-out memory module.md_state = _alloc(module.md_size) if def.m_slots is NULL: return for slot, value in def.m_slots: if slot == Py_mod_exec: value(module)
模块创建阶段
模块对象的创建——即 ExecutionLoader.create_module 的实现——受 Py_mod_create 槽控制。
Py_mod_create 槽
Py_mod_create 槽用于支持自定义模块子类。值指针必须指向具有以下签名的函数:
PyObject* (*PyModuleCreateFunction)(PyObject *spec, PyModuleDef *def)
该函数接收一个 ModuleSpec 实例,如 PEP 451 中定义,以及 PyModuleDef 结构。它应该返回一个新的模块对象,或者设置错误并返回 NULL。
此函数不负责在新的模块上设置 PEP 451 中指定的与导入相关的属性(例如 __name__
或 __loader__
)。
返回的对象没有必须是 types.ModuleType 实例的要求。可以使用任何类型,只要它支持设置和获取属性,包括至少与导入相关的属性。但是,只有 ModuleType 实例支持特定于模块的功能,例如每个模块的状态和执行槽的处理。如果返回的不是 ModuleType 子类,则不能定义任何执行槽;如果有,则会引发 SystemError。
请注意,当调用此函数时,模块在 sys.modules 中的条目尚未填充。尝试再次导入同一个模块(可能是传递性地)可能会导致无限循环。建议扩展作者使 Py_mod_create 保持最小化,特别是不要从它调用用户代码。
不能指定多个 Py_mod_create 槽。如果指定了,导入将失败,并出现 SystemError。
如果未指定 Py_mod_create,导入机制将使用 PyModule_New 创建一个正常的模块对象。名称将从 spec 中获取。
创建后步骤
如果 Py_mod_create 函数返回 types.ModuleType 或子类的实例(或如果不存在 Py_mod_create 槽),导入机制将把 PyModuleDef 与模块关联起来。这也使 PyModuleDef 可供执行阶段、PyModule_GetDef 函数和垃圾收集例程(遍历、清除、释放)访问。
如果 Py_mod_create 函数不返回模块子类,则 m_size 必须为 0,m_traverse、m_clear 和 m_free 必须全部为 NULL。否则,将引发 SystemError。
此外,将 PyModuleDef 中指定的初始属性设置在模块对象上,而不管其类型如何:
- docstring 将从 m_doc 中设置,如果 m_doc 不为 NULL。
- 模块的函数将从 m_methods 中初始化,如果有的话。
模块执行阶段
模块执行——即 ExecutionLoader.exec_module 的实现——受“执行槽”控制。本 PEP 仅添加了一个,Py_mod_exec,但在未来可能会添加其他槽。
执行阶段是在与模块对象关联的 PyModuleDef 上完成的。对于不是 PyModule_Type 子类的对象(对于它们,PyModule_GetDef 会失败),执行阶段会被跳过。
执行槽可以多次指定,并且按照它们在槽数组中出现的顺序进行处理。在使用默认导入机制时,它们在设置 PEP 451 中指定的与导入相关的属性(例如 __name__
或 __loader__
)之后以及模块被添加到 sys.modules 中之后进行处理。
执行前步骤
在处理执行槽之前,会为模块分配每个模块的狀態。从这一点开始,每个模块的狀態可以通过 PyModule_GetState 访问。
Py_mod_exec 槽
此槽中的条目必须指向具有以下签名的函数
int (*PyModuleExecFunction)(PyObject* module)
它将被调用来初始化一个模块。通常,这相当于设置模块的初始属性。“module”参数接收要初始化的模块对象。
函数必须在成功时返回 0
,或者在发生错误时设置异常并返回 -1
。
如果 PyModuleExec 替换了模块在 sys.modules 中的条目,那么新对象将在所有执行槽处理完之后被 importlib 机制使用并返回。这是导入机制本身的一个特性。所有槽都是使用从创建阶段返回的模块进行处理的;在执行阶段不会咨询 sys.modules。(请注意,对于扩展模块,实现 Py_mod_create 通常是使用自定义模块对象更好的解决方案。)
传统初始化
向后兼容的单阶段初始化仍然受支持。在这个方案中,PyInit 函数返回一个完全初始化的模块,而不是一个 PyModuleDef 对象。在这种情况下,PyInit 钩子实现了创建阶段,执行阶段是无操作的。
需要在旧版本的 Python 上保持不变的模块应该坚持使用单阶段初始化,因为它带来的好处无法回溯。以下是一个支持多阶段初始化的模块示例,在为旧版本的 CPython 编译时会回退到单阶段初始化。它主要作为一个说明启用多阶段 init 所需更改的示例。
#include <Python.h>
static int spam_exec(PyObject *module) {
PyModule_AddStringConstant(module, "food", "spam");
return 0;
}
#ifdef Py_mod_exec
static PyModuleDef_Slot spam_slots[] = {
{Py_mod_exec, spam_exec},
{0, NULL}
};
#endif
static PyModuleDef spam_def = {
PyModuleDef_HEAD_INIT, /* m_base */
"spam", /* m_name */
PyDoc_STR("Utilities for cooking spam"), /* m_doc */
0, /* m_size */
NULL, /* m_methods */
#ifdef Py_mod_exec
spam_slots, /* m_slots */
#else
NULL,
#endif
NULL, /* m_traverse */
NULL, /* m_clear */
NULL, /* m_free */
};
PyMODINIT_FUNC
PyInit_spam(void) {
#ifdef Py_mod_exec
return PyModuleDef_Init(&spam_def);
#else
PyObject *module;
module = PyModule_Create(&spam_def);
if (module == NULL) return NULL;
if (spam_exec(module) != 0) {
Py_DECREF(module);
return NULL;
}
return module;
#endif
}
内置模块
任何扩展模块都可以通过将其链接到可执行文件并将其包含在 inittab 中(在运行时使用 PyImport_AppendInittab,或在配置时使用 freeze 等工具)来用作内置模块。
为了保持这种可能性,本 PEP 中引入的扩展模块加载的所有更改也将应用于内置模块。唯一的例外是非 ASCII 模块名称,将在下面解释。
子解释器和解释器重新加载
使用新的初始化方案的扩展预计要正确支持子解释器和多个 Py_Initialize/Py_Finalize 周期,避免 Python 文档 [6] 中提到的问题。该机制的设计旨在使这变得容易,但扩展作者仍然需要小心。任何用户定义的函数、方法或实例都不能泄漏到不同的解释器。为了实现这一点,所有模块级状态应保留在模块字典中,或在模块对象的存储中通过 PyModule_GetState 访问。一个简单的经验法则是:不要定义任何静态数据,除了没有可变或用户可设置类属性的内置类型。
与多阶段初始化不兼容的函数
当在具有非 NULL m_slots 指针的 PyModuleDef 结构上使用时,PyModule_Create 函数将失败。该函数没有访问多阶段初始化所需的 ModuleSpec 对象。
PyState_FindModule 函数将返回 NULL,PyState_AddModule 和 PyState_RemoveModule 也将在具有非 NULL m_slots 的模块上失败。PyState 注册被禁用,因为可能从同一个 PyModuleDef 创建多个模块对象。
模块状态和 C 级回调
由于 PyState_FindModule 的不可用,任何需要访问模块级状态的函数(包括在模块级别定义的函数、类或异常)都必须直接或间接地接收对模块对象(或其需要的特定对象)的引用。这在当前的两种情况下很困难
- 类的 Method,它接收对类的引用,但没有接收对类的模块的引用
- 具有 C 级回调的库,除非回调可以接收在回调注册时设置的自定义数据
修复这些情况超出了本 PEP 的范围,但对于新机制对所有模块有用来说是必要的。在 import-sig 邮件列表 [5] 上讨论了适当的修复方案。
一般来说,依赖于 PyState_FindModule 的模块目前不适合移植到新机制。
新函数
将添加一个实现模块创建阶段的新函数和宏。它们类似于 PyModule_Create 和 PyModule_Create2,除了它们接受一个额外的 ModuleSpec 参数,并处理具有非 NULL 槽的模块定义。
PyObject * PyModule_FromDefAndSpec(PyModuleDef *def, PyObject *spec)
PyObject * PyModule_FromDefAndSpec2(PyModuleDef *def, PyObject *spec,
int module_api_version)
将添加一个实现模块执行阶段的新函数。它分配每个模块的狀態(如果尚未分配),并始终处理执行槽。导入机制在执行模块时调用此方法,除非正在重新加载模块。
PyAPI_FUNC(int) PyModule_ExecDef(PyObject *module, PyModuleDef *def)
将引入另一个函数来初始化 PyModuleDef 对象。这个幂等函数填充类型、引用计数和模块索引。它返回其参数转换为 PyObject*,因此可以直接从 PyInit 函数返回。
PyObject * PyModuleDef_Init(PyModuleDef *);
此外,将添加两个助手来设置模块的 docstring 和方法。
int PyModule_SetDocString(PyObject *, const char *)
int PyModule_AddFunctions(PyObject *, PyMethodDef *)
导出钩子名称
由于可移植的 C 标识符仅限于 ASCII,因此必须对模块名称进行编码以形成 PyInit 钩子名称。
对于 ASCII 模块名称,导入钩子名为 PyInit_<modulename>,其中 <modulename> 是模块的名称。
对于包含非 ASCII 字符的模块名称,导入钩子名为 PyInitU_<encodedname>,其中名称使用 CPython 的“punycode”编码 (Punycode,后缀为小写字母),并将连字符 (“-”) 替换为下划线 (“_”)。
在 Python 中
def export_hook_name(name):
try:
suffix = b'_' + name.encode('ascii')
except UnicodeEncodeError:
suffix = b'U_' + name.encode('punycode').replace(b'-', b'_')
return b'PyInit' + suffix
示例
模块名称 | Init 钩子名称 |
---|---|
spam | PyInit_spam |
lančmít | PyInitU_lanmt_2sa6t |
スパム | PyInitU_zck5b2b |
对于具有非 ASCII 名称的模块,不支持单阶段初始化。
在本 PEP 的初始实现中,不支持具有非 ASCII 名称的内置模块。
模块重新加载
使用 importlib.reload() 重新加载扩展模块将继续没有任何效果,除了重新设置与导入相关的属性。
由于共享库加载的限制(包括 POSIX 上的 dlopen 和 Windows 上的 LoadModuleEx),在磁盘上更改后通常不可能加载修改后的库。
除了尝试使用模块的新版本之外,重新加载的其他用例过于罕见,无法要求所有模块作者都记住重新加载。如果需要类似重新加载的功能,作者可以为其导出专用函数。
一个库中的多个模块
为了在一个共享库中支持多个 Python 模块,库可以导出除与库文件名对应的 PyInit* 符号之外的其他符号。
请注意,此机制目前只能用于加载额外的模块,而不能用于查找它们。(这是加载器机制的限制,本 PEP 不会尝试修改。)为了解决缺少合适的查找器的問題,可以使用以下代码
import importlib.machinery
import importlib.util
loader = importlib.machinery.ExtensionFileLoader(name, path)
spec = importlib.util.spec_from_loader(name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
在支持符号链接的平台上,可以使用符号链接将一个库安装在多个名称下,并将所有导出的模块公开给正常的导入机制。
测试和初始实现
为了进行测试,将创建一个新的内置模块 _testmultiphase
。该库将使用“一个库中的多个模块”中描述的机制导出几个额外的模块。
模块 _testcapi
将保持不变,并将无限期地使用单阶段初始化(或者直到不再支持它)。
模块 array
和 xx*
将被转换为使用多阶段初始化,作为初始实现的一部分。
API 更改和添加的摘要
新函数
- PyModule_FromDefAndSpec(宏)
- PyModule_FromDefAndSpec2
- PyModule_ExecDef
- PyModule_SetDocString
- PyModule_AddFunctions
- PyModuleDef_Init
新宏
- Py_mod_create
- Py_mod_exec
新类型
- 将公开 PyModuleDef_Type
新结构
- PyModuleDef_Slot
其他更改
PyModuleDef.m_reload 更改为 PyModuleDef.m_slots。
BuiltinImporter
和 ExtensionFileLoader
现在将实现 create_module
和 exec_module
。
内部模块 _imp
将进行向后不兼容的更改:将添加 create_builtin
、create_dynamic
和 exec_dynamic
;将删除 init_builtin
、load_dynamic
。
未记录的函数 imp.load_dynamic
和 imp.init_builtin
将被向后兼容的垫片替换。
向后兼容性
现有模块将继续与新版本的 Python 具有源代码和二进制兼容性。使用多阶段初始化的模块将与未实现本 PEP 的 Python 版本不兼容。
函数 init_builtin
和 load_dynamic
将从模块 _imp
中删除(但不会从模块 imp
中删除)。
所有更改的加载器(BuiltinImporter
和 ExtensionFileLoader
)将保持向后兼容;方法 load_module
将被垫片替换。
将删除 Python/import.c 和 Python/importdl.c 的内部函数。(具体来说,这些是 _PyImport_GetDynLoadFunc
、_PyImport_GetDynLoadWindows
和 _PyImport_LoadDynamicModule
。)
未来可能的扩展
槽机制受到 PEP 384 中 PyType_Slot 的启发,允许以后进行扩展。
一些扩展模块导出许多常量;例如,_ssl 有一个很长的调用列表,形式如下
PyModule_AddIntConstant(m, "SSL_ERROR_ZERO_RETURN",
PY_SSL_ERROR_ZERO_RETURN);
将其转换为类似于 PyMethodDef 的声明性列表,将减少样板代码,并提供免费的错误检查,而这些错误检查通常是缺失的。
字符串常量和类型可以类似地处理。(请注意,类型的非默认基类不能以可移植的方式静态指定;这种情况需要在添加槽之前运行的 Py_mod_exec 函数。不过,免费的错误检查仍然有益。)
另一个可能性是提供一个“main”函数,该函数将在模块传递给 Python 的 -m 开关时运行。为了使这能够工作,runpy 模块需要修改以利用 PEP 451 中引入的基于 ModuleSpec 的加载。此外,还需要添加一种机制来根据模块最初未定义的槽来设置模块。
实现
先前的方法
Stefan Behnel 的初始 proto-PEP [1] 拥有一个名为“PyInit_modulename”的钩子,它会创建一个模块类,然后调用该类的 __init__
方法来创建模块。此提案与当时(尚未存在)的 PEP 451 不符,PEP 451 中模块创建和初始化被拆分为不同的步骤。它也不支持将扩展加载到预先存在的模块对象中。
Alyssa (Nick) Coghlan 提出了“Create”和“Exec”钩子,并编写了原型实现 [2]。当时 PEP 451 尚未实现,因此原型没有使用 ModuleSpec。
此 PEP 的最初版本使用了 Create 和 Exec 钩子,并允许将扩展加载到任意预先构建的对象中,使用 Exec 钩子。该提案使扩展模块的初始化更接近 Python 模块的初始化方式,但后来意识到这并非一个重要目标。当前的 PEP 描述了一种更简单的解决方案。
进一步的迭代使用了一个名为“PyModuleExport”的钩子作为 PyInit 的替代方案,其中 PyInit 用于现有方案,而 PyModuleExport 用于多阶段方案。但是,无法根据模块名称确定钩子名称,这使得像 freeze 这样的工具自动生成 PyImport_Inittab 变得复杂。仅保留 PyInit 钩子名称,即使它并不完全适合于导出定义,也带来了更简单的解决方案。
参考文献
版权
本文件已进入公有领域。
来源:https://github.com/python/peps/blob/main/peps/pep-0489.rst
最后修改时间:2023-10-11 12:05:51 GMT