DEV Community

Dakota Huang
Dakota Huang

Posted on

Census Every Raise Site Before You Move a Module

Census Every Raise Site Before You Move a Module

Your test suite checks return values. It rarely checks which exceptions escape. That gap grows when you extract a module.

Here is the rule I use: freeze the error contract first, then move exactly one symbol. Two baselines, one empty diff, one revert command.

The census script below is a template. I have not run it against your repository. Treat its first output as review material, not as truth.

Step 1: Name the boundary and the one symbol

Write the boundary down before touching code. List the public functions that callers use today.

Then pick a single symbol to move. No signature changes. No renames. One commit, one revert target.

boundary: src/reports/parse.py -> exports parse_header, parse_rows
symbol:   parse_header
no-touch: parse_rows, src/reports/emit.py
Enter fullscreen mode Exit fullscreen mode

If you cannot name the symbol in one line, the change is too large.

Step 2: Build a static census of raise sites

Error paths drift because nobody counts them. Count them with the AST, not with grep.

#!/usr/bin/env python3
# tools/error_census.py - template; review output before trusting it
import ast, json, sys
from pathlib import Path

def template_of(node):
    if node is None:
        return ""
    if isinstance(node, ast.Constant) and isinstance(node.value, str):
        return node.value
    if isinstance(node, ast.JoinedStr):
        out = []
        for v in node.values:
            out.append(str(v.value) if isinstance(v, ast.Constant) else "{}")
        return "".join(out)
    return "<dynamic>"

def type_of(exc):
    if exc is None:
        return "<reraise>"
    f = exc.func if isinstance(exc, ast.Call) else exc
    if isinstance(f, ast.Name):
        return f.id
    if isinstance(f, ast.Attribute):
        return f.attr
    return "<dynamic>"

def census(root):
    rows = []
    for path in sorted(Path(root).rglob("*.py")):
        if "/.venv/" in str(path) or "/tests/" in str(path):
            continue
        tree = ast.parse(path.read_text(encoding="utf-8"))
        for node in ast.walk(tree):
            if isinstance(node, ast.Raise):
                msg = node.exc.args[0] if isinstance(node.exc, ast.Call) and node.exc.args else None
                rows.append({"file": str(path), "line": node.lineno,
                             "type": type_of(node.exc), "template": template_of(msg)})
    return sorted(rows, key=lambda r: (r["file"], r["line"]))

if __name__ == "__main__":
    print(json.dumps(census(sys.argv[1]), indent=2))
Enter fullscreen mode Exit fullscreen mode

Run it once and read the output by hand. You will find dead raises and untranslated messages.

python tools/error_census.py src > errors.baseline.json
wc -l errors.baseline.json
Enter fullscreen mode Exit fullscreen mode

Step 3: Snapshot runtime exceptions, not just static ones

Static analysis misses raise exc_cls("boom"). Add a small runtime snapshot for bad inputs.

# tests/test_error_contract.py - characterization, not specification
import json
from pathlib import Path
from reports.parse import parse_header

CASES = {"empty": b"", "garbage": b"\x00\xff", "truncated": b"HDR1|"}

def test_error_contract_is_stable():
    observed = {}
    for name, payload in CASES.items():
        try:
            parse_header(payload)
        except Exception as exc:
            observed[name] = {"type": type(exc).__name__, "msg": str(exc)}
    baseline = json.loads(Path("errors.runtime.json").read_text())
    assert observed == baseline
Enter fullscreen mode Exit fullscreen mode

Generate the baseline once on a clean checkout. Commit both files. They are now the contract.

Step 4: Capture the baseline on a disposable machine

Local baselines lie. Your shell exports, locale, and Python patch version leak into messages.

A throwaway Linux box fixes that. I used MonkeyCode's free server option for this step: fresh checkout, same interpreter, record both baselines there. MonkeyCode also offers free model access, which I used only to draft a first-pass exception taxonomy for human review.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

ssh box 'git clone <repo> && cd repo && python -m venv .venv'
ssh box 'cd repo && .venv/bin/python tools/error_census.py src > errors.baseline.json'
ssh box 'cd repo && .venv/bin/pytest tests/test_error_contract.py --record'
scp box:repo/errors.baseline.json .
Enter fullscreen mode Exit fullscreen mode

Do not upload proprietary code without written approval. Verify current terms yourself; free tiers change.

Step 5: Make the smallest safe change

The move should touch imports and one file. Nothing else.

git switch -c move-parse-header
git mv src/reports/parse.py src/reports/header.py
git commit -m "move: parse_header out of reports.parse"
Enter fullscreen mode Exit fullscreen mode

No logic edits in the same commit. If the diff shows a changed condition, you moved two things.

Step 6: Gate the second commit

Key on basename, type, and template. The move changes paths, so full paths would create false alarms.

# tools/error_gate.py
def key(r):
    return (Path(r["file"]).name, r["type"], r["template"])

def test_no_new_error_sites():
    current = json.loads(run_census("src"))
    baseline = json.loads(Path("errors.baseline.json").read_text())
    allow = {tuple(a["key"]) for a in load_allowlist()}
    added = {key(r) for r in current} - {key(r) for r in baseline} - allow
    assert not added, sorted(added)
Enter fullscreen mode Exit fullscreen mode

Added sites fail the gate. Removed sites pass with a note, because deletions are visible in review.

Step 7: Allow intentional changes with a reason

Some moves legitimately change errors. Make that a written decision, not a silent pass.

[{"key": ["header.py", "ValueError", "bad header magic"],
  "reason": "moved from TypeError; see PR #412",
  "expires": "2026-12-01"}]
Enter fullscreen mode Exit fullscreen mode

An allow-list with an expiry date is a receipt. Without it, the gate decays into comments.

When this gate pays off

| Repo shape | Use the census gate | Why |
| --- | --- |
| Library with public exceptions | Yes | Callers catch types, not messages |
| CLI that users script against | Yes | Exit paths depend on error types |
| Thin CRUD service | Maybe | Few raise sites, low drift risk |
| Heavy dynamic dispatch | No | AST census misses most sites |
| Generated or vendored code | No | Noise drowns the signal |

Pick rows that match your repo. A gate you cannot trust is worse than no gate.

Limitations you should expect

  • Dynamic raises resolve to <dynamic>. Read those rows by hand.
  • Library-raised exceptions never appear in your source census.
  • Runtime snapshots only cover the inputs you listed.
  • Basename keys collide in repos with repeated filenames.
  • Message text in i18n files sits outside the AST scan.

None of these are fatal. All of them need a sentence in your PR description.

Who should not use this

Skip it if you have no CI to run the gate. Skip it if your team will not maintain an allow-list. Skip it if the module is scheduled for deletion.

Also skip it if you cannot get approval to run code on an outside machine. Run the baseline locally instead, and accept the environment noise.

The habit worth keeping

Count error sites before the move. Snapshot them twice. Require one empty diff.

Then the refactor becomes boring, which is the goal. If you try the census on a single module, the first surprise is usually a raise nobody documented.

Top comments (0)