PEP 657 – Fein granulare Fehlerorte in Tracebacks einschließen
- Autor:
- Pablo Galindo <pablogsal at python.org>, Batuhan Taskaya <batuhan at python.org>, Ammar Askar <ammar at ammaraskar.com>
- Discussions-To:
- Discourse thread
- Status:
- Final
- Typ:
- Standards Track
- Erstellt:
- 08-Mai 2021
- Python-Version:
- 3.11
- Post-History:
Zusammenfassung
Diese PEP schlägt vor, eine Zuordnung von jedem Bytecode-Instruktion zu den Start- und End-Spalten-Offsets der Zeile, die sie erzeugt hat, sowie zur Endzeilennummer hinzuzufügen. Diese Daten werden verwendet, um Tracebacks, die vom CPython-Interpreter angezeigt werden, zu verbessern und so die Debugging-Erfahrung zu optimieren. Die PEP schlägt außerdem APIs vor, die es anderen Werkzeugen (wie Code-Coverage-Tools, Profilern, Tracern, Debuggern) ermöglichen, diese Informationen aus Code-Objekten zu konsumieren.
Motivation
Die Hauptmotivation für diese PEP ist die Verbesserung des Feedbacks zu den Fehlerorten, um das Debugging zu erleichtern.
Python behält derzeit eine Zuordnung von Bytecode zu Zeilennummern aus der Kompilierung bei. Der Interpreter verwendet diese Zuordnung, um auf die Quellzeile zu verweisen, die mit einem Fehler verbunden ist. Während diese Zeilengranularität für Anweisungen nützlich ist, kann eine einzige Zeile Python-Code in Dutzende von Bytecode-Operationen kompiliert werden, was es schwierig macht, nachzuvollziehen, welcher Teil der Zeile den Fehler verursacht hat.
Betrachten Sie die folgende Zeile Python-Code
x['a']['b']['c']['d'] = 1
Wenn einer der Werte in den Wörterbüchern None ist, wird der Fehler wie folgt angezeigt:
Traceback (most recent call last):
File "test.py", line 2, in <module>
x['a']['b']['c']['d'] = 1
TypeError: 'NoneType' object is not subscriptable
Aus dem Traceback ist es unmöglich zu bestimmen, welches der Wörterbücher das None-Element enthielt, das den Fehler verursacht hat. Benutzer müssen oft einen Debugger anhängen oder ihren Ausdruck aufteilen, um das Problem zu finden.
Hätte der Interpreter jedoch eine Zuordnung von Bytecode zu Spalten-Offsets sowie Zeilennummern, könnte er hilfreich anzeigen:
Traceback (most recent call last):
File "test.py", line 2, in <module>
x['a']['b']['c']['d'] = 1
~~~~~~~~~~~^^^^^
TypeError: 'NoneType' object is not subscriptable
wodurch dem Benutzer angezeigt wird, dass das Objekt x['a']['b'] None gewesen sein muss. Diese Hervorhebung erfolgt für jeden Frame im Traceback. Wenn beispielsweise ein ähnlicher Fehler Teil einer komplexen Funktionsaufrufkette ist, zeigt der Traceback in jedem Frame den Code an, der der aktuellen Anweisung zugeordnet ist.
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
Dieses Problem tritt in den folgenden Situationen auf.
- Beim Übergabe mehrerer Objekte an Funktionsaufrufe, während auf denselben Attribut in ihnen zugegriffen wird. Zum Beispiel dieser Fehler
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'
Mit den Verbesserungen in dieser PEP würde dies angezeigt werden:
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'
- Beim Umgang mit Zeilen mit komplexen mathematischen Ausdrücken, insbesondere bei Bibliotheken wie numpy, wo arithmetische Operationen basierend auf den Argumenten fehlschlagen können. Zum Beispiel
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)
Es gibt keine klare Anzeige, welche Operation fehlgeschlagen ist: war es die Addition links, rechts oder die Matrixmultiplikation in der Mitte? Mit dieser PEP würde die neue Fehlermeldung wie folgt aussehen:
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)
Was zu einer viel klareren und leichter zu debuggenden Fehlermeldung führt.
Abgesehen vom Debugging wären diese zusätzlichen Informationen auch für Code-Coverage-Tools nützlich, die es ihnen ermöglichen, Expression-Level-Coverage anstelle von nur Zeilen-Level-Coverage zu messen. Zum Beispiel bei der folgenden Zeile:
x = foo() if bar() else baz()
Tools für Coverage, Profiling oder Zustandsanalysen heben die gesamte Zeile in beiden Zweigen hervor, wodurch es unmöglich wird, zu unterscheiden, welcher Zweig genommen wurde. Dies ist ein bekanntes Problem in pycoverage.
Ähnliche Bemühungen wie bei dieser PEP wurden in anderen Sprachen wie Java in Form von JEP358 unternommen. NullPointerExceptions in Java waren bei Zeilen mit komplizierten Ausdrücken ähnlich undurchsichtig. Ein NullPointerException würde wenig zur Ursachenfindung eines Fehlers beitragen. Die Implementierung für JEP358 ist ziemlich komplex und erfordert das Zurückverfolgen des Bytecodes mithilfe eines Control-Flow-Graphen-Analyzers und Dekompilierungstechniken, um den Quellcode wiederherzustellen, der zum Nullzeiger geführt hat. Obwohl die Komplexität dieser Lösung hoch ist und die Wartung des Decompilers bei jeder Änderung des Java-Bytecodes erforderlich ist, wurde diese Verbesserung als lohnenswert für die zusätzlichen Informationen für *nur einen Ausnahmetyp* erachtet.
Begründung
Um den Bereich des Quellcodes zu identifizieren, der bei ausgelösten Ausnahmen ausgeführt wird, erfordert dieser Vorschlag das Hinzufügen neuer Daten für jede Bytecode-Instruktion. Dies hat Auswirkungen auf die Größe von pyc-Dateien auf der Festplatte und die Größe von Code-Objekten im Speicher. Die Autoren dieses Vorschlags haben die Datentypen so gewählt, dass sie diesen Einfluss minimieren. Der vorgeschlagene Overhead besteht darin, zwei uint8_t (einen für den Start-Offset und einen für den End-Offset) sowie die Endzeileninformationen für jede Bytecode-Instruktion zu speichern (in der gleichen kodierten Weise, wie die Startzeile derzeit gespeichert wird).
Als illustrative Beispiel zur Abschätzung der Auswirkungen dieser Änderung haben wir berechnet, dass die Einbeziehung der Start- und End-Offsets die Größe der pyc-Dateien der Standardbibliothek um 22% (6 MB) von 28,4 MB auf 34,7 MB erhöht. Der Overhead beim Speicherverbrauch ist derselbe (unter der Annahme, dass die *gesamte Standardbibliothek* im selben Programm geladen wird). Wir glauben, dass dies eine sehr akzeptable Zahl ist, da die Größenordnung des Overheads sehr gering ist, insbesondere angesichts der Speichergröße und der Speicherkapazitäten moderner Computer. Darüber hinaus wird die Speichergröße eines Python-Programms im Allgemeinen nicht von Code-Objekten dominiert. Um diese Annahme zu überprüfen, haben wir die Testsuite mehrerer beliebter PyPI-Projekte (einschließlich NumPy, pytest, Django und Cython) sowie mehrerer Anwendungen (Black, pylint, mypy über mypy oder die Standardbibliothek) ausgeführt und festgestellt, dass Code-Objekte normalerweise 3-6 % der durchschnittlichen Speichergröße des Programms ausmachen.
Wir verstehen, dass die zusätzlichen Kosten dieser Informationen für einige Benutzer möglicherweise nicht akzeptabel sind. Daher schlagen wir einen Opt-out-Mechanismus vor, der dazu führt, dass generierte Code-Objekte die zusätzlichen Informationen nicht enthalten und pyc-Dateien diese Informationen ebenfalls nicht enthalten.
Spezifikation
Um genügend Informationen zu haben, um den Ort innerhalb einer gegebenen Zeile, an der ein Fehler aufgetreten ist, korrekt aufzulösen, ist eine Zuordnung erforderlich, die Bytecode-Instruktionen mit Spalten-Offsets (Start- und End-Offset) und Endzeilennummern verknüpft. Dies geschieht ähnlich wie die aktuelle Verknüpfung von Zeilennummern mit Bytecode-Instruktionen.
Die folgenden Änderungen werden als Teil der Implementierung dieser PEP vorgenommen:
- Die Offset-Informationen werden Python über ein neues Attribut in der Code-Objekt-Klasse namens
co_positionsverfügbar gemacht, das eine Sequenz von vier-elementigen Tupeln zurückgibt, die den vollständigen Speicherort jeder Anweisung enthalten (einschließlich Startzeile, Endzeile, Startspalten-Offset und Endspalten-Offset) oderNone, wenn das Code-Objekt ohne die Offset-Informationen erstellt wurde. - Eine neue C-API-Funktion
int PyCode_Addr2Location( PyCodeObject *co, int addrq, int *start_line, int *start_column, int *end_line, int *end_column)
wird hinzugefügt, damit die Endzeile, die Startspalten-Offsets und der Endspalten-Offset anhand des Index einer Bytecode-Instruktion abgerufen werden können. Diese Funktion setzt die Werte auf 0, wenn die Informationen nicht verfügbar sind.
Die interne Speicherung, Kompression und Kodierung der Informationen bleibt ein Implementierungsdetail und kann jederzeit geändert werden, solange die öffentliche API unverändert bleibt.
Offset-Semantik
Diese Offsets werden vom Compiler aus den derzeit in allen AST-Knoten gespeicherten Offsets übernommen. Die Ausgabe der öffentlichen APIs (co_positions und PyCode_Addr2Location), die sich mit diesen Attributen befassen, verwendet 0-indizierte Offsets (genau wie die AST-Knoten), aber die zugrunde liegende Implementierung kann die tatsächlichen Daten in jeder Form darstellen, die sie für am effizientesten hält. Der Fehlercode bezüglich nicht verfügbarer Informationen ist None für die co_positions() API und -1 für die PyCode_Addr2Location API. Die Verfügbarkeit der Informationen hängt stark davon ab, ob die Offsets in den Bereich fallen, sowie von den Laufzeit-Flags der Interpreterkonfiguration.
Die AST-Knoten verwenden int-Typen, um diese Werte zu speichern. Die aktuelle Implementierung verwendet jedoch uint8_t-Typen als Implementierungsdetail, um den Speicherbedarf zu minimieren. Diese Entscheidung erlaubt Offsets von 0 bis 255, während Offsets größer als diese Werte als fehlend behandelt werden (was zu -1 in PyCode_Addr2Location und None in der co_positions() API führt).
Wie zuvor angegeben, sollte die zugrunde liegende Speicherung der Offsets als Implementierungsdetail betrachtet werden, da die öffentlichen APIs zum Abrufen dieser Werte entweder C int-Typen oder Python int-Objekte zurückgeben, was eine bessere Kompression/Kodierung in Zukunft ermöglicht, falls größere Bereiche unterstützt werden müssten. Diese PEP schlägt vor, mit dieser einfacheren Version zu beginnen und Verbesserungen für zukünftige Arbeiten aufzusparen.
Tracebacks anzeigen
Beim Anzeigen von Tracebacks wird der Standard-Exception-Hook modifiziert, um diese Informationen von den Code-Objekten abzurufen und sie zu verwenden, um eine Sequenz von Carets für jede angezeigte Zeile im Traceback anzuzeigen, falls die Informationen verfügbar sind. Zum Beispiel:
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
Beim Anzeigen von Tracebacks werden Instruktions-Offsets aus den Traceback-Objekten übernommen. Dadurch funktionieren Hervorhebungen von erneut ausgelösten Ausnahmen natürlich, ohne dass die neuen Informationen im Stack gespeichert werden müssen. Zum Beispiel für diesen 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)))
Der ausgegebene Traceback würde so aussehen:
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
Während dieser Code
def foo(x):
1 + 1/0 + 2
def bar(x):
try:
1 + foo(x) + foo(x)
except Exception:
raise
bar(bar(bar(2)))
würde wie folgt angezeigt werden:
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
Unter Beibehaltung des aktuellen Verhaltens wird in Tracebacks nur eine einzige Zeile angezeigt. Für Anweisungen, die sich über mehrere Zeilen erstrecken (der End-Offset und der Start-Offset gehören zu unterschiedlichen Zeilen), muss die Endzeilennummer geprüft werden, um zu wissen, ob der End-Offset zur selben Zeile wie der Start-Offset gehört.
Opt-out-Mechanismus
Um einen Opt-out-Mechanismus für Benutzer anzubieten, die den Speicher- und Speicheraufwand berücksichtigen, und um Drittanbieter-Tools und andere Programme, die derzeit Tracebacks parsen, die Möglichkeit zu geben, sich anzupassen, werden die folgenden Methoden bereitgestellt, um diese Funktion zu deaktivieren:
- Eine neue Umgebungsvariable:
PYTHONNODEBUGRANGES. - Eine neue Kommandozeilenoption für den Entwicklermodus:
python -Xno_debug_ranges.
Wenn eine dieser Methoden verwendet wird, wird der Python-Compiler die Code-Objekte **nicht** mit den neuen Informationen (stattdessen wird None verwendet) befüllen, und alle demarshallierten Code-Objekte, die die zusätzlichen Informationen enthalten, werden diese entfernt und durch None ersetzt. Zusätzlich wird die Traceback-Maschinerie die erweiterten Standortinformationen nicht anzeigen, selbst wenn die Informationen vorhanden waren. Diese Methode ermöglicht es Benutzern:
- Kleinere
pyc-Dateien zu erstellen, indem beim Erstellen eine der beiden Methoden verwendet wird. - Die zusätzlichen Informationen aus
pyc-Dateien nicht zu laden, wenn diese mit den zusätzlichen Informationen erstellt wurden. - Die zusätzlichen Informationen beim Anzeigen von Tracebacks zu deaktivieren (die Caret-Zeichen, die den Speicherort des Fehlers anzeigen).
Dies hat einen **sehr geringen** Leistungshitt, da der Interpreterzustand beim Erstellen von Code-Objekten abgerufen werden muss, um die Konfiguration abzufragen. Das Erstellen von Code-Objekten ist keine leistungskritische Operation, daher sollte dies kein Problem darstellen.
Abwärtskompatibilität
Die Änderung ist vollständig abwärtskompatibel.
Referenzimplementierung
Eine Referenzimplementierung finden Sie im Implementierungs-Fork.
Abgelehnte Ideen
Einen einzelnen Caret anstelle eines Bereichs verwenden
Es wurde vorgeschlagen, einen einzelnen Caret anstelle der Hervorhebung des gesamten Bereichs bei der Fehlerberichterstattung zu verwenden, um die Funktion zu vereinfachen. Wir haben uns aus folgenden Gründen gegen diesen Weg entschieden:
- Die Ableitung des Speicherorts des Carets ist anhand des aktuellen AST-Layouts nicht trivial. Dies liegt daran, dass die AST-Knoten nur die Start- und Endzeilennummern sowie die Start- und Endspalten-Offsets aufzeichnen. Da die AST-Knoten die ursprünglichen Tokens nicht beibehalten (designbedingt), ist die Ableitung des genauen Speicherorts einiger Tokens ohne zusätzliches erneutes Parsen nicht möglich. Zum Beispiel haben binäre Operatoren Knoten für die Operanden, aber der Typ des Operators ist in einer Enumeration gespeichert, sodass sein Speicherort nicht vom Knoten abgeleitet werden kann (dies ist nur ein Beispiel dafür, wie dieses Problem auftritt, und nicht das einzige).
- Die Ableitung der Bereiche aus AST-Knoten vereinfacht die Implementierung erheblich und reduziert den Wartungsaufwand und die Fehleranfälligkeit. Dies liegt daran, dass die Verwendung der Bereiche immer generisch für jeden AST-Knoten möglich ist, während jede andere benutzerdefinierte Information unterschiedlich von verschiedenen Knotentypen extrahiert werden müsste. Angesichts der Fehleranfälligkeit der manuellen Standortbestimmung, als dies noch ein manueller Prozess bei der Erzeugung des AST war, glauben wir, dass eine generische Lösung eine sehr wichtige Eigenschaft ist, die verfolgt werden sollte.
- Das Speichern von Informationen zur Hervorhebung eines einzelnen Carets wäre für Tools wie Coverage-Tools und Profiler sowie für Tools wie IPython und IDEs, die diese neue Funktion nutzen möchten, sehr einschränkend. Wie diese Nachricht des Autors von "friendly-traceback" erwähnt, liegt der Grund darin, dass es diesen Tools ohne den vollständigen Bereich (einschließlich Endzeilen) sehr schwer fallen würde, den relevanten Quellcode korrekt hervorzuheben. Zum Beispiel für diesen Code:
something = foo(a,b,c) if bar(a,b,c) else other(b,c,d)
Tools (wie Coverage-Reporter) möchten den gesamten Aufruf hervorheben können, der vom ausgeführten Bytecode abgedeckt wird (sagen wir
foo(a,b,c)) und nicht nur ein einzelnes Zeichen. Auch wenn es technisch möglich wäre, den Quellcode neu zu parsen und zu tokenisieren, um die Informationen zu rekonstruieren, ist dies nicht zuverlässig möglich und würde zu einer viel schlechteren Benutzererfahrung führen. - Viele Benutzer haben berichtet, dass ein einzelner Caret schwerer zu lesen ist als ein vollständiger Bereich, und dies hat die Verwendung von Bereichen zur Hervorhebung von Syntaxfehlern motiviert, was sehr gut aufgenommen wurde. Zusätzlich wurde festgestellt, dass Benutzer mit Sehproblemen Bereiche viel leichter identifizieren können als ein einzelnes Caret-Zeichen, was wir als großen Vorteil der Verwendung von Bereichen ansehen.
Ein Konfigurationsflag zum Opt-out bereitstellen
Ein Konfigurationsflag zum Opt-out des Overheads, selbst wenn Python im nicht optimierten Modus ausgeführt wird, mag wünschenswert erscheinen, kann aber Probleme beim Lesen von pyc-Dateien verursachen, die mit einer Interpreterversion erstellt wurden, die nicht mit aktiviertem Flag kompiliert wurde. Dies kann zu Abstürzen führen, die für normale Benutzer sehr schwer zu debuggen sind und verschiedene pyc-Dateien inkompatibel zueinander machen. Da diese pycs als Teil von Bibliotheken oder Anwendungen ohne den ursprünglichen Quellcode ausgeliefert werden könnten, ist es auch nicht immer möglich, die Neukompilierung solcher pycs zu erzwingen. Aus diesen Gründen haben wir uns entschieden, das -O-Flag zum Opt-out dieses Verhaltens zu verwenden.
Lazy Loading von Spalteninformationen
Eine mögliche Lösung zur Reduzierung der Speichernutzung dieser Funktion besteht darin, die Spalteninformationen nicht aus der pyc-Datei zu laden, wenn Code importiert wird. Nur wenn eine nicht abgefangene Ausnahme aufsteigt oder ein Aufruf an die C-API-Funktionen erfolgt, werden die Spalteninformationen aus der pyc-Datei geladen. Dies ähnelt der Art und Weise, wie wir nur Quellzeilen lesen, um sie im Traceback anzuzeigen, wenn eine Ausnahme aufsteigt. Dies würde zwar den Speicherverbrauch senken, führt aber auch zu einer wesentlich komplexeren Implementierung, die Änderungen an der Import-Maschinerie erfordert, um einen Teil des Code-Objekts selektiv zu ignorieren. Wir betrachten dies als einen interessanten Weg zur Erkundung, aber letztendlich denken wir, dass es außerhalb des Rahmens dieser speziellen PEP liegt. Es bedeutet auch, dass Spalteninformationen nicht verfügbar sind, wenn der Benutzer keine pyc-Dateien verwendet oder für dynamisch zur Laufzeit erstellte Code-Objekte.
Kompression implementieren
Obwohl es möglich wäre, eine Form der Kompression über die pyc-Dateien und die neuen Daten in Code-Objekten zu implementieren, glauben wir, dass dies aufgrund seiner größeren Auswirkungen (im Fall von pyc-Dateien) und der Tatsache, dass wir erwarten, dass Spalten-Offsets aufgrund des Fehlens von Mustern darin nicht gut komprimieren (im Fall der neuen Daten in Code-Objekten), außerhalb des Rahmens dieses Vorschlags liegt.
Danksagungen
Danke an Carl Friedrich Bolz-Tereick für die Bereitstellung eines anfänglichen Prototyps dieser Idee für den Pypy-Interpreter und für die hilfreiche Diskussion.
Urheberrecht
Dieses Dokument wird in die Public Domain oder unter die CC0-1.0-Universal-Lizenz gestellt, je nachdem, welche Lizenz permissiver ist.
Quelle: https://github.com/python/peps/blob/main/peps/pep-0657.rst
Zuletzt geändert: 2025-02-01 08:59:27 GMT