Tutorial: a variable watchpoint debugger
Debuggers let you set a watchpoint: “stop (or tell me) whenever this variable changes.” In this tutorial you’ll build one in about a dozen lines — a tracer that fires a callback every time a chosen name is reassigned, handing you the old and new values. It’s a compact, practical example of observing execution (no value overriding), reading the AST node and the live frame together, and threading your own state through a tracer.
Unlike the syntax examples, this needs no new syntax and no Python-version floor — just the assignment event.
Step 1: catch every assignment
The after_assign_rhs event fires with the value a right-hand side just
produced. That value is about to be bound to the target(s) on the left, so it’s
the perfect moment to notice a change. A handler receives the AST node and the
live frame; from the node we can find the enclosing assignment statement, and
from the frame we can read what the variable held a moment ago:
import ast
import pyccolo as pyc
class Watchpoint(pyc.BaseTracer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.callbacks = {} # name -> callback(name, old, new)
def watch(self, name, callback):
self.callbacks[name] = callback
return self
@pyc.after_assign_rhs
def on_assign(self, ret, node, frame, *_, **__):
stmt = self.containing_stmt_by_id.get(id(node))
if isinstance(stmt, ast.Assign):
for target in stmt.targets:
if isinstance(target, ast.Name) and target.id in self.callbacks:
old = frame.f_locals.get(target.id, "<unset>")
self.callbacks[target.id](target.id, old, ret)
return ret # observe only — never change the value
Two Pyccolo details do the work. self.containing_stmt_by_id is a bookkeeping
table Pyccolo maintains during the rewrite; it takes us from the right-hand-side
expression node up to the ast.Assign that contains it, so we can read the
assignment’s targets. And frame.f_locals still holds the variable’s
previous value, because the new one has not been bound yet — that’s what makes
“old → new” possible. Returning ret unchanged keeps this a pure observer.
Step 2: set a watch and run
Register a callback for the name you care about, then run some code under the
tracer. Here we record every change to balance into a list:
changes = []
tracer = Watchpoint.instance().watch(
"balance", lambda name, old, new: changes.append((old, new))
)
with tracer:
pyc.exec(
"balance = 100\n"
"balance = balance - 30\n"
"other = 5\n" # not watched -> ignored
"balance = balance + 10\n"
)
assert changes == [("<unset>", 100), (100, 70), (70, 80)]
The unwatched assignment to other is skipped, and each write to balance
arrives with the value it had just before. A real debugger would print or drop
into a prompt instead of appending to a list — that is just a different callback:
Watchpoint.instance().watch(
"balance",
lambda name, old, new: print(f"{name}: {old!r} -> {new!r}"),
)
# balance: '<unset>' -> 100
# balance: 100 -> 70
# ...
Step 3: watch by condition
Because the callback is ordinary Python, “break only when the value goes negative” or “only on the third change” is just a conditional inside it:
def alert_if_negative(name, old, new):
if isinstance(new, (int, float)) and new < 0:
raise AssertionError(f"{name} went negative: {new}")
Watchpoint.instance().watch("balance", alert_if_negative)
For a version that only inspects — rather than reads the frame’s previous value —
you could subscribe to load_name instead and watch reads, or combine both to
log the entire lifetime of a variable.
Where to next
You’ve now used the two halves of a handler’s context — the static AST (via
containing_stmt_by_id) and the dynamic frame — without overriding anything.
The Observe and override values guide covers the flip side (changing values
in flight), and Guards and performance shows how to keep a
per-variable watch cheap with local guards once you only need the first change.