stackscope: unusually detailed Python stack introspection¶
stackscope
is a library that helps you tell what your running
Python program is doing and how it got there. It can provide detailed
stack traces, similar to what you get in an exception traceback, but
without needing to throw an exception first. Compared to standard
library facilities such as traceback.extract_stack()
, it is far
more versatile. It supports async tasks, generators, threads, and
greenlets; provides information about active context managers in each
stack frame; and includes a customization interface that library
authors can use to teach it to improve the stack extraction logic for
code that touches their library. (As an example of the latter, the
stack of an async task blocked in a run_in_thread()
function could
be made to cover the code that’s running in the thread as well.)
stackscope
is loosely affiliated with the Trio async framework, and shares Trio’s
obsessive focus on usability and correctness. The context manager
analysis is especially helpful with Trio since you can use it to
understand where the nurseries are. You don’t have to use stackscope
with Trio, though; it requires only the Python standard library, 3.8
or later, and the ExceptionGroup backport
on versions below 3.11.
stackscope
is mostly intended as a building block for other
debugging and introspection tools. You can use it directly, but
there’s only rudimentary support for end-user-facing niceties such as
pretty-printed output. On the other hand, the core logic is (believed
to be) robust and flexible, exposing customization points that
third-party libraries can use to help stackscope
make better
tracebacks for their code. stackscope
ships out of the box with such
“glue” for Trio, greenback, and some of their
lower-level dependencies.
stackscope
requires Python 3.8 or later. It is fully
type-annotated and is tested with CPython (every minor version through
3.12) and PyPy, on Linux, Windows, and macOS. It will probably
work on other operating systems. Basic features will work on other
Python implementations, but the context manager decoding will be less
intelligent, and won’t work at all without a usable
gc.get_referents()
.
Quickstart¶
Call stackscope.extract()
to obtain a stackscope.Stack
describing the stack of a coroutine object, generator iterator (sync
or async), greenlet, or thread. If you want to extract part of the
stack that led to the extract()
call, then either pass a
stackscope.StackSlice
or use the convenience aliases
extract_since()
and extract_until()
.
Trio users: Try print(stackscope.extract(trio.lowlevel.current_root_task(),
recurse_child_tasks=True))
to print the entire task tree of your
Trio program.
Once you have a Stack
, you can:
Format it for human consumption:
str()
obtains a tree view as shown in the example below, or usestack.format()
to customize it orstack.format_flat()
to get an alternate format that resembles a standard Python traceback.Iterate over it (or equivalently, its
frames
attribute) to obtain a series ofstackscope.Frame
s for programmatic inspection. Each frame represents one function call. In addition to the interpreter-level frame object, it lets you access information about the active context managers in that function (Frame.contexts
).Look at its
leaf
attribute to see what’s left once you peel away all the frames. For example, this might be some atomic awaitable such as anasyncio.Future
. It will beNone
if the frames tell the whole story.Use its
as_stdlib_summary()
method to get a standard librarytraceback.StackSummary
object (with some loss of information), which can be pickled or passed to non-stackscope
-aware tools.
Example¶
This code uses a number of context managers:
from contextlib import contextmanager, ExitStack
@contextmanager
def null_context():
yield
def some_cb(*a, **kw):
pass
@contextmanager
def inner_context():
stack = ExitStack()
with stack:
stack.enter_context(null_context())
stack.callback(some_cb, 10, "hi", answer=42)
yield "inner"
@contextmanager
def outer_context():
with inner_context() as inner:
yield "outer"
def example():
with outer_context():
yield
def call_example():
yield from example()
gen = call_example()
next(gen)
You can use stackscope
to inspect the state of the partially-consumed generator
gen, showing the tree structure of all of those context managers:
$ python3 -i example.py
>>> import stackscope
>>> stack = stackscope.extract(gen)
>>> print(stack)
stackscope.Stack (most recent call last):
╠ call_example in __main__ at [...]/stackscope/example.py:28
║ └ yield from example()
╠ example in __main__ at [...]/stackscope/example.py:25
║ ├ with outer_context(): # _: _GeneratorContextManager (line 24)
║ │ ╠ outer_context in __main__ at [...]/stackscope/example.py:21
║ │ ║ ├ with inner_context() as inner: # inner: _GeneratorContextManager (line 20)
║ │ ║ │ ╠ inner_context in __main__ at [...]/stackscope/example.py:16
║ │ ║ │ ║ ├ with stack: # stack: ExitStack (line 13)
║ │ ║ │ ║ ├── stack.enter_context(null_context(...)) # stack[0]: _GeneratorContextManager
║ │ ║ │ ║ │ ╠ null_context in __main__ at [...]/stackscope/example.py:5
║ │ ║ │ ║ │ ║ └ yield
║ │ ║ │ ║ ├── stack.callback(__main__.some_cb, 10, 'hi', answer=42) # stack[1]: function
║ │ ║ │ ║ └ yield "inner"
║ │ ║ └ yield "outer"
║ └ yield
That full tree structure is exposed for programmatic inspection as well:
>>> print(stack.frames[1].contexts[0].inner_stack.frames[0].contexts[0])
inner_context(...) # inner: _GeneratorContextManager (line 20)
╠ inner_context in __main__ at /Users/oremanj/dev/stackscope/example.py:16
║ ├ with stack: # stack: ExitStack (line 13)
║ ├── stack.enter_context(null_context(...)) # stack[0]: _GeneratorContextManager
║ │ ╠ null_context in __main__ at /Users/oremanj/dev/stackscope/example.py:5
║ │ ║ └ yield
║ ├── stack.callback(__main__.some_cb, 10, 'hi', answer=42) # stack[1]: function
║ └ yield "inner"
Of course, if you just want a “normal” stack trace without the added information, you can get that too:
>>> print("".join(stack.format_flat()))
stackscope.Stack (most recent call last):
File "/Users/oremanj/dev/stackscope/example.py", line 28, in call_example
yield from example()
File "/Users/oremanj/dev/stackscope/example.py", line 25, in example
yield
Detailed documentation¶
Extracting and inspecting stacks¶
Extracting a stack¶
The main entry point for stackscope is the stackscope.extract()
function.
It comes in several variants:
extract()
accepts a “stack item”, which is anything stackscope knows how to turn into a series of frame objects. This might be aStackSlice
, coroutine object, generator iterator, thread, greenlet,trio.lowlevel.Task
, or anything else for which a library author (maybe you) have added support through theunwrap_stackitem()
customization hook.extract_since()
andextract_until()
obtain a stack for the callees or callers (respectively) of a currently-executing frame. Useextract_since(None)
to get the full stack of the thread that made theextract_since()
call, likeinspect.stack()
. These are aliases for invokingextract()
with aStackSlice
.extract_outermost()
returns the outermostFrame
thatextract()
would return, without computing the whole stack.
- stackscope.extract(stackitem: StackItem, *, with_contexts: bool = True, recurse_child_tasks: bool = False) Stack ¶
Extract a
Stack
from stackitem.stackitem may be anything that has a stack associated with it. If you want to dump the caller’s stack or the stack starting or ending with some frame, then either pass a
StackSlice
or useextract_since()
orextract_until()
, which are shortcuts for passing aStackSlice
toextract()
. Besides that, stackscope also ships with support for threads, greenlets, generator iterators (sync and async), coroutine objects, and a few more obscure things that might be encountered while traversing those; and libraries may add support for more using theunwrap_stackitem
hook.If with_contexts is True (the default), then each returned
Frame
will have acontexts
attribute specifying the context managers that are currently active in that frame. If you don’t care about this information, then specifyingwith_contexts=False
will substantially simplify the stack extraction process.Some context managers might logically contain other tasks that each have their own
Stack
:trio.Nursery
,asyncio.TaskGroup
, etc. These will be listed asContext.children
in the returnedFrame.contexts
for these context managers. If recurse_child_tasks is False (the default), then these “child tasks” will be rendered as stubStack
objects with only aroot
(the child task object) but noframes
. If recurse_child_tasks is True, then the child stacks will be fully populated, including grandchildren and so on.extract()
tries not to throw exceptions; any exception should be reported as a bug. Errors encountered during stack extraction are reported in theerror
attribute of the returned object. If multiple errors are encountered, they will be wrapped in anExceptionGroup
.
- stackscope.extract_since(outer_frame: types.FrameType | None, *, with_contexts: bool = True, recurse_child_tasks: bool = False) Stack ¶
Return a
Stack
reflecting the currently-executing frames that were directly or indirectly called by outer_frame, including outer_frame itself. Equivalent toextract(StackSlice(outer=outer_frame))
with more type checking.If outer_frame is a frame on the current thread’s stack, the result will start with outer_frame and end with the immediate caller of
extract_since()
.If outer_frame is a frame on some other thread’s stack, and it remains there throughout the traceback extraction process, the resulting stack will start with outer_frame and end with some frame that was recently the innermost frame on that thread’s stack.
Note
If other_frame is not continuously on the same other thread’s stack during the extraction process, you’re likely to get a one-frame stack, maybe with an
error
. It’s not possible to prevent thread switching from within Python code, so we can’t do better than this without a C extension.If outer_frame is None, the result contains all frames on the current thread’s stack, starting with the outermost and ending with the immediate caller of
extract_since()
.In any other case – if outer_frame belongs to a suspended coroutine, generator, greenlet, or if it starts or stops running on another thread while
extract_since()
is executing – you will get aStack
containing information only on outer_frame itself; depending on the situation, itserror
member might describe the reason more information can’t be provided.
- stackscope.extract_until(inner_frame: types.FrameType, *, limit: int | types.FrameType | None = None, with_contexts: bool = True, recurse_child_tasks: bool = False) Stack ¶
Return a
Stack
reflecting the currently executing frames that are direct or indirect callers of inner_frame, including inner_frame itself.If inner_frame belongs to a suspended coroutine or generator, or if it otherwise is not linked to other frames via its
f_back
attribute, then the returned traceback will contain only inner_frame and not any of its callers.If a limit is specified, only some of the callers of inner_frame will be returned. If the limit is a frame, then it must be an indirect caller of inner_frame and it will be the first frame in the result; any of its callers will be excluded. Otherwise, the limit must be a positive integer, and the traceback will start with the limit’th parent of inner_frame.
Equivalent to
extract(StackSlice(outer=outer_frame, limit=limit))
orextract(StackSlice(outer=outer_frame, inner=limit))
depending on the type of limit, except thatextract_until()
does more checking of its inputs (an exception will be raised if limit has an invalid type or is a frame that isn’t an indirect caller of inner_frame).
- stackscope.extract_outermost(stackitem: StackItem, *, with_contexts: bool = True, recurse_child_tasks: bool = False) Frame ¶
Extract the outermost
Frame
from stackitem.extract_outermost()
produces the same result as callingextract()
and returning the firstFrame
of the returned stack, but might be faster since it can stop once it’s extracted one frame. If the result has noFrame
s, an exception will be thrown.
- class stackscope.StackSlice(outer: types.FrameType | None = None, inner: types.FrameType | None = None, limit: int | None = None)¶
Identifies a contiguous series of frames that we want to analyze.
StackSlice
has no logic on its own; its only use is as something to pass toextract()
or return from anunwrap_stackitem()
hook.This can be used in three different ways:
If
inner
is not None, then theStackSlice
logically contains currently-executing frames that are direct or indirect callers ofinner
, ending withinner
itself. Iteration begins withinner
and proceeds outward viaframe.f_back
links until the frameouter
is reached orlimit
frames have been extracted. If neither of those is specified, then the stack slice starts with the outermost frame of the thread on whichinner
is running.If
inner
belongs to a suspended coroutine or generator, or if it otherwise is not linked to other frames via itsf_back
attribute, then the returned traceback will contain onlyinner
and not any of its callers.If
inner
is None butouter
is not, theStackSlice
containsouter
followed by its currently-executing direct and indirect callees, up tolimit
frames total.If
outer
is executing on the current thread, then theStackSlice
ends with the frame that calledstackscope.extract()
(unless it is cut off before that by reaching itslimit
). If it is executing on some other thread, and remains so throughout the stack extraction process, then theStackSlice
ends with the innermost frame on that thread. In any other case – ifouter
belongs to a suspended coroutine, generator, greenlet, or if it starts or stops running on another thread whilestackscope.extract()
is executing – the returned stack will contain information only onouter
itself.If
inner
andouter
are both None, theStackSlice
contains the entirety of the current thread’s stack, ending with the frame that made the call tostackscope.extract()
.
- outer: types.FrameType | None = None¶
The outermost frame to extract. If unspecified, start from
inner
(or the caller ofstackscope.extract()
ifinner
is None) and iterate outward until top-of-stack is reached orlimit
frames have been extracted.
- class stackscope.StackItem¶
Placeholder used in type hints to mean “anything you can pass to
extract()
.”
- exception stackscope.InspectionWarning¶
Warning raised if something goes awry during frame inspection.
Working with stacks¶
This section documents the Stack
object that extract()
returns, as well as the Frame
and Context
objects that it refers
to. All of these are dataclasses
. Their primary purpose is to
organize the data returned by extract()
.
- class stackscope.Stack(root: object, frames: Sequence[Frame], leaf: object = None, error: Exception | None = None)¶
Representation of a generalized call stack.
In addition to the attributes described below, you can treat a
Stack
like an iterable over itsframes
, and itslen()
is the number of frames it contains.- frames: Sequence[Frame]¶
The series of frames (individual calls) in the call stack, from outermost/oldest to innermost/newest.
- root: object¶
The object that was originally passed to
extract()
to produce this stack trace, orNone
if the trace was created from aStackSlice
(which doesn’t carry any information beyond the frames).
- leaf: object = None¶
An object that provides additional context on what this call stack is doing, after you peel away all the frames.
If this callstack comes from a generator that is yielding from an iterator which is not itself a generator, or comes from an async function that is awaiting an awaitable which is not itself a coroutine, then leaf will be that iterator or awaitable.
Library glue may provide additional semantics for leaf; for example, the call stack of an async task that is waiting on an event might set leaf to that event.
If there is no better option, leaf will be None.
- error: Exception | None = None¶
The error encountered walking the stack, if any. (
stackscope
does its best to not actually raise exceptions out ofextract()
.)
- format(*, ascii_only: bool = False, show_contexts: bool = True, show_hidden_frames: bool = False) List[str] ¶
Return a list of newline-terminated strings describing this object, which may be printed for human consumption.
str(obj)
is equivalent to"".join(obj.format())
.- Parameters:
ascii_only – Use only ASCII characters in the output. By default, Unicode line-drawing characters are used.
show_contexts – Include information about context managers in the output. This is the default; pass False for a shorter stack trace that only includes frames in the main series.
show_hidden_frames – Include frames in the output even if they are marked as hidden. By default, hidden frames will be suppressed. See
Frame.hide
for more details.
- format_flat(*, show_contexts: bool = False) List[str] ¶
Return a list of newline-terminated strings providing a flattened representation of this stack.
This will be formatted similarly to a standard Python traceback, such as might be produced if an exception were raised at the point where the stack was extracted. Context manager information is not included by default, but can be requested using the show_contexts parameter.
- as_stdlib_summary(*, show_contexts: bool = False, show_hidden_frames: bool = False, capture_locals: bool = False) StackSummary ¶
Return a representation of this stack as a standard
traceback.StackSummary
. Unlike theStack
object, aStackSummary
can be pickled and will not keep frames alive, at the expense of some loss of information.If show_contexts is True, then additional frame summaries will be emitted describing the context managers active in each frame. See the documentation of
Frame.as_stdlib_summary_with_contexts()
for details.By default, hidden frames (
Frame.hide
) are not included in the output. You can use the show_hidden_frames parameter to override this.capture_locals is passed through to the
Frame.as_stdlib_summary()
calls for each stack frame; see that method’s documentation for details on its semantics.
- class stackscope.Frame(pyframe: types.FrameType, lineno: int = -1, origin: StackItem | None = None, contexts: Sequence[Context] = (), hide: bool = False, hide_line: bool = False)¶
Representation of one call frame within a generalized call stack.
- pyframe: types.FrameType¶
The Python frame object that this frame describes.
- lineno: int = -1¶
A line number in
pyframe
.This is the currently executing line number, or the line number at which it will resume execution if currently suspended by a
yield
statement or greenlet switch, as captured when theFrame
object was constructed.
- origin: StackItem | None = None¶
The innermost weak-referenceable thing that we looked inside to find this frame, or None. Frames themselves are not weak-referenceable, but
extract_outermost(frame.origin).pyframe
will recover the originalframe.pyframe
. For example, when traversing an async call stack,origin
might be a coroutine object or generator iterator.This is exposed for use in async debuggers, which might want a way to get ahold of a previously-reported frame if it’s still running, without keeping it pinned in memory if it’s finished.
- contexts: Sequence[Context] = ()¶
The series of contexts (
with
orasync with
blocks) that are active in this frame, from outermost to innermost. A context is considered “active” for this purpose from the point where its manager’s__enter__
or__aenter__
method returns until the point where its manager’s__exit__
or__aexit__
method returns.
- hide: bool = False¶
If true, this frame relates to library internals that are likely to be more distracting than they are useful to see in a traceback. Analogous to the
__tracebackhide__
variable supported by pytest. Hidden frames are suppressed by default when printing stacks, but this can be controlled using the show_hidden_frames argument toformat()
.
- hide_line: bool = False¶
Limited version of
hide
which by default suppresses display of the executing line, but not of the function information or context managers associated with the frame. As withhide
, you can force the hidden information to be displayed by specifying the show_hidden_frames argument toformat()
.
- property filename: str¶
The filename of the Python file from which the code executing in this frame was imported.
- property clsname: str | None¶
The name of the class that contains the function executing in this frame, or None if we couldn’t determine one.
This is determined heuristically, based on the executing function having a first argument named
self
orcls
, so it can be fooled.
- property modname: str | None¶
The name of the module that contains the function executing in this frame, or None if we couldn’t determine one.
This is looked up using the
__name__
attribute of the frame’s globals namespace. It can be fooled, but usually won’t be. Another option, which is possibly more reliable but definitely much slower, would be to iterate throughsys.modules
looking for a module whose__file__
matches this frame’sfilename
.
- property linetext: str¶
The text of the line of source code that this stack entry describes. The result has leading and trailing whitespace stripped, and does not end in a newline.
- format(*, ascii_only: bool = False, show_contexts: bool = True, show_hidden_frames: bool = False) List[str] ¶
Return a list of newline-terminated strings describing this object, which may be printed for human consumption.
str(obj)
is equivalent to"".join(obj.format())
.- Parameters:
ascii_only – Use only ASCII characters in the output. By default, Unicode line-drawing characters are used.
show_contexts – Include information about context managers in the output. This is the default; pass False for a shorter stack trace that only includes frames in the main series.
show_hidden_frames – Include frames in the output even if they are marked as hidden. By default, hidden frames will be suppressed. See
Frame.hide
for more details.
- as_stdlib_summary(*, capture_locals: bool = False) FrameSummary ¶
Return a representation of this frame entry as a standard
traceback.FrameSummary
object. Unlike theFrame
object, aFrameSummary
can be pickled and will not keep frames alive, at the expense of some loss of information.If capture_locals is True, then the returned
FrameSummary
will contain the stringified object representations of local variables in the frame, just like passingcapture_locals=True
totraceback.StackSummary.extract()
.
- for ... in as_stdlib_summary_with_contexts(*, show_hidden_frames: bool = False, capture_locals: bool = False) Iterator[FrameSummary] ¶
Return a representation of this frame and its context managers as a series of standard
traceback.FrameSummary
objects.The last yielded
FrameSummary
matches whatas_stdlib_summary()
would return. Before that, one or moreFrameSummary
objects will be yielded for each of the activecontexts
in this frame. Each context will get oneFrameSummary
introducing it (pointing to the start of thewith
orasync with
block), followed by zero or more frames containing any relevant substructure, such as elements in anExitStack
or nested context managers within a@contextmanager
function. The order ofFrameSummary
objects is intended to hew as closely as possible to the (reverse) path that an exception would take if it were to propagate up the call stack. That is, the result ofas_stdlib_summary_with_contexts()
should ideally look pretty similar to what you would see when printing out a traceback after an exception.A
FrameSummary
that introduces a context will append some additional information (the type of the context manager and the name that its result was assigned to) to the function name in the returned object, in parentheses after a space. This results in reasonable output fromtraceback.StackSummary.format()
.By default, hidden frames (
Frame.hide
) encountered during context manager traversal are not included in the output. You can use the show_hidden_frames parameter to override this. The frame on which you calledas_stdlib_summary_with_contexts()
will be included unconditionally.If capture_locals is True, then the local
repr
s will be included in eachFrameSummary
, as withas_stdlib_summary()
. Frame summaries that introduce a context will include the stringified context manager object as a fictitious local called"<context manager>"
.
- class stackscope.Context(obj: object, is_async: bool, is_exiting: bool = False, varname: str | None = None, start_line: int | None = None, description: str | None = None, inner_stack: Stack | None = None, children: Sequence[Context | Stack] = (), hide: bool = False)¶
Information about a context manager active within a frame.
- obj: object¶
The object that best describes what this context is doing. By default, this is the context manager object (the thing with the
__enter__
/__aenter__
and__exit__
/__aexit__
methods), but library glue may override it to provide something more helpful. For example, anasync with trio.open_nursery():
block will put thetrio.Nursery
object here instead of the context manager that wraps it.
- is_exiting: bool = False¶
True if this context manager is currently exiting, i.e., the next thing in the traceback is a call to its
__exit__
or__aexit__
.
- varname: str | None = None¶
The name that the result of the context manager was assigned to. In
with foo() as bar:
, this is the string"bar"
. This may be an expression representing any valid assignment target, not just a simple identifier, although a simple identifier is by far the most common case. If the context manager result was not assigned anywhere, or if its assignment target was too complex for us to reconstruct, name will be None.
- start_line: int | None = None¶
The line number on which the
with
orasync with
block started, orNone
if we couldn’t determine it. (In order to determine the corresponding filename, you need to know whichFrame
thisContext
is associated with.)
- description: str | None = None¶
A description of the context manager suitable for human-readable output. By default this is None, meaning we don’t know how to do better than
repr(obj)
, but library glue may augment it in some cases, such as to provide the arguments that were passed to a@contextmanager
function.
- inner_stack: Stack | None = None¶
The call stack associated with the implementation of this context manager, if applicable. For a
@contextmanager
function, this will typically contain a single frame, though it might be more if the function usesyield from
. In most other cases there are no associated frames so stack will be None.
- children: Sequence[Context | Stack] = ()¶
The other context managers or child task stacks that are logically nested inside this one, if applicable. For example, an
ExitStack
will have one entry here per thing that was pushed on the stack, and atrio.Nursery
will have one entry per child task running in the nursery.
- hide: bool = False¶
If true, this context manager relates to library internals that are likely to be more distracting than they are useful to see in a traceback. Analogous to the
__tracebackhide__
variable supported by pytest. Hidden context managers are suppressed by default when printing stacks, but this can be controlled using the show_hidden_frames argument toformat()
.
- format(*, ascii_only: bool = False, show_contexts: bool = True, show_hidden_frames: bool = False) List[str] ¶
Return a list of newline-terminated strings describing this object, which may be printed for human consumption.
str(obj)
is equivalent to"".join(obj.format())
.- Parameters:
ascii_only – Use only ASCII characters in the output. By default, Unicode line-drawing characters are used.
show_contexts – Include information about context managers in the output. This is the default; pass False for a shorter stack trace that only includes frames in the main series.
show_hidden_frames – Include frames in the output even if they are marked as hidden. By default, hidden frames will be suppressed. See
Frame.hide
for more details.
Customizing stackscope for your library¶
stackscope
contains several customization hooks that allow it to be
adapted to provide good stack traces for context managers, awaitables,
and control-flow primitives that it doesn’t natively know anything about.
These are implemented either as @functools.singledispatch
functions (which dispatch to a different implementation depending on the type of
their first argument) or as @stackscope.lowlevel.code_dispatch
functions (which dispatch to a different
implementation depending on the identity of the Python code object associated
with their first argument). Implementations of these hooks that support a
particular library are referred to as stackscope “glue” for that library.
If you’re working with a library that could be better-supported by stackscope, you have two options for implementing that support:
If you maintain the library that the customizations are intended to support, then define a function named
_stackscope_install_glue_
at top level in any of your library’s module(s), which takes no arguments and returns None. The body of the function should register customization hooks appropriate to your library, using the stackscope APIs described in the rest of this section. As long as you only writeimport stackscope
inside the body of the glue installation function, this won’t require that users of your library install stackscope, but they will benefit from your glue if they do.If you’re contributing glue for a library you don’t maintain, you can put the glue in stackscope instead. We have glue for several modules and a system that avoids registering it unless the module has been imported. See
stackscope/_glue.py
, and feel free to submit a PR.
If the same module has glue implemented using both of these methods, then the glue provided by the module will be used; the glue shipped with stackscope is ignored. This allows for a module’s glue to start out being shipped with stackscope and later “graduate” to being maintained upstream.
Overview of customization hooks¶
In order to understand the available customization hooks, it’s helpful to know how stackscope’s stack extraction works internally. There are separate systems for frames and for context managers, and they can interact with each other recursively.
Frames¶
There are two hooks that are relevant in determining the frames in the
returned stack: unwrap_stackitem()
and elaborate_frame()
.
unwrap_stackitem()
receives a “stack item”, which may have been
passed to stackscope.extract()
or returned from an
elaborate_frame()
or unwrap_stackitem()
hook. It is
dispatched based on the Python type of the stack item. Abstractly
speaking, a stack item should be something that logically has Python
frame objects associated with it, and the job of
unwrap_stackitem()
is to turn it into something that is closer
to those frame objects. unwrap_stackitem()
may return a single
stack item, a sequence thereof, or None if it can’t do any unwrapping.
Each returned stack item is recursively unwrapped in the same way until
no further unwrapping can be done. The resulting frame objects become the
basis for the Stack.frames
and any non-frame-objects go in Stack.leaf
.
Example: built-in glue for unwrapping a generator iterator:
@unwrap_stackitem.register(types.GeneratorType)
def unwrap_geniter(gen: types.GeneratorType[Any, Any, Any]) -> Any:
if gen.gi_running:
return StackSlice(outer=gen.gi_frame)
return (gen.gi_frame, gen.gi_yieldfrom)
elaborate_frame()
operates after unwrapping is complete, and is
dispatched based on the code object identity of the executing frame,
so it’s useful for customizations that are specific to a particular
function. (You get approximately one code object per source file
location of a function definition.) It receives the frame it is
elaborating as well as the next inner frame-or-leaf for context. It
can customize the Frame
object, such as by setting the Frame.hide
attribute or modifying the Frame.contexts
. It can also redirect the
rest of the stack extraction, by returning a stack item or sequence of
stack items that will be used in place of the otherwise-next frame and
all of its callees. (If it returns a sequence that ends with the
otherwise-next frame, then the preceding elements are inserted before
the rest of the stack trace rather than replacing it.)
Example: glue for elaborating greenback.await_()
:
@elaborate_frame.register(greenback.await_)
def elaborate_greenback_await(
frame: Frame, next_inner: object
) -> object:
frame.hide = True
if (
isinstance(next_inner, Frame)
and next_inner.pyframe.f_code.co_name != "switch"
):
# await_ that's not suspended at greenlet.switch() requires
# no special handling
return None
# Greenback-mediated await of async function from sync land.
# If we have a coroutine to descend into, do so;
# otherwise the traceback will unhelpfully stop here.
# This works whether the coro is running or not.
# (The only way to get coro=None is if we're taking
# the traceback in the early part of await_() before
# coro is assigned.)
return frame.pyframe.f_locals.get("coro")
Contexts¶
There are three hooks relevant in determining the context
managers in the returned stack: unwrap_context()
,
unwrap_context_generator()
, and
elaborate_context()
. They are less complex than the frame hooks
since they only operate on one context manager at a time.
unwrap_context()
handles context managers that wrap other context
managers. It receives the “outer” context manager and returns the “inner”
one, or returns None to indicate no further unwrapping is needed. It is
dispatched based on the type of the outer context manager. When using
unwrap_context()
, the “outer” context manager is totally lost; it
appears to stackscope, and to clients of extract()
, as though only
the “inner” one ever existed. If you prefer to include both, you can
assign to the Context.children
attribute in elaborate_context()
instead.
Example: glue for elaborating greenback.async_context
objects:
@unwrap_context.register(greenback.async_context)
def unwrap_greenback_async_context(manager: Any) -> Any:
return manager._cm
unwrap_context_generator()
is a specialization of
unwrap_context()
for generator-based context managers
(@contextmanager
and
@asynccontextmanager
). It works
exactly like unwrap_context()
except that it takes as its first
argument the generator’s Stack
rather than the context manager
object. Like elaborate_frame()
, unwrap_context_generator()
is dispatched based on the code object identity of the function that
implements the context manager, so you can unwrap different generator-based
context managers in different ways even though their context manager
objects all have the same type.
elaborate_context()
is called once for each context manager
before trying to unwrap it, and again after each successful
unwrapping. It is dispatched based on the context manager type and
fills in attributes of the Context
object, such as
Context.description
, Context.children
, and
Context.inner_stack
. These Context
attributes might be filled out
using calls to fill_context()
or extract()
, which will
recursively execute context/frame hooks as
needed. elaborate_context()
can also change the Context.obj
which may influence further unwrapping attempts.
Example: glue for handling generator-based @contextmanager
s:
@elaborate_context.register(contextlib._GeneratorContextManagerBase)
def elaborate_generatorbased_contextmanager(mgr: Any, context: Context) -> None:
# Don't descend into @contextmanager frames if the context manager
# is currently exiting, since we'll see them later in the traceback
# anyway
if not context.is_exiting:
context.inner_stack = stackscope.extract(mgr.gen)
context.description = f"{mgr.gen.__qualname__}(...)"
Utilities for use in customization hooks¶
- stackscope.extract_child(stackitem: StackItem, *, for_task: bool) Stack ¶
Perform a recursive call equivalent to
extract(stackitem)
, but reusing the options that were passed to the originalextract()
. You should only call this from within a customization hook such aselaborate_context()
.If for_task is True, then this nested stackitem is considered to represent an async child task. Its stack will be fully extracted only if the outer
extract()
call specifiedrecurse_child_tasks=True
; otherwise you will get a stubStack
with aroot
but noframes
.
- stackscope.fill_context(context: Context) None ¶
Augment the given newly-constructed
Context
object using the context manager hooks (unwrap_context()
andelaborate_context()
), calling both hooks in a loop until a steady state is reached.
Customization hooks reference¶
- stackscope.unwrap_stackitem(item: StackItem) StackItem | Sequence[StackItem] | FrameIterator[StackItem] | None ¶
Hook for turning something encountered during stack traversal into one or more objects that are easier to understand than it, eventually resulting in a Python frame. May return a single object, a sequence of objects, or None if we don’t know how to unwrap any further. When extracting a stack,
unwrap_stackitem()
will be applied repeatedly until a series of frames results.The built-in unwrapping rules serve as good examples here:
Unwrapping a coroutine object, generator iterator, or async generator iterator produces a tuple
(frame, next)
where frame is a Python frame object and next is the thing being awaited or yielded-from (potentially another coroutine object, generator iterator, etc).Unwrapping a
threading.Thread
produces the sequence of frames that form the thread’s stack, from outermost to innermost.Unwrapping an async generator
asend()
orathrow()
awaitable produces the async generator it is operating on.Unwrapping a
coroutine_wrapper
object produces the coroutine it is wrapping. This allows stackscope to look inside most awaitable objects. (A “coroutine wrapper” is the object returned when you callcoro.__await__()
on a coroutine object, which acts like a coroutine object except that it also implements__next__
.)
- stackscope.yields_frames(fn: Callable[[P], Iterator[T]]) Callable[[P], FrameIterator[T]] ¶
Decorator for an
unwrap_stackitem()
implementation which allows the series of stack items to be yielded one-by-one instead of returned in a sequence.The main benefit of this approach is that previously-yielded frames can be preserved even if an exception is raised. It is used by the built-in glue that handles
StackSlice
objects. A decorator is needed to distinguish an iterator of stack items (which should be unpacked and treated one-by-one) from a generator iterator with a different purpose (which is a common stack item that we definitely should not iterate over).Put
@yields_frames
underneath@unwrap_stackitem.register
.
- stackscope.elaborate_frame(frame: Frame, next_inner: object) StackItem | Sequence[StackItem] | None ¶
Hook for providing additional information about a frame encountered during stack traversal. This hook uses
@code_dispatch
, so it can be customized based on which function the frame is executing.next_inner is the thing that the frame is currently busy with: either the next
Frame
in the list ofStack.frames
, or else theStack.leaf
(which may beNone
) if there is no next frame.The
elaborate_frame()
hook may modify the attributes of frame, such as by settingFrame.hide
. It may also redirect the remainder of the stack trace, by returning an object or sequence of objects that should be unwrapped to become the new next_inner. If the return value is a sequence and it ends with next_inner, then the items before next_inner are inserted before the remainder of the stack trace instead of replacing it. If you don’t want to affect the rest of the stack trace, then return None (equivalent to next_inner). If you want to remove the rest of the stack trace and not replace it with anything, then returnPRUNE
(which is equivalent to an empty tuple).
- stackscope.PRUNE¶
Sentinel value which may be returned by
elaborate_frame()
to indicate that the remainder of the stack (containing the direct and indirect callees of the frame being elaborated) should not be included in the extractedstackscope.Stack
.
- stackscope.customize(target: Any = None, *inner_names: str, hide: bool = False, hide_line: bool = False, prune: bool = False, elaborate: Callable[[Frame, object], object] | None = None) Any ¶
Shorthand for common
elaborate_frame()
customizations which affect how stack extraction interacts with invocations of specific functions.(target, *inner_names)
identifies a code object; all frames executing that code object will receive the customizations specified by the keyword arguments tocustomize()
. Typically you would a function as target, with no inner_names, to customize frames that are executing that function. You only need inner_names if you’re trying to name a nested function; seeget_code()
for details.If you don’t specify a target, then
customize()
returns a partially bound invocation of itself so that you can use it as a decorator. The target in that case is the decorated function; the decorator returns that function unchanged. (Note the distinction:@customize
decorates the function whose frames get the custom behavior, while@elaborate_frame.register
decorates the function that implements the custom behavior.)The customizations are specified by the keyword arguments you pass:
If elaborate is specified, then it will be registered as an
elaborate_frame
hook for the matching frames.If hide is True, then the matching frames will have their
Frame.hide
attribute set to True, indicating that they should not be shown by default when printing the stack.If hide_line is True, then the matching frames will have their
Frame.hide_line
attribute set to True, indicating that the executing line should not be shown by default when printing the stack (but the function info and contexts still will be).If prune is True, then direct and indirect callees of the matching frames will not be included when extracting the stack. This option only has effect if elaborate either is unspecified or returns None.
- stackscope.unwrap_context(manager: ContextManager[Any] | AsyncContextManager[Any], context: Context) None | ContextManager[Any] | AsyncContextManager[Any] | tuple[()] ¶
Hook for extracting an inner context manager from another context manager that wraps it. The stackscope context object is also provided in case it’s useful. Return None if there is no further unwrapping to do. Return
PRUNE
(equivalent to an empty tuple) to hide this context manager from the traceback. Unlikeunwrap_stackitem()
, it is not currently supported to let the result of unwrapping a context manager be a sequence of multiple context managers.Note
If the original context manager is currently exiting, the frames implementing its
__exit__
will appear on the stack regardless of any unwrapping you do here. You can customizeelaborate_frame()
for the appropriate__exit__
if you want to affect the display there as well.
- stackscope.unwrap_context_generator(frame: Frame, context: Context) None | ContextManager[Any] | AsyncContextManager[Any] | tuple[()] ¶
Hook for extracting an inner context manager from the outermost frame of a generator-based context manager that wraps it. This hook uses
@code_dispatch
, so it can be customized based on the identity of the function that implements the context manager. Apart from that, its semantics are equivalent tounwrap_context()
.Note
If the context manager you’re unwrapping uses
yield from
, it’s possible that you’ll need to access callees of frame to implement your logic. You can find these usingContext.inner_stack
, or if that’s None because the context is currently exiting, you can reconstruct it usingstackscope.extract(context.obj.gen)
.
- stackscope.elaborate_context(manager: ContextManager[Any] | AsyncContextManager[Any], context: Context) None ¶
Hook for providing additional information about a context manager encountered during stack traversal. It should modify the attributes of the provided context object (a
Context
) based on the provided manager (the actual context manager, i.e., the thing whose type has__enter__
and__exit__
attributes).
Low-level introspection tools and utilities¶
In order to implement its context manager analysis, stackscope
includes some
fairly arcane bits of Python introspection lore, including bytecode analysis
and inspection of raw frame objects using ctypes
. The stackscope.lowlevel
module provides direct access to these lower-level bits, in case you want to
use them for a different purpose. It also collects a few of the utilities
used to track code objects for the stackscope.elaborate_frame()
customization hook, in the hope that they might find some broader use.
These are supported public APIs just like the rest of the library;
their membership in the lowlevel
module is primarily
because the problems they solve aren’t directly relevant to the
typical end user.
Extracting context managers¶
- stackscope.lowlevel.contexts_active_in_frame(frame: types.FrameType, origin: Any = None, next_inner: types.FrameType | None = None) List[Context] ¶
Inspects the given frame to try to determine which context managers are currently active; returns a list of
stackscope.Context
objects describing the active context managers from outermost to innermost.This is the entry point to the frame-analysis functions in
stackscope.lowlevel
from the rest of the library. All the others are indirectly called from this one.There are two implementations of this function with different tradeoffs. By default, the most capable one that appears to work in your environment will be chosen; you can override this choice using
set_trickery_enabled()
. See the documentation of that function for more information.If frame is the frame of a generator or coroutine, then you are encouraged to pass that generator or coroutine as the origin parameter. This is required in order to get context manager information on CPython 3.11 and later when using the (fallback/safer) “referents” implementation.
next_inner should be the frame that frame is currently calling, if any. This is necessary to set the
stackscope.Context.obj
attribute correctly on a context manager that is currently exiting.
- stackscope.lowlevel.set_trickery_enabled(enabled: bool | None) None ¶
Choose which of the two available implementations of
contexts_active_in_frame()
should be used. This is a global setting.The “trickery” implementation (enabled = True) uses
ctypes
frame object introspection and bytecode analysis to determine all available information about context managers. It works on currently-executing frames and there are presently no known situations in which it can be fooled.The “referents” implementation (enabled = False) uses
gc.get_referents()
to locate__exit__
and__aexit__
methods referenced by the frame. It doesn’t support executing frames on CPython (but does on PyPy, and supports suspended frames on CPython such as in a generator object/coroutine). It can’t tell which line a context manager started on or what name it was assigned to; you just get the context manager object and an is-async flag. It can be fooled by context managers whose__exit__
methods are not implemented by functions that know their name is__exit__
, and by frames that keep direct references to methods called__exit__
for reasons unrelated to an active context manager in that frame. In exchange for these limitations, you get increased portability and robustness: it should be impossible by construction to crash the interpreter using this implementation, while with the “trickery” implementation you’re putting more trust in our level of testing and caution.The default on CPython and (PyPy with the default “incminimark” garbage collector) is to attempt trickery-based analysis of a simple function the first time context managers need to be extracted, and to use trickery as long as that works. On other Python implementations the “referents” implementation is used. You may request a return to this dynamic default by passing enabled = None.
Frame analysis pieces¶
- stackscope.lowlevel.analyze_with_blocks(code: types.CodeType) Dict[int, Context] ¶
Analyze the bytecode of the given code object, returning a partially filled-in
Context
object for eachwith
orasync with
block.Each key in the returned mapping uniquely identifies one
with
orasync with
block in the function, by specifying the bytecode offset of theWITH_CLEANUP_START
(3.8 and earlier),WITH_EXCEPT_START
(3.9 and 3.10), orPUSH_EXC_INFO
(3.11+) instruction that begins its associated exception handler. The corresponding value is aContext
object appropriate to that block, with the is_async, varname, and start_line fields filled in.
- stackscope.lowlevel.inspect_frame(frame: types.FrameType) FrameDetails ¶
Return a
FrameDetails
object describing the exception handlers and evaluation stack for the currently executing or suspended frame frame.There are three implementations of this function: one for CPython 3.8-3.10, one for CPython 3.11+, and one for PyPy when using the “incminimark” garbage collector. The appropriate one will be chosen automatically.
- class stackscope.lowlevel.FrameDetails(blocks: List[FinallyBlock] = <factory>, stack: List[object] = <factory>)¶
A collection of internal interpreter details relating to a currently executing or suspended frame.
- class FinallyBlock(handler: int, level: int)¶
Information about a currently active exception-catching context within the frame.
On CPython 3.11+, these are inferred from the “zero-cost exception handling”
co_exceptiontable
attribute of the code object. On earlier CPython and all PyPy, they are directly tracked at runtime by the frame object.
- blocks: List[FinallyBlock]¶
Currently active exception-catching contexts in this frame (includes context managers too) in order from outermost to innermost
- stack: List[object]¶
All values on this frame’s evaluation stack. This may be truncated at the position where an exception would unwind to, if the frame is currently executing and we don’t know its actual stack depth. Null pointers are rendered as None and local variables (including cellvars/freevars) are not included.
- stackscope.lowlevel.currently_exiting_context(frame: types.FrameType) ExitingContext | None ¶
If frame is currently suspended waiting for one of its context managers’
__exit__
or__aexit__
methods to complete, then return an object indicating which context manager is exiting and whether it’s async or not. Otherwise return None.This function uses some rather involved bytecode introspection, but only via public interfaces, and should always be safe to call even if something is incorrect in its output. It is used in the “referents” mode of
contexts_active_in_frame()
as well as “trickery” mode, because an exiting context manager is no longer referenced by its frame’s value stack.For the curious, the implementation of this function contains extensive comments about the bytecode sequences to which context managers compile on different Python versions.
- class stackscope.lowlevel.ExitingContext(is_async: bool, cleanup_offset: int)¶
Information about the sync or async context manager that’s currently being exited in a frame.
- stackscope.lowlevel.describe_assignment_target(insns: List[Instruction], start_idx: int) str | None ¶
Given that
insns[start_idx]
and beyond constitute a series of instructions that assign the top-of-stack value somewhere, this function returns a string description of where it’s getting assigned, or None if we can’t figure it out. Understands simple names, attributes, subscripting, unpacking, and positional-only function calls.
Code-object-based dispatch utilities¶
- stackscope.lowlevel.get_code(thing: object, *nested_names: str) types.CodeType ¶
Return the code object that implements the behavior of thing or of a function nested inside it.
thing should be a code object or a callable: a function, method, or
functools.partial
object. If thing is a callable, it will be unwrapped if necessary to yield a function (looking inside methods, following decorator-produced wrappers to the original decorated function, and so forth), and then the function’s code object will be extracted. (If thing is already a code object, it is used as-is at this stage.)If no nested_names are provided, then the code object obtained through the actions in the previous paragraph is returned directly. Otherwise, each of the nested_names is used to look up a function or class whose definition is nested inside the function we’re working with. The code object that results at the end of this traversal is returned from
get_code()
.A somewhat contrived example:
def make_calc(mult, add): class C: def __init__(self, addend): self.addend = addend def calculate(val): return val * mult + self.addend return C(add) calc_code = get_code(make_calc, "C", "calculate") calc_obj = make_calc(5, 2) assert calc_obj.calculate.__func__.__code__ is calc_code assert calc_obj.calculate(3) == (3 * 5) + 2 == 17
- stackscope.lowlevel.code_dispatch(code_from_arg: Callable[[T], types.CodeType]) Callable[[Callable[[Concatenate[T, P]], R]], _CodeDispatcher[T, P, R]] ¶
Decorator for a function that should dispatch to different specializations depending on the code object associated with its first argument. Similar to
functools.singledispatch
, except thatsingledispatch
dispatches on the type of its first argument rather than the implementation of something associated with its first argument.“Associated with” is determined by the required code_from_arg argument, a callable which will be used to extract a code object from the first argument of the decorated function each time it is called. For example, a function that operates on Python frames, and wants to operate differently depending on what function those frames are executing, might pass
lambda frame: frame.f_code
as its code_from_arg.Example use: creating a registry of “should this frame be hidden?” logic:
# The argument to code_dispatch() is used to obtain a code object # from the first argument of each call to should_hide_frame() @code_dispatch(lambda frame: frame.f_code) def should_hide_frame(frame: types.FrameType) -> bool: return "__tracebackhide__" in frame.f_locals # Hide any outcome.capture() or outcome.acapture() frames @should_hide_frame.register(outcome.capture) @should_hide_frame.register(outcome.acapture) def hide_captures(frame: types.FrameType) -> bool: return True
As with
singledispatch
, the decorated function has some additional attributes in addition to being callable:Use
@func.register(target, *names)
as a decorator to register specializations.(target, *names)
identifies a code object as documented underget_code()
.register()
also supports invocation as a non-decoratorfunc.register(target, *names, impl)
.Use
func.dispatch(first_arg)
to return the function that will be invoked for calls tofunc(first_arg, ...)
.func.registry
is a read-only mapping whose keys are code objects and whose values are the corresponding specializations of the@code_dispatch
-decorated function.
- class stackscope.lowlevel.IdentityDict(items: Iterable[Tuple[K, V]] = ())¶
Bases:
MutableMapping
[K
,V
]A dict that hashes objects by their identity, not their contents.
We use this to track code objects, since they have an expensive-to-compute hash which is not cached. You can probably think of other uses too.
Single item lookup, assignment, deletion, and
setdefault()
are thread-safe because they are each implented in terms of a single call to a method of an underlying native dictionary object.
Release history¶
stackscope 0.2.2 (2024-02-27)¶
Bugfixes¶
stackscope 0.2.1 (2024-02-02)¶
Bugfixes¶
Fixed inspection of async context managers that contain a
CLEANUP_THROW
bytecode instruction in their__aenter__
sequence. This can appear on 3.12+ if you write an async context manager inside anexcept
orfinally
block, and would previously produce an inspection warning. (#11)The first invocation of
stackscope.extract()
no longer leaves a partially-exhausted async generator object to be garbage collected, which previously could confuse async generator finalization hooks. (#12)
stackscope 0.2.0 (2023-12-22)¶
With this release, stackscope can print full Trio task trees out-of-the-box.
Try print(stackscope.extract(trio.lowlevel.current_root_task(),
recurse_child_tasks=True))
.
Backwards-incompatible changes¶
The
unwrap_context()
hook now accepts an additionalContext
argument. This saves on duplicated effort betweenelaborate_context()
andunwrap_context()
, avoiding exponential time complexity in some pathological cases.Removed support for Python 3.7.
User-facing improvements to core logic¶
Added support for representing child tasks in structured concurrency libraries, by allowing
Context.children
to containStack
s in addition to the existing support for childContext
s. By default, the child tasks will not have their frames filled out, but you can override this with the new recurse_child_tasks parameter toextract()
. (#9)Added
Frame.hide_line
andContext.hide
attributes for more precise control of output.Added a new attribute
Stack.root
which preserves the original “stack item” object that was passed toextract()
. For stacks generated from async child tasks, this will be theTask
object.Added support for Python 3.12.
Library support (“glue”) improvements¶
stackscope can now trace seamlessly across Trio/thread boundaries when extracting a stack that includes calls to
trio.to_thread.run_sync()
and/ortrio.from_thread.run()
. The functions running in the cross-thread child will appear in the same way that they would if they had been called directly without a thread transition. (#8)Added glue to support
pytest-trio
. (#4)Updated Trio glue to support unwrapping
trio.lowlevel.Task
s and filling in the child tasks of atrio.Nursery
.
Improvements for glue developers¶
A library can now ship its own stackscope customizations without requiring that all of its users install stackscope. Any module may define a function called
_stackscope_install_glue_()
, which stackscope will call when it is first used to extract a stack trace after the module has been imported. (#7)Added
unwrap_context_generator()
hook for more specific customization of generator-based context managers.Modified the
elaborate_frame()
hook to be able to return a sequence of stack items rather than just a single one. This permits more expressive augmentation rules, such as inserting elements into the stack trace without removing what would’ve been there if the hook were not present.Added a new function
extract_child()
for use in customization hooks. It is likeextract()
except that it reuses the options that were specified for the outerextract()
call, and contains some additional logic to prune child task frames if the outerextract()
didn’t ask for them.elaborate_frame()
now runs afterFrame.contexts
is populated, so it has the chance to modify the detected context managers.
stackscope 0.1.0 (2023-04-12)¶
Initial release.