Observe and override values
A handler is not limited to watching your program run. For value-carrying events, whatever your handler returns replaces the value flowing through the instrumented expression — and returning nothing means “leave it alone.” That one rule is enough to build behavioral changes that would otherwise require rewriting the program. This guide collects the moves that rule enables: overriding values, the sentinels that give you finer control, gating a handler to just the nodes you care about, and intercepting an expression before it runs.
Override a value by returning it
Every after_* event that carries a value hands your handler that value as its
first argument (conventionally ret) and uses your return value in its place.
Here we add one to the right-hand side of every assignment:
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
pyc.exec returns the resulting namespace as a dict, which makes the effect
easy to inspect. The same idea works on literals — promoting every float to
an exact Decimal:
from decimal import Decimal
class ExactFloats(pyc.BaseTracer):
@pyc.after_float
def to_decimal(self, ret, *_, **__):
return Decimal(str(ret))
with ExactFloats:
assert pyc.exec("x = 0.1 + 0.2")["x"] == Decimal("0.3")
…or redacting digits out of every string literal with a regex — an override that reaches into ordinary-looking code and quietly changes its data:
import re
class Redact(pyc.BaseTracer):
@pyc.after_string
def scrub(self, ret, *_, **__):
return re.sub(r"\d", "*", ret)
with Redact:
assert pyc.exec("s = 'call 5551234'")["s"] == "call *******"
Returning None means “don’t override”
There is one wrinkle: since a handler that returns None (or falls off the
end) means no override, you cannot substitute a real None just by returning
it. Use the pyc.Null sentinel to override with None:
class NullOutAssignments(pyc.BaseTracer):
@pyc.after_assign_rhs
def handle(self, *_, **__):
return pyc.Null
with NullOutAssignments:
assert pyc.exec("x = 42")["x"] is None
The four sentinels
pyc.Null is one of a small family of sentinels a handler can return to
control the flow of instrumentation. The others govern the handler stack — when
more than one handler (on one tracer, or across composed tracers) is registered
for the same event, they run in order and each receives the previous one’s result
as ret.
pyc.Null— override the value with a realNone.pyc.Pass— forbefore_stmt, skip the statement entirely (it never runs).pyc.Skip— stop running this tracer’s remaining handlers for this event.pyc.SkipAll— stop the entire tracer stack for this event, so no lower tracer’s handler runs either.
pyc.Pass turns before_stmt into a veto. Here we drop every top-level
print call without running it:
class SuppressPrints(pyc.BaseTracer):
@pyc.before_stmt(
when=pyc.Predicate(
lambda node: isinstance(node, ast.Expr)
and isinstance(node.value, ast.Call)
and isinstance(node.value.func, ast.Name)
and node.value.func.id == "print",
static=True,
)
)
def veto(self, *_, **__):
return pyc.Pass
with SuppressPrints:
env = pyc.exec("print('never shown')\nkept = 5")
assert env["kept"] == 5
And pyc.Skip halts the rest of a tracer’s handler chain for the current
event:
ran = []
class StopsEarly(pyc.BaseTracer):
@pyc.register_handler(pyc.after_assign_rhs)
def first(self, ret, *_, **__):
ran.append("first")
return pyc.Skip
@pyc.register_handler(pyc.after_assign_rhs)
def second(self, ret, *_, **__):
ran.append("second")
return ret
with StopsEarly:
pyc.exec("x = 1")
assert ran == ["first"]
See Handlers for the full return-value contract.
Fire a handler only sometimes
Pay-as-you-go decides whether an event is emitted at all; a when= predicate
goes finer and gates an individual handler to only the nodes it cares about. In
its simplest form it is a plain callable of the AST node:
seen = []
class OnlyAdditions(pyc.BaseTracer):
@pyc.after_binop(when=lambda node: isinstance(node.op, ast.Add))
def handle(self, ret, node, *_, **__):
seen.append(type(node.op).__name__)
return ret
with OnlyAdditions:
pyc.exec("a = 1 + 2\nb = 3 * 4")
assert seen == ["Add"] # the multiplication never reached the handler
To combine conditions, just combine them in the callable:
class AddOrSub(pyc.BaseTracer):
@pyc.after_binop(when=lambda node: isinstance(node.op, (ast.Add, ast.Sub)))
def handle(self, ret, *_, **__):
return ret
with AddOrSub:
pyc.exec("a = 1 + 2 - 3")
Static vs. dynamic predicates
Wrapping the condition in a pyccolo.Predicate lets you say when it is
evaluated:
pyc.Predicate(cond, static=True)is evaluated once, at instrument time, and the answer is baked into the rewrite — zero runtime cost, but the condition may only look at the shape of the syntax tree. This is what theSuppressPrintsexample above uses: whether a statement is aprintcall is decidable from the AST alone.pyc.Predicate(cond)(dynamic) re-checks on every event.
Because a static predicate is resolved during the rewrite, a handler that never
matches adds nothing to your running program — the same spirit as pay-as-you-go
itself. Reach for static=True whenever the decision depends only on syntax.
Register by AST node type
Instead of naming an event, you can register a handler by the ast node
type it should fire for; Pyccolo maps the node to the corresponding value-carrying
event. ast.Assign maps to after_assign_rhs, ast.BinOp to
after_binop, ast.Call to after_call, and so on:
class Bump(pyc.BaseTracer):
@pyc.register_handler(ast.Assign)
def handle(self, ret, *_, **__):
return ret + 100
with Bump:
assert pyc.exec("x = 2 * 3")["x"] == 106
The full node-to-event mapping lives in Registering handlers.
Intercept an expression before it runs
Overriding an after_* value happens after the expression has been
evaluated. Sometimes you need to decide whether it runs at all — and the
before_* events give you that, by passing your handler a thunk: a
zero-argument callable that, when invoked, evaluates the original expression.
Return a thunk of your own and Pyccolo calls it in place of the original.
This is exactly what short-circuit evaluation needs. before_boolop_arg fires
once per operand of an and / or, so we can record which operands actually
get evaluated:
evaluated = []
class TraceShortCircuit(pyc.BaseTracer):
@pyc.register_handler(pyc.before_boolop_arg)
def handle(self, thunk, *_, **__):
def wrapped():
value = thunk() # evaluate the operand for real...
evaluated.append(value) # ...and record what it produced
return value
return wrapped
with TraceShortCircuit:
assert pyc.exec("r = False and (1 / 0)")["r"] is False
assert evaluated == [False] # the `1 / 0` operand was never evaluated
Because and short-circuits, the second operand’s thunk is never called — so
the 1 / 0 that would have raised is quietly skipped, and it never reaches our
list. The reference marks which events pass a thunk (the “before-expression”
events) in the event catalog.