DEV Community

Dakota Huang
Dakota Huang

Posted on

Pin the Output Schema. Then Extract One Key Builder.

Do not start a messy-repo refactor with a rewrite.
Pin the public output schema on fixtures first.
Then extract one pure helper and stop there.

Messy repositories hide public contracts inside mixed functions.
Environment reads sit beside globbing and dict assembly.
A wide rename then breaks callers nobody listed.

This workflow freezes keys, types, and opened paths.
It changes one function and leaves I/O untouched.

The failure mode

Legacy report builders often mix three separate jobs.
They load environment values from the process state.
They scan files with glob patterns in the next step.
They assemble nested dictionaries for every downstream caller.

A coding assistant will offer a full rewrite.
That rewrite usually changes key order and types.
Callers then fail far from the original module.

You need a ledger before any extract starts.
The ledger is a characterization test, not a specification.
It records what the module does today only.

What you freeze

Freeze four observables on a small fixture corpus.

  1. Record sorted top-level keys from the returned dict.
  2. Record the Python types of every nested value.
  3. Record relative paths passed into the open calls.
  4. Record the import surface of the target module.

Do not freeze wall-clock timestamps in this ledger.
Do not freeze absolute home directory path strings.
Do not freeze unordered set iteration in snapshots.

Those fields drift without a real behavior change.
Your characterization ledger would become noisy very quickly.

Example module (proposal)

The listing below is a teaching fixture only.
Treat it as unlabeled production-shaped example code today.
Do not copy it into a live service.

# report.py — mixed env, glob, and dict assembly
from __future__ import annotations

import glob
import json
import os
from pathlib import Path
from typing import Any


def build_report(root: str) -> dict[str, Any]:
    pattern = os.environ.get("REPORT_GLOB", "*.json")
    label = os.environ.get("REPORT_LABEL", "batch")
    files = sorted(glob.glob(str(Path(root) / pattern)))
    rows = []
    opened = []
    for path in files:
        opened.append(os.path.relpath(path, root))
        with open(path, encoding="utf-8") as handle:
            payload = json.load(handle)
        key = f"{label}:{Path(path).stem}"
        n = len(payload) if isinstance(payload, dict) else 0
        rows.append({"id": key, "n": n})
    return {
        "label": label,
        "count": len(rows),
        "ids": [row["id"] for row in rows],
        "opened": opened,
        "rows": rows,
    }
Enter fullscreen mode Exit fullscreen mode

The function returns one nested dictionary to callers.
Public keys form the contract you must freeze.
File opens count as public side effects too.

1. Inventory the import surface

List every caller that imports the target module.
Complete this scan before you edit any line.

rg -n "from report import|import report" --glob "*.py"
Enter fullscreen mode Exit fullscreen mode

Write every match into a committed callers.txt file.
Commit that file with the first characterization test.

If the scan returns no matches, stop immediately.
You do not yet have a public surface.
Find scripts and tests that call build_report next.

2. Build a hashed fixture tree

Create a tiny directory of stable JSON files.
Keep both filenames and file bytes fully stable.

mkdir -p fixtures/batch
printf '%s\n' '{"a":1}' > fixtures/batch/alpha.json
printf '%s\n' '{"b":2,"c":3}' > fixtures/batch/beta.json
Enter fullscreen mode Exit fullscreen mode

Hash the tree immediately after you write files.

find fixtures/batch -type f -print0 | sort -z | xargs -0 sha256sum
Enter fullscreen mode Exit fullscreen mode

Store those hashes in fixtures.sha256 beside the tree.
Re-run the hash command after every later extract.
Do not paste invented hash strings into the commit.

3. Write the characterization harness

The harness below is an unexecuted proposal only.
Run it locally before you trust any result.

# test_report_char.py
from __future__ import annotations

import json
from pathlib import Path

import pytest

from report import build_report

HERE = Path(__file__).resolve().parent
FIXTURE_ROOT = HERE / "fixtures" / "batch"
LEDGER_DIR = HERE / "ledgers"


def _schema(value):
    if isinstance(value, dict):
        return {k: _schema(value[k]) for k in sorted(value)}
    if isinstance(value, list):
        return [_schema(item) for item in value]
    return type(value).__name__


@pytest.fixture
def isolated_env(monkeypatch):
    monkeypatch.setenv("REPORT_GLOB", "*.json")
    monkeypatch.setenv("REPORT_LABEL", "batch")
    return FIXTURE_ROOT


