签名C扩展

此模块是CPython 3.5及以上版本以及CPython 2.7的C扩展。 其目的是为内置PyCFunction对象的__signature__属性提供支持。

主题简介

从CPython 3.5开始,Python函数开始为普通的Python函数增加一个__signature__属性。这完全是可选的,只是Python中的一个不错的功能。

另一方面,PySide 非常需要 __signature__,因为 15000 多个 PySide 函数的类型信息确实缺失,如果能直接获取这些信息将会非常有用。

支持签名的想法

我们希望在所有PySide方法中增加一个__signature__属性,而不需要改变大量生成的代码。 因此,我们没有改变任何现有的数据结构, 而是通过一个全局字典来支持这个新属性。

当请求__signature__属性时,会调用一个方法在全局字典中进行查找。这是一种灵活的方法,对项目的其余部分影响很小。与直接属性访问相比,它的开销非常有限,但对于偶尔需要访问签名的需求来说,这是一个适当的折衷方案。

这段代码如何工作

仅支持常规Python函数的签名。为PyCFunction对象创建签名在Python中需要相当多的额外工作。

幸运的是,我们发现了这种特殊的隐身技术,它为我们节省了大部分所需的努力:

基本思想是创建一个具有varnamesdefaultsannotations属性的虚拟Python函数,然后使用inspect模块创建一个签名对象。该对象作为真实PyCFunction对象的__signature__属性的计算结果返回。

有一件事真的让Python有些改变:

  • 我们为每个函数添加了__signature__属性。

这是对Python的一个小改动,虽然无害,但它为我们节省了大量的代码,这些代码在模块的早期版本中是必需的。

内部工作分为两个步骤:

  • 当模块被导入时,类的所有函数都会获得签名文本。 这只是启动时间中增加的一个非常小的开销。对于整个类来说,它只是一个字符串。

  • 实际的签名对象是在稍后真正请求属性时创建的。签名会被缓存,并且只在第一次访问时创建。

示例:

PyCFunction QtWidgets.QApplication.palette 被询问其签名。这意味着调用了 pyside_sm_get___signature__()。它调用 GetSignature_Function,如果找到签名,则返回签名。

为什么这段代码很快

遍历每个签名对象需要花费一点时间(大约6秒),因为这些对象超过25000个Python对象。但所有签名对象很少被访问,除非在特殊应用中。通常情况下只有少数访问,这些访问速度非常快。

使这个签名模块快速的关键是尽可能避免计算。当没有使用签名对象时,初始化几乎不会浪费时间。只有在import PySide6时,才会额外加载上述提到的字符串和一些支持模块。当涉及到签名使用时,会使用延迟初始化并进行缓存。这种技术在haskell中也被称为完全惰性

实际上有两个地方会发生延迟初始化:

  • dict 可以不是字典而是元组。这是由 PySide_BuildSignatureArgs 在模块加载时保存的初始参数元组。 如果是这样,那么将调用 parser.py 中的 pyside_type_init,它会解析字符串并创建字典。

  • props 可以为空。然后调用 loader.py 中的 create_signature,它使用一个虚拟函数通过 inspect 模块生成一个签名实例。

始终执行的初始化只是每个类的两次字典写入,我们有大约1000个类。 为了测量额外的开销,我们模拟了当执行from PySide6 import *时会发生什么。 结果表明,开销低于0.5毫秒。

签名包结构

与签名模块相关的C++代码完全位于文件 shiboken6/libshiboken/signature.cpp 中。所有其他功能都在 signature Python包中实现。它具有以下结构:

sources/shiboken6/shibokenmodule/files.dir/shibokensupport
├── __init__.py
├── feature.py
├── fix-complaints.py
├── shibokensupport.pyproject
└── signature
    ├── PSF-3.7.0.txt
    ├── __init__.py
    ├── errorhandler.py
    ├── importhandler.py
    ├── layout.py
    ├── lib
    │   ├── __init__.py
    │   ├── enum_sig.py
    │   ├── pyi_generator.py
    │   └── tool.py
    ├── loader.py
    ├── mapping.py
    ├── parser.py
    └── qt_attribution.json

真正重要的是parsermappingerrorhandlerenum_siglayoutloader模块。其余部分是为了创建Python 2兼容性或与嵌入和安装程序兼容而需要的。

loader.py

该模块组装并导入inspect模块,然后导出create_signature函数。该函数接受一个假函数和一些属性,并使用inspect模块构建一个__signature__对象。

parser.py

该模块从C++中获取类签名字符串,并将其解析为create_signature函数所需的属性。其入口点是pyside_type_init函数,该函数通过loader.py从C模块中调用。

mapping.py

映射模块的目的是维护一个替换字符串列表,这些字符串将C中的签名文本映射到Python所需的属性字符串。许多映射通过parser.py中的相当复杂的表达式来解析,但在这里明确拼写几百个案例会更好。

errorhandler.py

自从 Qt For Python 5.12 版本以来,我们不再使用 C++ 的内置类型错误消息。 相反,我们使用签名模块获得了更好的结果。同时, 这也强制支持了 shiboken,并且签名模块不再是可选的。

enum_sig.py

签名模块的各种应用都需要遍历模块、类和函数。为了集中这种枚举,该过程已被提取为一个上下文管理器。用户只需提供实际进行格式化的函数。

例如,参见 .pyi 生成器 pyside6/PySide6/support/generate_pyi.py

layout.py

随着越来越多的应用程序使用签名模块,需要不同的签名格式。为了支持这一点,我们创建了函数create_signature,它有一个参数可以选择一些预定义的布局。

typing27.py

Python 2 完全没有 typing 模块。这是所需最小功能的后移植版本。

