You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Written by Claude Fable 5.1 via Claude Code for the pytest maintainers; I prompted it, it did the work, I read it.
Summary
ExceptionInfo and the Repr* objects in _pytest/_code/code.py date back to py.code (2004 era). #3399 asked for a strategy to refactor them and never got one. This issue is that strategy: split the current objects into three layers, capture, a frozen data model, and renderers, so that the same recorded exception can be matched in pytest.raises, shipped through xdist, and rendered by the terminal, junitxml, pytest-html or a JSON reporter without each of them re-deriving structure from pre-rendered text. rich is a hard dependency by the time this lands, so the renderers are rich renderables; the model stays plain data.
Supersedes #3399. Directly resolves #14720, #12175, the remaining #9159 fallback, and gives #10224 a place to land. Migrating the rest of the terminal reporter to rich is a separate effort and not covered here.
What is wrong today
Verified against main (9.2.0.dev):
Style is baked in at build time.ExceptionInfo.getrepr() takes style, showlocals, abspath, truncate_*, chain and emits ReprEntry.lines as already-rendered text with > and E markers. ReprEntry._write_entry_lines then re-parses those markers to syntax-highlight. --tb=line/--tb=no drop reprfileloc entirely, which is --tb affects longrepr construction, so plugins cannot suppress traceback display without losing traceback data #14720. nodes.py:444 has carried # XXX should excinfo.getrepr record all data and toterminal() process it? for years; the answer is yes.
Serialization is hand-written and lossy.reports.py:574-735 dispatches on hasattr(longrepr, "reprtraceback"), rebuilds dataclasses by hand, and turns any other TerminalRepr (ReprFailDoctest, FixtureLookupErrorRepr, CollectErrorRepr) into a plain string. xdist cannot ship pytest_internalerror data at all (dsession.py:226). The wire format carries no function name, module, column, raw source or structured locals.
longrepr is a four-way union (str | tuple[str,int,str] | TerminalRepr | ExceptionInfo | None). The skip 3-tuple is load-bearing in terminal.py, junitxml.py, runner.py and xdist tests. Duck-typed hasattr/getattr probes for reprcrash/toterminal exist in five places. None of the Repr* classes are exported or documented, yet reprcrash.message, reprtraceback.reprentries[-1].reprfileloc (pastebin) and chain/sections are read by plugins.
Live-object coupling.Traceback/TracebackEntry wrap real frames; for_later() loses the assertion strip text (ExceptionInfo.for_later() wont add assertion strip text #12175); _getreprcrash recomputes from live frames; statement ranges come from pytest's own AST search (getstatementrange_ast) while co_positions has carried line, end line and columns since 3.11.
Raisers duplicate matching logic.ExceptionInfo.match vs AbstractRaises._check_match, errisinstance+group_contains vs RaisesExc.matches (raises.py:358, TODO at :477), and fail_reason is a string that gets stitched together.
What everyone else converged on
The agent surveyed rich, structlog, the stdlib traceback module, Sentry's event protocol, OpenTelemetry, stackprinter, tblib and the exceptiongroup backport. The tools that model exceptions as data agree on the shape:
exception node
frame fields
locals
source
chain
groups
positions
serializable
stdlib TracebackException
type module/qualname, str, notes, SyntaxError fields, exceptions
filename, abs_path, function, module, lineno, colno, context_line, pre/post_context, in_app, vars
serializer-limited, per-frame filter
inline 5+5 lines
flat list, oldest first, ids
ids + source="exceptions[i]"
lineno/colno
JSON with truncation metadata
OpenTelemetry
type, message, stacktrace
none
none
inline in string
none
none
none
string
Common ground: capture is eager and produces plain data (strings for values, ints for positions); style, suppression, frame elision and colour are renderer options; groups are children of a node, chaining is a link between nodes. Where they differ (tree vs flat-with-ids, inline vs lazy source) the choice is driven by whether the data crosses a process boundary. pytest's does.
Why not rich's Trace as the model
rich is the right rendering substrate and the wrong wire model. Its Trace/Stack/Frame dataclasses are undocumented (no __all__, not in the reference docs), Frame.line is never filled, locals are rich.pretty.Node trees rather than strings, __notes__ of the outer exception are copied onto every chained stack, there is no hidden-frame flag, no module or qualname per frame, no source capture (linecache at render time) and no serialization API. structlog's port of the same model to JSON shows the shape transfers; pytest's model below is a superset of it, so a lossless adapter to rich.traceback.Trace is cheap and pytest keeps control of what crosses the xdist boundary.
Proposed design
Three layers. Every name below is a placeholder and open to bikeshedding; the shape is the proposal, not the spelling.
1. Capture: ExceptionInfo stays the live handle. It keeps type/value/tb, traceback navigation, match, group_contains, and is what pytest.raises returns. It gains one method, record(...), that produces layer 2. The striptext for assertions is computed at record time, which removes the for_later() gap (#12175).
2. Model: frozen dataclasses, plain data, versioned, no rich import.
@dataclass(frozen=True)classLocation: # replaces ReprFileLocation and the skip 3-tuplepath: strlineno: intmessage: str@dataclass(frozen=True)classFrameRecord:
path: str# as recorded (relative or absolute is a render choice; both kept)abspath: str|Nonelineno: intend_lineno: int|None# from co_positions; None only on 3.10colno: int|Noneend_colno: int|Nonefunction: str# co_qualname where availablemodule: str|Nonestatement: tuple[str, ...] # source lines of the failing statement, captured eagerlystatement_lineno: int# first line of `statement`locals: Mapping[str, str] |None# saferepr'd, only when requestedargs: tuple[tuple[str, str], ...] |Nonehidden: bool# __tracebackhide__ / internal; rendering decides whether to show@dataclass(frozen=True)classExceptionRecord:
type_name: strtype_module: strmessage: str# exconly without the type prefixnotes: tuple[str, ...]
syntax_error: SyntaxErrorDetails|Noneframes: tuple[FrameRecord, ...]
cause: ExceptionRecord|Nonecontext: ExceptionRecord|Nonesuppress_context: boolchildren: tuple[ExceptionRecord, ...] # exception groupssections: tuple[tuple[str, str, str], ...]
recursion_index: int|None@propertydefcrash(self) ->Location|None: ... # last non-hidden frame + messagedefwalk(self) ->Iterator[ExceptionRecord]: ...
defto_dict(self) ->dict: ... # generated, schema-versioned@classmethoddeffrom_dict(cls, data) ->ExceptionRecord: ...
defto_rich_trace(self) ->rich.traceback.Trace: ... # lossless
Everything is JSON-able and picklable. The full traceback is always recorded; --tb no longer decides what is stored (#14720). Source lines are captured eagerly because the current wire format already inlines rendered text, and because xdist workers and file edits after the fact mean linecache cannot be trusted on the reading side.
3. Renderers are rich renderables over the model.
PytestTraceback(record, style, showlocals, abspath, chain, truncate_locals, truncate_args, funcargs) implements __rich_console__ and reproduces today's long/short/line/no/native/value layouts, including the per-entry style override that Collector uses. Source goes through rich.syntax.Syntax, locals through rich.pretty, the >/E markers and separators become Text with styles instead of re-parsed strings.
--tb=rich is a new style: rich.traceback.Traceback(trace=record.to_rich_trace(), ...), rich's own layout for people who already use it.
str(record) renders the default style to plain text; record.toterminal(tw) stays as a shim for one deprecation cycle.
Console.export_html()/export_svg() give pytest-html and docs a faithful rendering without a second renderer.
junitxml, pastebin and the short summary read record.crash, record.frames, record.message instead of probing attributes. JSON reporters get to_dict().
Exception groups render in pytest style from children; the TracebackException.format() fallback goes away.
Positions come from co_positions, not from the AST statement search: statement is the lineno..end_lineno range of the failing instruction, and PEP 657 carets (PEP-657 enhanced error locations in pytest tracebacks #10224) are a renderer feature over colno/end_colno. On 3.10 the record carries line numbers only and the renderer degrades to today's single-line behaviour.
Raisers.AbstractRaises.matches() produces a structured failure instead of a string, rendered through the same path (a rich.tree.Tree for RaisesGroup mismatches is the obvious win); ExceptionInfo.match and _check_match share one implementation. pytest_exception_interact keeps the live CallInfo.excinfo; the report carries the record. pytest_internalerror receives the record, so xdist can forward it.
Compatibility
TestReport.longrepr becomes ExceptionRecord | FailureRecord | None. For one cycle the record exposes reprcrash (returns crash), reprtraceback.reprentries[-1].reprfileloc, chain and sections as deprecated properties, and Location unpacks as a 3-tuple.
JSON: _report_to_json emits the record's to_dict(); _report_kwargs_from_json accepts both shapes for one cycle. xdist already requires matching pytest versions on both ends.
ReprFailDoctest, FixtureLookupErrorRepr, CollectErrorRepr migrate to a small FailureRecord protocol (crash, sections, to_dict, __rich_console__) so they stop degrading to strings over xdist.
ExceptionInfo.getrepr() keeps working by recording then rendering into the old-shaped objects, deprecated.
Terminal output is purpose-matching, not byte-identical: each --tb style keeps conveying the same information with the same visual roles (source context, > flow marker, E failure lines, location line, locals, chain descriptions), and tests that pin exact bytes are updated where the difference is cosmetic. A deliberate restyle of the default look is still a separate issue.
Increments
Each step ships on its own and keeps the suite green.
Add the model and ExceptionInfo.record() alongside the existing code, with to_dict/from_dict round-trip tests and to_rich_trace(). No consumer changes.
Add the PytestTraceback renderable and run it against testing/code/test_excinfo.py and testing/test_terminal.py, adjusting assertions that pin cosmetic bytes. Ship --tb=rich.
Written by Claude Fable 5.1 via Claude Code for the pytest maintainers; I prompted it, it did the work, I read it.
Summary
ExceptionInfoand theRepr*objects in_pytest/_code/code.pydate back topy.code(2004 era). #3399 asked for a strategy to refactor them and never got one. This issue is that strategy: split the current objects into three layers, capture, a frozen data model, and renderers, so that the same recorded exception can be matched inpytest.raises, shipped through xdist, and rendered by the terminal, junitxml, pytest-html or a JSON reporter without each of them re-deriving structure from pre-rendered text. rich is a hard dependency by the time this lands, so the renderers are rich renderables; the model stays plain data.Supersedes #3399. Directly resolves #14720, #12175, the remaining #9159 fallback, and gives #10224 a place to land. Migrating the rest of the terminal reporter to rich is a separate effort and not covered here.
What is wrong today
Verified against
main(9.2.0.dev):ExceptionInfo.getrepr()takesstyle,showlocals,abspath,truncate_*,chainand emitsReprEntry.linesas already-rendered text with>andEmarkers.ReprEntry._write_entry_linesthen re-parses those markers to syntax-highlight.--tb=line/--tb=nodropreprfilelocentirely, which is--tbaffectslongreprconstruction, so plugins cannot suppress traceback display without losing traceback data #14720.nodes.py:444has carried# XXX should excinfo.getrepr record all data and toterminal() process it?for years; the answer is yes.reports.py:574-735dispatches onhasattr(longrepr, "reprtraceback"), rebuilds dataclasses by hand, and turns any otherTerminalRepr(ReprFailDoctest,FixtureLookupErrorRepr,CollectErrorRepr) into a plain string. xdist cannot shippytest_internalerrordata at all (dsession.py:226). The wire format carries no function name, module, column, raw source or structured locals.longrepris a four-way union (str | tuple[str,int,str] | TerminalRepr | ExceptionInfo | None). The skip 3-tuple is load-bearing in terminal.py, junitxml.py, runner.py and xdist tests. Duck-typedhasattr/getattrprobes forreprcrash/toterminalexist in five places. None of theRepr*classes are exported or documented, yetreprcrash.message,reprtraceback.reprentries[-1].reprfileloc(pastebin) andchain/sectionsare read by plugins.code.py:1213, the Pytest does not show inner exceptions in PEP-654ExceptionGroups #9159 workaround).Traceback/TracebackEntrywrap real frames;for_later()loses the assertion strip text (ExceptionInfo.for_later()wont add assertion strip text #12175);_getreprcrashrecomputes from live frames; statement ranges come from pytest's own AST search (getstatementrange_ast) whileco_positionshas carried line, end line and columns since 3.11.ExceptionInfo.matchvsAbstractRaises._check_match,errisinstance+group_containsvsRaisesExc.matches(raises.py:358, TODO at:477), andfail_reasonis a string that gets stitched together.What everyone else converged on
The agent surveyed rich, structlog, the stdlib
tracebackmodule, Sentry's event protocol, OpenTelemetry, stackprinter, tblib and the exceptiongroup backport. The tools that model exceptions as data agree on the shape:TracebackExceptionexceptions{name: repr}opt-inexceptionslist_codeslot breaks 3.13+)Trace/Stack/FrameNodetrees, cappedstackswithis_causeTraceper memberExceptionDictTransformer{name: str}capped, hide dunder, path suppressis_causeasdict, 50-frame cutsource="exceptions[i]"Common ground: capture is eager and produces plain data (strings for values, ints for positions); style, suppression, frame elision and colour are renderer options; groups are children of a node, chaining is a link between nodes. Where they differ (tree vs flat-with-ids, inline vs lazy source) the choice is driven by whether the data crosses a process boundary. pytest's does.
Why not rich's
Traceas the modelrich is the right rendering substrate and the wrong wire model. Its
Trace/Stack/Framedataclasses are undocumented (no__all__, not in the reference docs),Frame.lineis never filled, locals arerich.pretty.Nodetrees rather than strings,__notes__of the outer exception are copied onto every chained stack, there is no hidden-frame flag, no module or qualname per frame, no source capture (linecache at render time) and no serialization API. structlog's port of the same model to JSON shows the shape transfers; pytest's model below is a superset of it, so a lossless adapter torich.traceback.Traceis cheap and pytest keeps control of what crosses the xdist boundary.Proposed design
Three layers. Every name below is a placeholder and open to bikeshedding; the shape is the proposal, not the spelling.
1. Capture:
ExceptionInfostays the live handle. It keepstype/value/tb,tracebacknavigation,match,group_contains, and is whatpytest.raisesreturns. It gains one method,record(...), that produces layer 2. Thestriptextfor assertions is computed at record time, which removes thefor_later()gap (#12175).2. Model: frozen dataclasses, plain data, versioned, no rich import.
Everything is JSON-able and picklable. The full traceback is always recorded;
--tbno longer decides what is stored (#14720). Source lines are captured eagerly because the current wire format already inlines rendered text, and because xdist workers and file edits after the fact mean linecache cannot be trusted on the reading side.3. Renderers are rich renderables over the model.
PytestTraceback(record, style, showlocals, abspath, chain, truncate_locals, truncate_args, funcargs)implements__rich_console__and reproduces today'slong/short/line/no/native/valuelayouts, including the per-entry style override thatCollectoruses. Source goes throughrich.syntax.Syntax, locals throughrich.pretty, the>/Emarkers and separators becomeTextwith styles instead of re-parsed strings.--tb=richis a new style:rich.traceback.Traceback(trace=record.to_rich_trace(), ...), rich's own layout for people who already use it.str(record)renders the default style to plain text;record.toterminal(tw)stays as a shim for one deprecation cycle.Console.export_html()/export_svg()give pytest-html and docs a faithful rendering without a second renderer.record.crash,record.frames,record.messageinstead of probing attributes. JSON reporters getto_dict().children; theTracebackException.format()fallback goes away.co_positions, not from the AST statement search:statementis thelineno..end_linenorange of the failing instruction, and PEP 657 carets (PEP-657 enhanced error locations in pytest tracebacks #10224) are a renderer feature overcolno/end_colno. On 3.10 the record carries line numbers only and the renderer degrades to today's single-line behaviour.Raisers.
AbstractRaises.matches()produces a structured failure instead of a string, rendered through the same path (arich.tree.TreeforRaisesGroupmismatches is the obvious win);ExceptionInfo.matchand_check_matchshare one implementation.pytest_exception_interactkeeps the liveCallInfo.excinfo; the report carries the record.pytest_internalerrorreceives the record, so xdist can forward it.Compatibility
TestReport.longreprbecomesExceptionRecord | FailureRecord | None. For one cycle the record exposesreprcrash(returnscrash),reprtraceback.reprentries[-1].reprfileloc,chainandsectionsas deprecated properties, andLocationunpacks as a 3-tuple._report_to_jsonemits the record'sto_dict();_report_kwargs_from_jsonaccepts both shapes for one cycle. xdist already requires matching pytest versions on both ends.ReprFailDoctest,FixtureLookupErrorRepr,CollectErrorReprmigrate to a smallFailureRecordprotocol (crash,sections,to_dict,__rich_console__) so they stop degrading to strings over xdist.ExceptionInfo.getrepr()keeps working by recording then rendering into the old-shaped objects, deprecated.--tbstyle keeps conveying the same information with the same visual roles (source context,>flow marker,Efailure lines, location line, locals, chain descriptions), and tests that pin exact bytes are updated where the difference is cosmetic. A deliberate restyle of the default look is still a separate issue.Increments
Each step ships on its own and keeps the suite green.
ExceptionInfo.record()alongside the existing code, withto_dict/from_dictround-trip tests andto_rich_trace(). No consumer changes.PytestTracebackrenderable and run it againsttesting/code/test_excinfo.pyandtesting/test_terminal.py, adjusting assertions that pin cosmetic bytes. Ship--tb=rich.getrepr(), reports and JSON to record-then-render. Fixes--tbaffectslongreprconstruction, so plugins cannot suppress traceback display without losing traceback data #14720. xdist suite green.Location.ExceptionGroups #9159 fallback.ExceptionInfo.for_later()wont add assertion strip text #12175.getstatementrange_astand the AST cache once 3.10 is dropped (EOL October 2026).Repr*classes andgetrepr()parameters.Open points
statementcapture is unconditional or bounded (line count, byte size) for pathological frames.