Your first tracer
Let’s build the smallest interesting Pyccolo program and understand every line of
it. Here is a tracer that prints "Hello, world!" before every statement that
executes:
import pyccolo as pyc
class HelloTracer(pyc.BaseTracer):
@pyc.before_stmt
def handle_stmt(self, *_, **__):
print("Hello, world!")
with HelloTracer:
# prints "Hello, world!" 11 times
pyc.exec("for _ in range(10): pass")
Three things are happening here.
The tracer class
Instrumentation is provided by a tracer class that inherits from
pyc.BaseTracer. The class rewrites Python source so that events of interest —
here, “a statement is about to execute” — trigger your code. You subscribe to an
event by decorating a method with it; @pyc.before_stmt registers
handle_stmt as a handler for the before_stmt
event. The *_, **__ swallows the arguments this handler doesn’t need (a
handler receives several — see Handlers).
The class is the context manager
Notice that we write with HelloTracer: — the class itself, not an
instance. You do not instantiate a tracer; BaseTracer is a singleton whose
class doubles as a context manager. Entering the with block activates the
tracer; leaving it deactivates it.
What is up with pyc.exec(...)?
A program’s abstract syntax tree is fixed when the module is compiled. When our
script first started running, HelloTracer was not yet active, so any unquoted
Python in the same file was compiled without instrumentation — its statements
will never emit before_stmt. To instrument code that lives in the same module
as the tracer definition, we hand it to pyc.exec(...) as a string (or AST), so
Pyccolo can rewrite and run it while the tracer is active.
pyc.exec returns the resulting namespace as a dict, which is handy for
inspecting results. (Code in other modules can be instrumented at import time
instead — see Tracing real programs. And sys-level events like
call don’t need pyc.exec at all — see Tracing real programs.) The
full story is in Why pyc.exec, and how scoping works.
Handlers can change behavior, not just observe it
A handler isn’t limited to watching — for value-carrying events, the value it returns replaces the value of the instrumented expression. Here is a tracer that adds one to the result of every assignment’s right-hand side:
import pyccolo as pyc
class IncrementEveryAssignment(pyc.BaseTracer):
@pyc.after_assign_rhs
def handle(self, ret, *_, **__):
return ret + 1
with IncrementEveryAssignment:
env = pyc.exec("x = 42")
assert env["x"] == 43
The after_assign_rhs handler receives ret (the value the right-hand side
produced) and returns ret + 1, which is what actually gets bound to x.
Returning None (or nothing) would mean “don’t override.” This ability to
rewrite values in flight is what makes Pyccolo more than a profiler — the
Observe and override values guide picks up right here with the full set of
overriding moves, and The model: events and handlers covers the events-and-handlers model
in depth.