Tutorial: build a coverage tracer

In this tutorial you’ll build a working statement-level code-coverage tool from scratch. Along the way you’ll meet raw handlers, file filtering, sandbox detection, and the tracing lifecycle hooks — the same ingredients that power the coverage.py example shipped with Pyccolo.

The idea is simple: every time a statement is about to run, record it. At the end, compare the set of statements we saw against the total number of statements in each file.

Step 1: record every statement

Start with a pyccolo.BaseTracer subclass that handles before_stmt. We only need to know which statement fired — its identity — so we register a raw handler with pyc.register_raw_handler. A raw handler receives the integer node id instead of a resolved ast.AST node, which is exactly what we want to use as a dictionary/set key (and it’s the fastest path). See Registering handlers for the difference.

from collections import Counter

import pyccolo as pyc


class CoverageTracer(pyc.BaseTracer):
    bytecode_caching_allowed = False

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.seen_stmts = set()
        self.stmt_count_by_fname = Counter()

    @pyc.register_raw_handler(pyc.before_stmt)
    def handle_stmt(self, _ret, stmt_id, frame, *_, **__):
        fname = frame.f_code.co_filename
        if stmt_id not in self.seen_stmts:
            self.stmt_count_by_fname[fname] += 1
            self.seen_stmts.add(stmt_id)

Why bytecode_caching_allowed = False? Pyccolo can cache the instrumented bytecode it compiles. For most tracers that’s a welcome speedup, but a coverage tool must see every statement on every run — a stale or partially-cached module could omit statements from the count. Turning caching off keeps the picture honest. (See Configuration attributes for the full list of these class-level switches.)

Step 2: choose which files to cover

By default, imported modules are not instrumented. You opt files in by overriding should_instrument_file(), which is called with each candidate module’s filename. Return True for the files you want to measure:

def should_instrument_file(self, filename: str) -> bool:
    if "test/" in filename or "examples" in filename:
        return False
    return "pyccolo" in filename

Here we cover the pyccolo package itself while skipping tests and examples. See Tracing real programs for the mechanics of import instrumentation.

Step 3: ignore sandbox frames

Code you run through pyc.exec / pyc.eval is compiled under a synthetic “sandbox” filename. If you only care about coverage of real files on disk, filter those frames out by testing the frame’s filename against pyc.SANDBOX_FNAME_PREFIX:

@pyc.register_raw_handler(pyc.before_stmt)
def handle_stmt(self, _ret, stmt_id, frame, *_, **__):
    fname = frame.f_code.co_filename
    if fname.startswith(pyc.SANDBOX_FNAME_PREFIX):
        return
    if stmt_id not in self.seen_stmts:
        self.stmt_count_by_fname[fname] += 1
        self.seen_stmts.add(stmt_id)

Step 4: report at the end

To emit a report when tracing finishes, override exit_tracing_hook(). We need the total number of statements per file to compute a ratio; a small ast.NodeVisitor that counts ast.stmt nodes does the job:

import ast


class CountStatementsVisitor(ast.NodeVisitor):
    def __init__(self):
        self.num_stmts = 0

    def generic_visit(self, node):
        if isinstance(node, ast.stmt) and not isinstance(node, ast.Raise):
            self.num_stmts += 1
        super().generic_visit(node)


def count_statements(path: str) -> int:
    with open(path) as f:
        visitor = CountStatementsVisitor()
        visitor.visit(ast.parse(f.read()))
    return visitor.num_stmts
def exit_tracing_hook(self) -> None:
    total = 0
    for fname in sorted(self.stmt_count_by_fname):
        if fname.startswith(pyc.SANDBOX_FNAME_PREFIX):
            continue
        seen = self.stmt_count_by_fname[fname]
        in_file = count_statements(fname)
        total += in_file
        print(f"{fname}: seen={seen}, total={in_file}, ratio={seen / in_file:.3f}")
    if total:
        print(f"overall ratio: {len(self.seen_stmts) / total:.3f}")

Step 5: run it

Activate the tracer as a context manager and run some code under it. Coverage of imported modules works because the tracer is active while the import happens:

with CoverageTracer.instance():
    import some_package  # its statements are now counted as they execute

To measure coverage of a whole test suite, drive the test runner from inside the tracing context and install the import hook so freshly-imported modules get instrumented. The shipped example does exactly this with pytest:

from pyccolo.import_hooks import patch_meta_path

with CoverageTracer.instance():
    with patch_meta_path(pyc._TRACER_STACK):
        pytest.console_main()

That’s a complete, if minimal, coverage tool — a couple of handlers and two lifecycle hooks. The full version lives in pyccolo/examples/coverage.py.

Where to next

  • Coverage adds real overhead. Guards and performance shows how to deactivate instrumentation once you’ve seen a statement for the first time.

  • The BaseTracer page catalogs every hook and method you can override.