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.