PEP 657: Include fine grained error locations in tracebacks (GH-1950)
This commit is contained in:
parent
fae0ce2014
commit
822755724f
|
@ -0,0 +1,405 @@
|
|||
PEP: 657
|
||||
Title: Include Fine Grained Error Locations in Tracebacks
|
||||
Version: $Revision$
|
||||
Last-Modified: $Date$
|
||||
Author: Pablo Galindo <pablogsal@python.org>,
|
||||
Batuhan Taskaya <batuhan@python.org>,
|
||||
Ammar Askar <ammar@ammaraskar.com>
|
||||
Status: Draft
|
||||
Type: Standards Track
|
||||
Content-Type: text/x-rst
|
||||
Created: 08-May-2021
|
||||
Python-Version: 3.11
|
||||
Post-History:
|
||||
|
||||
Abstract
|
||||
========
|
||||
|
||||
This PEP proposes adding a mapping from each bytecode instruction to the start
|
||||
and end column offsets of the line that generated them. This data will be used
|
||||
to improve tracebacks displayed by the CPython interpreter in order to improve
|
||||
the debugging experience. The PEP also proposes adding APIs that allow other
|
||||
tools (such as coverage analysis tools, profilers, tracers, debuggers) to
|
||||
consume this information from code objects.
|
||||
|
||||
Motivation
|
||||
==========
|
||||
|
||||
The primary motivation for this PEP is to improve the feedback presented about the location of errors to aid with debugging.
|
||||
|
||||
Python currently keeps a mapping of bytecode to line numbers from compilation.
|
||||
The interpreter uses this mapping to point to the source line associated with
|
||||
an error. While this line-level granularity for instructions is useful, a
|
||||
single line of Python code can compile into dozens of bytecode operations
|
||||
making it hard to track which part of the line caused the error.
|
||||
|
||||
Consider the following line of Python code::
|
||||
|
||||
x['a']['b']['c']['d'] = 1
|
||||
|
||||
If any of the values in the dictionaries are ``None``, the error shown is::
|
||||
|
||||
Traceback (most recent call last):
|
||||
File "test.py", line 2, in <module>
|
||||
x['a']['b']['c']['d'] = 1
|
||||
TypeError: 'NoneType' object is not subscriptable
|
||||
|
||||
From the traceback, it is impossible to determine which one of the dictionaries
|
||||
had the ``None`` element that caused the error. Users often have to attach a
|
||||
debugger or split up their expression to track down the problem.
|
||||
|
||||
However, if the interpreter had a mapping of bytecode to column offsets as well
|
||||
as line numbers, it could helpfully display::
|
||||
|
||||
Traceback (most recent call last):
|
||||
File "test.py", line 2, in <module>
|
||||
x['a']['b']['c']['d'] = 1
|
||||
^^^^^^^^^^^^^^^^
|
||||
TypeError: 'NoneType' object is not subscriptable
|
||||
|
||||
indicating to the user that the object ``x['a']['b']`` must have been ``None``.
|
||||
This highlighting will occur for every frame in the traceback. For instance, if
|
||||
a similar error is part of a complex function call chain, the traceback would
|
||||
display the code associated to the current instruction in every frame::
|
||||
|
||||
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
|
||||
|
||||
This problem presents itself in the following situations.
|
||||
|
||||
* When passing down multiple objects to function calls while
|
||||
accessing the same attribute in them.
|
||||
For instance, this error::
|
||||
|
||||
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'
|
||||
|
||||
With the improvements in this PEP this would show::
|
||||
|
||||
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'
|
||||
|
||||
* When dealing with lines with complex mathematical expressions,
|
||||
especially with libraries such as numpy where arithmetic
|
||||
operations can fail based on the arguments. For example: ::
|
||||
|
||||
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)
|
||||
|
||||
There is no clear indication as to which operation failed, was it the addition
|
||||
on the left, the right or the matrix multiplication in the middle? With this
|
||||
PEP the new error message would look like::
|
||||
|
||||
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)
|
||||
|
||||
Giving a much clearer and easier to debug error message.
|
||||
|
||||
|
||||
Debugging aside, this extra information would also be useful for code
|
||||
coverage tools, enabling them to measure expression-level coverage instead of
|
||||
just line-level coverage. For instance, given the following line: ::
|
||||
|
||||
x = foo() if bar() else baz()
|
||||
|
||||
coverage, profile or state analysis tools will highlight the full line in both
|
||||
branches, making it impossible to differentiate what branch was taken. This is
|
||||
a known problem in pycoverage_.
|
||||
|
||||
Similar efforts to this PEP have taken place in other languages such as Java in
|
||||
the form of JEP358_. ``NullPointerExceptions`` in Java were similarly nebulous when
|
||||
it came to lines with complicated expressions. A ``NullPointerException`` would
|
||||
provide very little aid in finding the root cause of an error. The
|
||||
implementation for JEP358 is fairly complex, requiring walking back through the
|
||||
bytecode by using a control flow graph analyzer and decompilation techniques to
|
||||
recover the source code that led to the null pointer. Although the complexity
|
||||
of this solution is high and requires maintenance for the decompiler every time
|
||||
Java bytecode is changed, this improvement was deemed to be worth it for the
|
||||
extra information provided for *just one exception type*.
|
||||
|
||||
|
||||
Rationale
|
||||
=========
|
||||
|
||||
In order to identify the range of source code being executed when exceptions
|
||||
are raised, this proposal requires adding new data for every bytecode
|
||||
instruction. This will have an impact on the size of ``pyc`` files on disk and
|
||||
the size of code objects in memory. The authors of this proposal have chosen
|
||||
the data types in a way that tries to minimize this impact. The proposed
|
||||
overhead is storing two ``uint8_t`` (one for the start offset and one for the
|
||||
end offset) for every bytecode instruction.
|
||||
|
||||
As an illustrative example to gauge the impact of this change, we have
|
||||
calculated that this change will increase the size of the standard library’s
|
||||
pyc files by 22% (6MB) from 70MB to 76MB. The overhead in memory usage will be
|
||||
the same (assuming the *full standard library* is loaded into the same
|
||||
program). We believe that this is a very acceptable number since the order of
|
||||
magnitude of the overhead is very small, especially considering the storage
|
||||
size and memory capabilities of modern computers. Additionally, in general the
|
||||
memory size of a Python program is not dominated by code objects. To check this
|
||||
assumption we have executed the test suite of several popular PyPI projects
|
||||
(including NumPy, pytest, Django and Cython) as well as several applications
|
||||
(Black, pylint, mypy executed over either mypy or the standard library) and we
|
||||
found that code objects represent normally 3-6% of the average memory size of
|
||||
the program.
|
||||
|
||||
We understand that the extra cost of this information may not be acceptable for
|
||||
some users, so we propose an opt-out mechanism when Python is executed in
|
||||
optimized mode (``python -O``), which will cause pyo files to not include the
|
||||
extra information.
|
||||
|
||||
|
||||
Specification
|
||||
=============
|
||||
|
||||
In order to have enough information to correctly resolve the location within a
|
||||
given line where an error was raised, a map linking bytecode instructions and
|
||||
column offsets (start and end offset) is needed. This is similar in fashion to
|
||||
how line numbers are currently linked to bytecode instructions.
|
||||
|
||||
The following changes will be performed as part of the implementation of this PEP:
|
||||
|
||||
* The offset information will be exposed to Python via a new attribute in the
|
||||
code object class called ``co_col_offsets`` that will return a sequence of
|
||||
two-element tuples (containing the start offsets and end offsets) or None if
|
||||
the code object was created without the offset information.
|
||||
* Two new C-API functions, ``PyCode_Addr2StartOffset`` and
|
||||
``PyCode_Addr2EndOffset`` will be added that can obtain the start and end
|
||||
offsets respectively given the index of a bytecode instruction. These
|
||||
functions will return 0 if the offset information is not available.
|
||||
* A new private (underscore prefixed) C-API constructor for code objects will
|
||||
be added that takes a bytes object containing the start offsets in the even
|
||||
position and the end offsets in the odd positions. Old constructors will be
|
||||
left untouched for backwards compatibility and will create code objects
|
||||
without the new field.
|
||||
|
||||
Offset semantics
|
||||
^^^^^^^^^^^^^^^^
|
||||
|
||||
These offsets are propagated by the compiler from the ones stored currently in
|
||||
all AST nodes. They are 1-indexed and a value of 0 will mean that the
|
||||
information is not available. Although the AST nodes use ``int`` types to store
|
||||
these values, ``uint8_t`` types will be used for storage in the new map to
|
||||
minimize storage impact. This decision allows offsets to go from 0 to 255,
|
||||
while offsets bigger than these values will be treated as missing (value of 0).
|
||||
We believe this is an acceptable compromise as line lengths in Python tend to
|
||||
be much lower than this limit (a query of the top 100 packages in PyPI shows
|
||||
that less than 0.01% of lines were longer than 255 characters).
|
||||
|
||||
Maintaining the current behavior, only a single line will be displayed in
|
||||
tracebacks. For instructions that span multiple lines (the end offset and the
|
||||
start offset belong to different lines), the end offset will be set to 0
|
||||
(meaning it is unavailable). If the start offset is not 0, this will be
|
||||
interpreted by the displaying code as if the range spans from the starting
|
||||
offset to the end of the line. The actual end offset cannot be calculated at
|
||||
compile time since the compiler does not know how many characters “the end of
|
||||
the line” actually represents.
|
||||
|
||||
Displaying tracebacks
|
||||
^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
When displaying tracebacks, the default exception hook will be modified to
|
||||
query this information from the code objects and use it to display a sequence
|
||||
of carets for every displayed line in the traceback if the information is
|
||||
available. For instance::
|
||||
|
||||
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
|
||||
|
||||
When displaying tracebacks, instruction offsets will be taken from the
|
||||
traceback objects. This makes highlighting exceptions that are re-raised work
|
||||
naturally without the need to store the new information in the stack. For
|
||||
example, for this code::
|
||||
|
||||
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)))
|
||||
|
||||
The printed traceback would look like this::
|
||||
|
||||
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
|
||||
|
||||
While this code::
|
||||
|
||||
def foo(x):
|
||||
1 + 1/0 + 2
|
||||
def bar(x):
|
||||
try:
|
||||
1 + foo(x) + foo(x)
|
||||
except Exception:
|
||||
raise
|
||||
bar(bar(bar(2)))
|
||||
|
||||
Will be displayed as::
|
||||
|
||||
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
|
||||
|
||||
|
||||
Opt-out mechanism
|
||||
^^^^^^^^^^^^^^^^^
|
||||
|
||||
To offer an opt-out mechanism for those users that care about the storage and
|
||||
memory overhead, the functionality will be deactivated along with the extra
|
||||
information when Python is executed in optimized mode (``python -O``) resulting
|
||||
in ``pyo`` files not having the overhead associated with the extra required
|
||||
data.
|
||||
|
||||
To allow third party tools and other programs that are currently parsing
|
||||
tracebacks to catch up and to allow users to deactivate the new feature, the
|
||||
following methods will be provided to deactivate displaying the new highlight
|
||||
carets (but not to avoid to storing the data, users will need to use Python in
|
||||
optimized mode for that):
|
||||
|
||||
* A new environment variable: ``PY_DEACTIVATE_TRACEBACK_RANGES``
|
||||
* A new command line option for the dev mode: ``python -Xnotracebackranges``.
|
||||
|
||||
These flags will be removed in the next version of the Python interpreter
|
||||
(counting from the version that releases this feature).
|
||||
|
||||
Backwards Compatibility
|
||||
=======================
|
||||
|
||||
The change is fully backwards compatible.
|
||||
|
||||
|
||||
Reference Implementation
|
||||
========================
|
||||
|
||||
A reference implementation can be found in the implementation_ fork.
|
||||
|
||||
Rejected Ideas
|
||||
==============
|
||||
|
||||
Include end line number
|
||||
^^^^^^^^^^^^^^^^^^^^^^^
|
||||
Some instructions can span across multiple lines and therefore the end offset
|
||||
and the start offset can be located in two different lines. We have decided to
|
||||
set the value for the start offset to the correct value and set a value of 0 to
|
||||
the end offset. This will result in highlighting the entire line starting from
|
||||
the value of the starting offset. The reason behind this decision is that
|
||||
storing the end line will require us to store another field similar to
|
||||
``co_lnotab``, but our traceback machinery only highlights a single line
|
||||
per frame so this information would only be used to decide to highlight to the
|
||||
end of the line. On the other hand, the end line could be useful for other
|
||||
tools such as coverage-measuring tools and tracers.
|
||||
|
||||
Have a configure flag to opt out
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
Having a configure flag to opt out of the overhead even when executing Python
|
||||
in non-optimized mode may sound desirable, but it may cause problems when
|
||||
reading pyc files that were created with a version of the interpreter that was
|
||||
not compiled with the flag activated. This can lead to crashes that would be
|
||||
very difficult to debug for regular users and will make different pyc files
|
||||
incompatible between each other. As this pyc could be shipped as part of
|
||||
libraries or applications without the original source, it is also not always
|
||||
possible to force recompilation of said pyc files. For these reasons we have
|
||||
decided to use the -O flag to opt-out of this behaviour.
|
||||
|
||||
Lazy loading of column information
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
One potential solution to reduce the memory usage of this feature is to not
|
||||
load the column information from the pyc file when code is imported. Only if an
|
||||
uncaught exception bubbles up or if a call to the C-API functions is made will
|
||||
the column information be loaded from the pyc file. This is similar to how we
|
||||
only read source lines to display them in the traceback when an exception
|
||||
bubbles up. While this would indeed lower memory usage, it also results in a
|
||||
far more complex implementation requiring changes to the importing machinery to
|
||||
selectively ignore a part of the code object. We consider this an interesting
|
||||
avenue to explore but ultimately we think is out of the scope for this particular
|
||||
PEP. It also means that column information will not be available if the user is
|
||||
not using pyc files or for code objects created dynamically at runtime.
|
||||
|
||||
Implement compression
|
||||
^^^^^^^^^^^^^^^^^^^^^
|
||||
Although it would be possible to implement some form of compression over the
|
||||
pyc files and the new data in code objects, we believe that this is out of the
|
||||
scope of this proposal due to its larger impact (in the case of pyc files) and
|
||||
the fact that we expect column offsets to not compress well due to the lack of
|
||||
patterns in them (in case of the new data in code objects).
|
||||
|
||||
Acknowledgments
|
||||
===============
|
||||
Thanks to Carl Friedrich Bolz-Tereick for showing an initial prototype of this
|
||||
idea for the Pypy interpreter and for the helpful discussion.
|
||||
|
||||
|
||||
References
|
||||
==========
|
||||
|
||||
.. _JEP358: https://openjdk.java.net/jeps/358
|
||||
.. _implementation: https://github.com/colnotab/cpython/tree/bpo-43950
|
||||
.. _pycoverage: https://github.com/nedbat/coveragepy/issues/509
|
||||
|
||||
Copyright
|
||||
=========
|
||||
|
||||
This document is placed in the public domain or under the
|
||||
CC0-1.0-Universal license, whichever is more permissive.
|
||||
|
||||
..
|
||||
Local Variables:
|
||||
mode: indented-text
|
||||
indent-tabs-mode: nil
|
||||
sentence-end-double-space: t
|
||||
fill-column: 70
|
||||
coding: utf-8
|
||||
End:
|
Loading…
Reference in New Issue