Why pyc.exec, and how scoping works

Newcomers almost always ask the same question: why do I have to wrap my code in pyc.exec("...") instead of just writing it normally? The answer is a consequence of when Python compiles code, and it is worth understanding because it also tells you exactly when you don’t need pyc.exec.

The AST is fixed at compile time

A module’s abstract syntax tree is built once, when the module is compiled and imported — and Pyccolo’s instrumentation is an AST rewrite (see The model: events and handlers). When your script first starts running, the module containing your tracer is already compiled, and the tracer was not active at that moment. So any ordinary, unquoted Python written in the same file as the tracer definition has no instrumentation woven into it — there was no active tracer to weave it in.

To instrument code that lives in that file, you “quote” it as a string (or an AST) and hand it to pyc.exec or pyc.eval. These parse it, run it through the instrumentation pipeline, and execute the result — this time with your tracer active:

import pyccolo as pyc


class HelloTracer(pyc.BaseTracer):
    @pyc.before_stmt
    def handle_stmt(self, *_, **__):
        print("Hello, world!")


with HelloTracer:
    pyc.exec("for _ in range(10): pass")  # instrumented; prints 11 times

pyc.exec returns a namespace

pyc.exec runs its code in a sandbox and returns the resulting namespace as a dict, so you can inspect what the executed code produced:

env = pyc.exec("x = 42")
assert env["x"] == 42

pyc.eval is the expression-valued counterpart, returning the value of a single expression. Both compile under a synthetic sandbox filename so their frames are recognizable; if you need those frames visible in tracebacks, see the traceback-visibility helpers in Utilities & globals.

When you don’t need pyc.exec

Two important cases run instrumented code without any quoting:

  • ``sys`` events. Handlers for call, line, return_, exception, and opcode ride on Python’s built-in tracing machinery, not on an AST rewrite. Nothing needs to be recompiled, so ordinary in-file code triggers them directly. See Tracing real programs.

  • Imported modules. Code imported inside a tracing context can be instrumented at import time, because Pyccolo controls the import and can rewrite the module as it loads — provided the module opts in via should_instrument_file(). See Tracing real programs.

Reaching into the caller’s scope

Handlers receive the executing frame, and sometimes need to write a value back into it — for example to set up a guard variable or bind a synthetic name. pyc.set_frame_local does this safely across the frame’s local and global namespaces, rather than mutating frame.f_locals directly (which does not reliably propagate). It is part of the utilities reference, alongside the sandbox-filename constants used to identify pyc.exec frames. For the full set of run-and-compile entry points, see BaseTracer.