PEP 657 – 在回溯信息中包含细粒度的错误位置
- 作者:
- Pablo Galindo <pablogsal at python.org>,Batuhan Taskaya <batuhan at python.org>,Ammar Askar <ammar at ammaraskar.com>
- 讨论列表:
- Discourse 帖子
- 状态:
- 最终
- 类型:
- 标准跟踪
- 创建:
- 2021年5月8日
- Python 版本:
- 3.11
- 历史记录:
摘要
本 PEP 提案添加一个映射,将每个字节码指令映射到生成它们的行的起始和结束列偏移量,以及结束行号。此数据将用于改进 CPython 解释器显示的回溯信息,以改善调试体验。PEP 还提议添加 API,允许其他工具(例如代码覆盖率分析工具、分析器、跟踪器、调试器)从代码对象中使用此信息。
动机
本 PEP 的主要动机是改进关于错误位置的反馈,以帮助调试。
Python 目前在编译时保留了一个字节码到行号的映射。解释器使用此映射来指向与错误关联的源代码行。虽然指令的行级粒度很有用,但一行 Python 代码可以编译成数十个字节码操作,这使得难以跟踪导致错误的代码行哪一部分。
考虑以下 Python 代码行
x['a']['b']['c']['d'] = 1
如果字典中的任何值是 None
,则显示的错误是
Traceback (most recent call last):
File "test.py", line 2, in <module>
x['a']['b']['c']['d'] = 1
TypeError: 'NoneType' object is not subscriptable
从回溯信息中,无法确定哪个字典具有导致错误的 None
元素。用户通常必须附加调试器或拆分表达式以追踪问题。
但是,如果解释器具有字节码到列偏移量以及行号的映射,它可以帮助显示
Traceback (most recent call last):
File "test.py", line 2, in <module>
x['a']['b']['c']['d'] = 1
~~~~~~~~~~~^^^^^
TypeError: 'NoneType' object is not subscriptable
指示用户对象 x['a']['b']
必须是 None
。此突出显示将出现在回溯信息的每个帧中。例如,如果类似的错误是复杂函数调用链的一部分,则回溯信息将显示与每个帧中当前指令关联的代码
Traceback (most recent call last):
File "test.py", line 14, in <module>
lel3(x)
^^^^^^^
File "test.py", line 12, in lel3
return lel2(x) / 23
^^^^^^^
File "test.py", line 9, in lel2
return 25 + lel(x) + lel(x)
^^^^^^
File "test.py", line 6, in lel
return 1 + foo(a,b,c=x['z']['x']['y']['z']['y'], d=e)
~~~~~~~~~~~~~~~~^^^^^
TypeError: 'NoneType' object is not subscriptable
此问题出现在以下情况下。
- 当将多个对象传递给函数调用时,同时访问其中的相同属性。例如,此错误
Traceback (most recent call last): File "test.py", line 19, in <module> foo(a.name, b.name, c.name) AttributeError: 'NoneType' object has no attribute 'name'
使用本 PEP 中的改进,这将显示
Traceback (most recent call last): File "test.py", line 17, in <module> foo(a.name, b.name, c.name) ^^^^^^ AttributeError: 'NoneType' object has no attribute 'name'
- 处理包含复杂数学表达式的行时,尤其是在 numpy 等库中,算术运算可能会根据参数失败。例如
Traceback (most recent call last): File "test.py", line 1, in <module> x = (a + b) @ (c + d) ValueError: operands could not be broadcast together with shapes (1,2) (2,3)
没有明确的指示说明哪个操作失败了,是左边的加法、右边的加法还是中间的矩阵乘法?使用本 PEP,新的错误消息将如下所示
Traceback (most recent call last): File "test.py", line 1, in <module> x = (a + b) @ (c + d) ~~^~~ ValueError: operands could not be broadcast together with shapes (1,2) (2,3)
提供了一个更清晰、更容易调试的错误消息。
除了调试之外,此额外信息对于代码覆盖率工具也很有用,使它们能够测量表达式级覆盖率,而不仅仅是行级覆盖率。例如,给定以下行
x = foo() if bar() else baz()
覆盖率、分析或状态分析工具将在两个分支中突出显示整行,这使得无法区分采取了哪个分支。这是 pycoverage 中的一个已知问题。
类似于本 PEP 的工作已经在其他语言中进行,例如 Java 中的 JEP358。Java 中的 NullPointerExceptions
在处理包含复杂表达式的行时同样模糊不清。一个 NullPointerException
在查找错误的根本原因方面提供的帮助非常少。JEP358 的实现相当复杂,需要使用控制流图分析器和反编译技术回溯字节码,以恢复导致空指针的源代码。尽管此解决方案的复杂性很高,并且每次更改 Java 字节码时都需要维护反编译器,但此改进被认为值得为仅一种异常类型提供的额外信息。
基本原理
为了识别在引发异常时执行的源代码范围,此提案要求为每个字节码指令添加新的数据。这将影响磁盘上 pyc
文件的大小以及内存中代码对象的大小。本提案的作者以一种试图最大程度地减少这种影响的方式选择了数据类型。建议的开销是为每个字节码指令存储两个 uint8_t
(一个用于起始偏移量,一个用于结束偏移量)以及结束行信息(以与当前存储的起始行相同的方式编码)。
作为一个说明性示例来衡量此更改的影响,我们计算出包含起始和结束偏移量将使标准库的 pyc 文件大小增加 22%(6MB),从 28.4MB 增加到 34.7MB。内存使用量的开销将相同(假设完整标准库加载到同一个程序中)。我们认为这是一个非常可以接受的数字,因为开销的数量级非常小,特别是考虑到现代计算机的存储大小和内存能力。此外,通常 Python 程序的内存大小不受代码对象支配。为了检查这个假设,我们执行了几个流行的 PyPI 项目(包括 NumPy、pytest、Django 和 Cython)以及几个应用程序(Black、pylint、mypy 在 mypy 或标准库上执行)的测试套件,我们发现代码对象通常占程序平均内存大小的 3-6%。
我们理解此信息的额外成本对于某些用户来说可能无法接受,因此我们提出了一个退出机制,该机制将导致生成的代码对象不包含额外信息,同时还允许 pyc 文件不包含额外信息。
规范
为了获得足够的信息来正确解析引发错误的给定行中的位置,需要一个将字节码指令链接到列偏移量(起始和结束偏移量)和结束行号的映射。这类似于当前将行号链接到字节码指令的方式。
以下更改将在本 PEP 的实现过程中执行
- 偏移量信息将通过代码对象类中的一个新属性
co_positions
公开给 Python,该属性将返回一个四元素元组序列,其中包含每个指令的完整位置(包括起始行、结束行、起始列偏移量和结束列偏移量),或者如果代码对象是在没有偏移量信息的情况下创建的,则返回None
。 - 一个新的 C-API 函数
int PyCode_Addr2Location( PyCodeObject *co, int addrq, int *start_line, int *start_column, int *end_line, int *end_column)
将被添加,以便在给定字节码指令的索引的情况下可以获取结束行、起始列偏移量和结束列偏移量。如果信息不可用,此函数将值设置为 0。
信息的内部存储、压缩和编码作为实现细节,可以在任何时候更改,只要公共 API 保持不变。
偏移量语义
这些偏移量由编译器从当前存储在所有 AST 节点中的偏移量传播。处理这些属性的公共 API(co_positions
和 PyCode_Addr2Location
)的输出使用 0 索引偏移量(就像 AST 节点一样),但底层实现可以自由地以任何它们认为最有效的方式表示实际数据。关于信息不可用的错误代码对于 co_positions()
API 是 None
,对于 PyCode_Addr2Location
API 是 -1
。信息的可访问性高度依赖于偏移量是否在范围内,以及解释器配置的运行时标志。
AST 节点使用 int
类型来存储这些值。但是,当前实现使用 uint8_t
类型作为实现细节以最大程度地减少存储影响。此决定允许偏移量从 0 到 255,而大于这些值的偏移量将被视为缺失(在 PyCode_Addr2Location
上返回 -1
,在 co_positions()
API 中返回 None
)。
如前所述,偏移量的底层存储应被视为实现细节,因为获取这些值的公共 API 将返回 C int
类型或 Python int
对象,这允许将来实现更好的压缩/编码,如果需要支持更大的范围。本 PEP 建议从这个更简单的版本开始,并将改进推迟到将来的工作中。
显示回溯信息
在显示回溯信息时,默认异常挂钩将被修改为从代码对象查询此信息,并在信息可用时使用它来为回溯信息中显示的每一行显示一系列插入符号。例如
File "test.py", line 6, in lel
return 1 + foo(a,b,c=x['z']['x']['y']['z']['y'], d=e)
~~~~~~~~~~~~~~~~^^^^^
TypeError: 'NoneType' object is not subscriptable
在显示回溯信息时,指令偏移量将从回溯信息对象中获取。这使得突出显示重新引发的异常可以自然地工作,而无需在堆栈中存储新信息。例如,对于此代码
def foo(x):
1 + 1/0 + 2
def bar(x):
try:
1 + foo(x) + foo(x)
except Exception as e:
raise ValueError("oh no!") from e
bar(bar(bar(2)))
打印的回溯信息将如下所示
Traceback (most recent call last):
File "test.py", line 6, in bar
1 + foo(x) + foo(x)
^^^^^^
File "test.py", line 2, in foo
1 + 1/0 + 2
~^~
ZeroDivisionError: division by zero
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "test.py", line 10, in <module>
bar(bar(bar(2)))
^^^^^^
File "test.py", line 8, in bar
raise ValueError("oh no!") from e
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
ValueError: oh no
而此代码
def foo(x):
1 + 1/0 + 2
def bar(x):
try:
1 + foo(x) + foo(x)
except Exception:
raise
bar(bar(bar(2)))
将显示为
Traceback (most recent call last):
File "test.py", line 10, in <module>
bar(bar(bar(2)))
^^^^^^
File "test.py", line 6, in bar
1 + foo(x) + foo(x)
^^^^^^
File "test.py", line 2, in foo
1 + 1/0 + 2
~^~
ZeroDivisionError: division by zero
保持当前行为,回溯信息中只会显示一行。对于跨越多行的指令(结束偏移量和起始偏移量属于不同的行),必须检查结束行号以了解结束偏移量是否与起始偏移量应用于同一行。
退出机制
为了为那些关心存储和内存开销的用户提供退出机制,并允许当前正在解析回溯信息的第三方工具和其他程序赶上,将提供以下方法来停用此功能
- 一个新的环境变量:
PYTHONNODEBUGRANGES
。 - 开发模式的一个新的命令行选项:
python -Xno_debug_ranges
。
如果使用了任何这些方法,Python 编译器将**不会**用新信息填充代码对象(将使用None
代替),并且任何包含额外信息的已反序列化的代码对象都将被剥离这些信息并替换为None
)。此外,即使信息存在,跟踪回溯机制也不会显示扩展的位置信息。此方法允许用户
- 通过在创建
pyc
文件时使用两种方法之一,创建更小的pyc
文件。 - 如果
pyc
文件最初创建时包含额外信息,则不要加载这些信息。 - 在显示跟踪回溯(指示错误位置的插入符号字符)时停用额外信息。
这样做会带来**非常小**的性能损失,因为在创建代码对象时需要获取解释器状态以查找配置。创建代码对象不是性能敏感的操作,因此这应该不是问题。
向后兼容性
更改完全向后兼容。
参考实现
可以在实现分支中找到参考实现。
被拒绝的想法
使用单个插入符号而不是范围
有人建议在报告错误时使用单个插入符号而不是突出显示整个范围来简化功能。我们决定不采用这种方式,原因如下
- 使用当前的 AST 布局推导出插入符号的位置并不简单。这是因为 AST 节点仅记录开始和结束行号以及开始和结束列偏移量。由于 AST 节点(根据设计)不保留原始标记,因此如果不进行额外的重新解析,则无法推导出某些标记的确切位置。例如,当前二元运算符为操作数具有节点,但运算符的类型存储在枚举中,因此无法从节点中推导出其位置(这只是此问题如何表现的一个示例,而不是唯一的示例)。
- 从 AST 节点推导出范围极大地简化了实现,并降低了维护成本和错误可能性。这是因为对任何 AST 节点始终可以使用范围进行泛型处理,而任何其他自定义信息都需要从不同类型的节点中以不同的方式提取。鉴于在生成 AST 时,手动获取位置在曾经是手动过程时容易出错,我们认为泛型解决方案是一个非常重要的特性。
- 存储突出显示单个插入符号的信息对于覆盖工具和分析器等工具以及希望使用此新功能的 IPython 和 IDE 等工具来说将非常有限。正如“friendly-traceback”作者提到的那样,原因是如果没有完整的范围(包括结束行),这些工具将难以正确突出显示相关源代码。例如,对于以下代码
something = foo(a,b,c) if bar(a,b,c) else other(b,c,d)
工具(如覆盖率报告器)希望能够突出显示由执行的字节码覆盖的调用的全部内容(假设为
foo(a,b,c)
),而不仅仅是一个字符。即使从技术上讲可以重新解析和重新标记源代码以重建信息,但无法可靠地执行此操作,并且会导致更糟糕的用户体验。 - 许多用户报告说,单个插入符号比完整范围更难阅读,这促使使用范围来突出显示语法错误,这受到了很好的评价。此外,有人指出,视力障碍的用户可以比单个插入符号字符更容易识别范围,我们认为这是使用范围的一大优势。
使用配置标志退出
即使在非优化模式下执行 Python 时,也可能需要一个配置标志来选择退出开销,但这在读取使用未激活该标志的解释器版本创建的 pyc 文件时可能会导致问题。这可能导致普通用户难以调试的崩溃,并导致不同的 pyc 文件彼此不兼容。由于此 pyc 可能作为库或应用程序的一部分交付,而没有原始源代码,因此也不总是可以强制重新编译这些 pyc 文件。由于这些原因,我们决定使用 -O 标志来选择退出此行为。
列信息的延迟加载
减少此功能内存使用的一种潜在解决方案是在导入代码时不从 pyc 文件加载列信息。只有当未捕获的异常冒泡或进行对 C-API 函数的调用时,才会从 pyc 文件加载列信息。这类似于我们仅在异常冒泡时读取源代码行以在跟踪回溯中显示它们的方式。虽然这确实会降低内存使用量,但它也导致更复杂的实现,需要更改导入机制以选择性地忽略代码对象的一部分。我们认为这是一个值得探索的有趣途径,但最终我们认为它超出了此特定 PEP 的范围。这也意味着如果用户未使用 pyc 文件或对于在运行时动态创建的代码对象,列信息将不可用。
实现压缩
虽然可以对 pyc 文件和代码对象中的新数据进行某种形式的压缩,但我们认为这超出了本提案的范围,因为它具有更大的影响(在 pyc 文件的情况下)以及我们预计列偏移量由于缺乏模式而无法很好地压缩(在代码对象中的新数据的情况下)。
致谢
感谢 Carl Friedrich Bolz-Tereick 为 Pypy 解释器展示了这个想法的初始原型,并进行了有益的讨论。
版权
本文档置于公共领域或根据 CC0-1.0-Universal 许可证,以两者中许可范围更广者为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0657.rst