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 write import 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 @contextmanagers:

@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 original extract(). You should only call this from within a customization hook such as elaborate_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 specified recurse_child_tasks=True; otherwise you will get a stub Stack with a root but no frames.

stackscope.fill_context(context: Context) None

Augment the given newly-constructed Context object using the context manager hooks (unwrap_context() and elaborate_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() or athrow() 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 call coro.__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 of Stack.frames, or else the Stack.leaf (which may be None) if there is no next frame.

The elaborate_frame() hook may modify the attributes of frame, such as by setting Frame.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 return PRUNE (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 extracted stackscope.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 to customize(). 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; see get_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. Unlike unwrap_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 customize elaborate_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 to unwrap_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 using Context.inner_stack, or if that’s None because the context is currently exiting, you can reconstruct it using stackscope.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).