backport_inspect.py

Python 2 有一个 inspect 模块,但完全缺少签名功能。 这个模块添加了缺失的功能,这些功能在运行时被合并到 inspect 模块中。

多重参数

到目前为止被忽略的一个方面是多重参数:当一个函数有多个签名时如何处理?

我没有找到关于在Python中如何处理多个签名的任何说明, 但这些简单的规则似乎很有效:

  • 如果有一个列表,那么它是一个多重签名。

  • 否则,它是一个简单的签名。

签名模块的影响

签名模块对其他PySide模块有许多影响,这些影响是由于它的存在而产生的,未来还会有更多影响:

existence_test.py

文件 pyside6/tests/registry/existence_test.py 是使用签名模块中的签名编写的。其想法是有大约15000个具有特定签名的函数。

这些函数不应因某些不良的签入而丢失。因此,所有现有签名的列表被保留为一个模块,该模块组装了一个字典。检查函数的存在性,以及确切的参数数量。

该模块存在于每个PySide版本和每个平台中。初始模块生成一次并保存为exists_{plat}_{version}.py

错误通常只作为警告报告,但是:

与Coin模块的交互

当这个测试程序在COIN中运行时,警告会被转换为错误。原因是只有在COIN中,我们有一个稳定的PySide模块配置,可以可靠地进行比较。

这些模块的名称为exists_{platf}_{version}_ci.py,作为生成代码的一个大例外,这些文件是有意被检入的。

当列表缺失时会发生什么?

当创建新版本的PySide时,初始情况下不存在存在性测试文件。

当运行COIN测试时,它会报告错误并在标准输出上创建缺失的模块。 但由于COIN测试会多次运行,第一次测试生成的输出在后续运行中仍然存在。 (如果COIN被正确实现,我们就无法利用这一点,并且需要将其作为额外的异常来实现。)

因此,缺失的模块将被报告为部分成功的测试(称为“FLAKY”)。为了避免进一步的不可靠测试并激活为真正的测试,我们现在可以捕获COIN的错误输出并检查生成的模块。

显式强制重新创建

以前重新生成注册表文件的方法是删除文件并检查。这达到了预期的效果,但会产生巨大的差异。作为一种更有效的方法,我们在第一行准备了一个包含“recreate”一词的注释。通过取消注释这一行,会触发一个NameError,从而达到相同的效果。

init_platform.py

为了生成exists_{platf}_{version}模块,编写了模块 pyside6/tests/registry/init_platform.py。它可以从命令行独立使用,直接检查某些更改的兼容性。

scrape_testresults.py

为了简化和自动化提取exists_{platf}_{version}_ci.py文件的过程,编写了脚本pyside6/tests/registry/scrape_testresults.py

此脚本扫描整个 PySide 的测试结果网站,即:

https://testresults.qt.io/coin/api/results/pyside/pyside-setup/

在第一次扫描时,脚本运行时间不到30分钟。之后,会生成一个缓存,扫描速度会快得多。测试结果会被放入文件夹pyside6/tests/registry/testresults/embedded/中,并带有唯一的名称,便于排序。例如:

testresults/embedded/2018_09_10_10_40_34-test_1536891759-exists_linux_5_11_2_ci.py

这些文件只创建一次。如果它们已经存在,则不会再次被修改。 文件pyside6/tests/registry/known_urls.json`保存了成功扫描后所有扫描到的URL。testresults/embedded文件夹可以保留以供参考或可以删除。重要的是只有json文件。

扫描的结果会直接放入pyside6/tests/registry/文件夹中。应该进行审查,然后最终提交。

generate_pyi.py

pyside6/PySide6/support/generate_pyi.py 仍在开发中。 该模块生成所谓的提示存根,用于将 PySide 与各种 Python IDE 集成。

尽管此模块将存根创建为附加组件,但对签名模块质量的影响是相当大的:

模块必须创建语法正确的.pyi文件,这些文件不仅包含签名,还包含所有PySide模块的常量和枚举。这作为一个额外的挑战,对签名的完整性和正确性有非常积极的影响。

该模块有一个--feature选项来生成修改后的.pyi文件。 此命令的快捷方式是pyside6-genpyi

一个有用的命令是将所有 .pyi 文件更改为使用所有功能

pyside6-genpyi all --feature snake_case true_property

pyi_generator.py

shiboken6/shibokenmodule/files.dir/shibokensupport/signature/lib/pyi_generator.py 已从generate_pyi.py中提取出来。它允许从使用shiboken创建的任意扩展模块生成.pyi文件。

此命令的快捷方式是 shiboken6-genpyi

当前扩展

在签名模块编写之前,已经存在签名的概念,但更偏向于C++的方式。从那时起,就存在错误信息,这些信息是在函数接收到错误的参数类型时生成的。

这些错误消息被签名模块按需生成的文本所取代,以使其更加一致和正确。 这是在Qt For Python 5.12.0中实现的。

此外,PySide方法的__doc__属性未设置。 通过创建签名作为文档字符串的默认内容,很容易获得一个很好的help()功能。 这是在Qt For Python 5.12.1中实现的。

签名模块的更新与未来

PYSIDE-2101: The __signature__ attribute is gone due to rlcompleter.

2022年底,对rlcompleter模块的更改使得无法继续支持PySide中的非官方__signature__属性。从那时起,签名的功能由get_signature函数保留。

多年来,对生成的pyi文件正确性的要求急剧增加,并且投入了大量精力来确保生成的.pyi文件对于当前的mypy工具是正确的。关于已纠正错误类型的更多信息可以在mypy-correctnes部分找到。

文献

个人备注:这个模块献给我们的小鸟“Püppi”,它于2017年9月15日去世。