def test_output_schema_is_stable(isolated_env):
    LEDGER_DIR.mkdir(exist_ok=True)
    ledger = LEDGER_DIR / "report_schema.json"
    observed = _schema(build_report(str(isolated_env)))
    if not ledger.exists():
        ledger.write_text(json.dumps(observed, indent=2) + "\n", encoding="utf-8")
        pytest.skip("wrote first schema ledger")
    expected = json.loads(ledger.read_text(encoding="utf-8"))
    assert observed == expected


def test_ids_and_opened_match_ledger(isolated_env):
    LEDGER_DIR.mkdir(exist_ok=True)
    ledger = LEDGER_DIR / "report_ids.json"
    report = build_report(str(isolated_env))
    slice_ = {
        "ids": report["ids"],
        "opened": report["opened"],
        "count": report["count"],
    }
    if not ledger.exists():
        ledger.write_text(json.dumps(slice_, indent=2) + "\n", encoding="utf-8")
        pytest.skip("wrote first id ledger")
    expected = json.loads(ledger.read_text(encoding="utf-8"))
    assert slice_ == expected
Enter fullscreen mode Exit fullscreen mode

The first run writes both ledger JSON files.
The second run compares fresh output against them.
A later extract must keep both files green.

4. Run the harness twice

python -m pytest test_report_char.py -q
python -m pytest test_report_char.py -q
Enter fullscreen mode Exit fullscreen mode

The first command should skip the two tests.
The second command should pass both tests cleanly.
If the second run fails, your fixtures drifted.

Commit the ledgers directory with the unchanged module.
That commit is the characterization baseline you protect.

5. Extract one key builder

Do not move I/O in this first change.
Do not rename the public build_report function yet.
Extract only the identifier builder from the loop.

def stable_id(label: str, path: str) -> str:
    stem = Path(path).stem
    return f"{label}:{stem}"
Enter fullscreen mode Exit fullscreen mode

Replace the inline f-string with that helper call.
Keep globbing and open inside build_report for now.

Re-run the same two pytest commands after extracting.
Both ledgers must still match byte for byte.

If a ledger breaks, revert the helper immediately.
The helper is wrong, not the characterization test.

How to read a red ledger

Open the JSON diff before you edit production code.
A changed ids list means the helper reshaped keys.
A changed opened list means I/O moved by accident.

A type change from int to str is a break.
Callers that do arithmetic will fail after deploy.
Treat that as a revert, not a cleanup.

Do not rewrite the ledger to match a new patch.
The ledger is the contract you chose to keep.
Update it only with an explicit behavior ticket.

Expanding the corpus later

Add one fixture file when a new caller appears.
Recompute fixtures.sha256 before you rerun the pytest harness.
Expect the id ledger to change in that case.

Commit the new fixture, hash, and ledger together.
Never mix a helper extract with a corpus expansion.
Two reasons for a diff make blame impossible.

Keep the corpus under a dozen files at first.
Large trees hide the first broken key.
Small trees make schema diffs readable in review.

Decision table

Observed signal Required action
keys and types stay equal keep the extract
id list changes revert the helper
opened paths change undo the I/O move
import scan grows you leaked a surface
fixture hashes change restore the tree first
skip count on second run ledgers were not committed

Use the table as a merge gate.
Do not negotiate with a red ledger.

Where a free coding model fits

A model can draft stable_id after the ledger exists.
It should not invent a new report shape.

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

MonkeyCode offers free model access and a free server option.
Use the models only against a green harness.
Use the server only if you need a remote pytest runner.

Paste the module, the tests, and the ledgers.
Ask for one helper extract and nothing larger.
Reject any patch that edits the ledgers directory.

Do not send secrets or production data dumps.
The fixture tree above is enough model input.

Limitations

Characterization freezes bugs as if they were contracts.
If today's identifiers are wrong, you keep them.
Schedule a separate and explicit behavior change later.

The harness ignores concurrency and filesystem races completely.
It also ignores permission errors on unreadable files.
It ignores JSON documents that are not UTF-8.

Schema equality is not semantic equality for nested data.
Two dicts can share types and still contain lies.
Add value ledgers only for tiny, hashed fixtures.

Who should skip this

Skip this if you have no fixture corpus yet.
Skip this if callers remain unknown after the scan.
Skip this if the module is a security boundary.

Do not use it to justify a full rewrite.
Do not use it as cover for deleting tests.
Do not use it on generated protocol buffer modules.

Teams without pytest should pick another test runner.
The method does not require pytest as such.
It requires a committed and replayable behavior ledger.

Close

Pin the output schema on a hashed fixture tree.
Extract one pure helper after the second pytest pass.
Leave I/O and public names in place until then.

Commit the ledger before you touch production code.
Review the helper as if it were already shipped.

Top comments (0)