retrospect instrumented Python functions already defined or imported in a live process—no source edits, no restart, and an exact way back. The README’s one-liner:
Classic logging expects perfect foresight, but only hindsight is 20/20!
The wrapper keeps exactly two things—a weakref to the function and its original code object:
class FunctionRetrospector(object):
def __init__(self, function):
self._function_ref = weakref.ref(function)
self._function_code = function.__code__implement(**kwds) decompiles that saved code with byteplay, walks the (opcode, oparg) stream, and splices an emission sequence around the points you asked for: lines= matches SetLineno markers, symbols= brackets STORE_FAST both before and after the store, opcodes= matches other named opcodes, boundaries= covers entry and the RETURN_VALUE exit. Then it reassembles and assigns function.__code__. Calling implement() with no arguments assigns the saved original back—reversal is one attribute write, not an unpatch algorithm, and since the original object was only ever replaced, never mutated, toggling on and off cannot accumulate drift.
The selectors have an ordering limit. Line markers, stores, and returns have dedicated branches before the general opcode match. Selecting opcodes='STORE_FAST' alone therefore does not instrument stores; that earlier branch checks symbols= instead.
Assigning function.__code__ changes what subsequent calls execute. It does not replace the code object of a frame already on the stack; that invocation continues through the original frame. “After the fact” means instrumentation can be armed after the need is discovered and observed on the next invocation, not that it splices probes into an instruction stream already in flight.
The emission sequence is hand-built opcodes:
return [(byteplay.LOAD_CONST, emit or _emit),
(byteplay.LOAD_CONST, context),
(byteplay.LOAD_CONST, globals),
(byteplay.CALL_FUNCTION, 0),
(byteplay.LOAD_CONST, locals),
(byteplay.CALL_FUNCTION, 0),
(byteplay.CALL_FUNCTION, 3),
(byteplay.POP_TOP, None)]Push the emitter, push a frozen (lineno, opcode, oparg) context, call globals() and locals() at exactly that instruction, hand all three to the emitter, discard the result. The emitter sees the live locals dict as it stood at that instruction—per-line locals without sys.settrace. A trace function invokes a callback on each selected tracing event; here there is no always-on tracing callback, and only the chosen sites execute the injected emitter sequence.
The symbols= mode is the demo that sells it. Instrumenting a = b = c = a * 2 + b * c emits before and after each STORE_FAST, so the README output shows the frame’s locals changing store by store—the sub-expression evaluation order of a chained assignment, visible at the bytecode level.
One sharp edge by design: the weakref means the target can be collected out from under the wrapper, and implement raises FunctionVanished rather than resurrect it.
This is Python 2 era and stays there. byteplay reads and writes CPython 2 bytecode, so retrospect 0.1.4 is version-locked to unsupported Python 2 interpreters.