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中的NullPointerException在处理包含复杂表达式的行时同样模糊不清。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文件最初是用额外信息创建的,则不从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