A weekly finance export that lived in one file for years invites a sudden, agent-driven split into modules. The usual failure is not a syntax error but a renamed column or a reordered CSV. Finance already wired those artifacts into spreadsheets, so a tidy diff can still break the week. Characterization for this mess is a frozen output manifest, not a unit test of a helper.
This article treats that exporter as a teaching fixture and walks a merge gate you can run locally. The gate pins clock, locale, filenames, headers, row counts, and content hashes before anyone extracts a formatter. Coding agents can propose the extract later. They should not own the first merge if the output tree can drift.
The Monday job nobody wants to open
Picture a payments repo where scripts/weekly_export.py still owns SQL, rounding, CSV writing, and a JSON summary. The file is about fourteen hundred lines, and the last six commits only appended helpers. Operators run it from cron with WEEKLY_EXPORT_DIR pointed at a shared folder. Downstream tools then glob payments_*.csv and parse header row zero as a contract.
A coding agent, asked to clean the file, often extracts three classes and renames amount to amount_usd. The Python tests still pass because they mocked the database and never opened the directory. Finance notices on Monday, when a VLOOKUP misses a column that used to sit in position four. The missing piece was not coverage of helpers. It was a byte-level picture of the tree the job emits.
What to freeze besides return values
Unit tests of pure functions are useful after the mess is already sliced. They are a weak first harness when I/O, clocks, and filenames are the product. For a batch exporter, characterize the observable directory, then allow one internal change that cannot alter that directory.
Freeze at least these fields under a pinned clock and locale:
- Relative paths of every file the job writes, including empty marker files
- SHA-256 of file bytes, so silent reordering or newline changes show up
- First line of each CSV, treated as an explicit header contract
- Row counts excluding the header, so a filter change cannot hide
- JSON keys in sorted order for the summary document, ignoring insignificant whitespace
If any field moves after a refactor, the change is not yet a cleanup. It is a behavior change and needs a product decision, not a style argument.
Teaching fixture: a tangled weekly export
The following module is a simplified stand-in, not production finance code. It mixes clock reads, locale-sensitive money formatting, and two output files on purpose. Label it as a fixture for the harness, not as advice about how to design exporters.
# weekly_export.py — teaching fixture, mixed I/O on purpose
from __future__ import annotations
import csv
import json
from datetime import datetime, timezone
from pathlib import Path
def load_rows() -> list[dict]:
# Stand-in for a SQL dump. Keep this deterministic in tests.
return [
{"account": "A-100", "cents": 1999, "status": "paid"},
{"account": "B-200", "cents": 500, "status": "paid"},
{"account": "C-300", "cents": 0, "status": "void"},
]
def run_export(out_dir: Path) -> None:
out_dir.mkdir(parents=True, exist_ok=True)
stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d")
rows = [r for r in load_rows() if r["status"] != "void"]
csv_path = out_dir / f"payments_{stamp}.csv"
with csv_path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.writer(handle)
writer.writerow(["account", "amount", "status"])
for row in rows:
amount = f"${row['cents'] / 100:.2f}"
writer.writerow([row["account"], amount, row["status"]])
summary = {
"date": stamp,
"count": len(rows),
"total_cents": sum(r["cents"] for r in rows),
}
(out_dir / "summary.json").write_text(
json.dumps(summary, indent=2) + "\n",
encoding="utf-8",
)
The amount string is the landmine. An agent that extracts formatting and switches to locale.currency will change $19.99 into a different grouping or symbol. Tests that only check len(rows) will stay green. The manifest will not.
Harness: write a manifest, then fail on drift
Pin time and locale before the job runs. Then hash the tree. The script below is a proposal you can copy into tools/characterize_export.py and run against a temporary directory.
# tools/characterize_export.py
from __future__ import annotations
import hashlib
import json
import os
import sys
from pathlib import Path
GOLDEN = Path("tests/goldens/weekly_export_manifest.json")
def file_digest(path: Path) -> str:
digest = hashlib.sha256()
digest.update(path.read_bytes())
return digest.hexdigest()
def csv_header_and_rows(path: Path) -> tuple[str, int]:
lines = path.read_text(encoding="utf-8").splitlines()
header = lines[0] if lines else ""
return header, max(len(lines) - 1, 0)
def build_manifest(out_dir: Path) -> dict:
entries = []
for path in sorted(p for p in out_dir.rglob("*") if p.is_file()):
rel = path.relative_to(out_dir).as_posix()
header, rows = (csv_header_and_rows(path) if path.suffix == ".csv" else ("", 0))
entries.append(
{
"path": rel,
"sha256": file_digest(path),
"bytes": path.stat().st_size,
"header": header,
"data_rows": rows,
}
)
return {"files": entries}
def main(argv: list[str]) -> int:
mode = argv[1] if len(argv) > 1 else "check"
out_dir = Path(os.environ["WEEKLY_EXPORT_DIR"])
current = build_manifest(out_dir)
if mode == "record":
GOLDEN.parent.mkdir(parents=True, exist_ok=True)
GOLDEN.write_text(json.dumps(current, indent=2) + "\n", encoding="utf-8")
print(f"recorded {GOLDEN}")
return 0
expected = json.loads(GOLDEN.read_text(encoding="utf-8"))
if current != expected:
sys.stderr.write("export manifest drifted\n")
sys.stderr.write(json.dumps({"expected": expected, "current": current}, indent=2))
return 1
print("export manifest matched")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
Record once against a fixture clock, then check on every refactor attempt. A small wrapper keeps the job from reading the real wall clock during characterization.
export WEEKLY_EXPORT_DIR="$(mktemp -d)"
export TZ=UTC
export LC_ALL=C.UTF-8
export PYTHONPATH=.
python - <<'PY'
from datetime import datetime, timezone
from pathlib import Path
import weekly_export
from unittest.mock import patch
frozen = datetime(2026, 9, 7, tzinfo=timezone.utc)
with patch("weekly_export.datetime") as dt:
dt.now.return_value = frozen
dt.side_effect = lambda *args, **kwargs: datetime(*args, **kwargs)
weekly_export.run_export(Path(__import__("os").environ["WEEKLY_EXPORT_DIR"]))
PY
python tools/characterize_export.py record # first capture only
python tools/characterize_export.py check
The first record is a human action. After that, agents may run check as often as they want. They should not rewrite the golden file to make a refactor pass.
Decision table: snapshot, example, or mutation
Use the table when someone asks why a directory hash is the first test, not a pytest of format_amount.
| Signal you care about | First characterization | Weak substitute | When to switch |
|---|---|---|---|
| Column names and order | CSV header line in the manifest | Asserting dict.keys() on an in-memory row |
After the writer is a pure function |
| Money text and locale | SHA-256 of the CSV bytes | Rounding unit tests with floats | After formatting is extracted and frozen |
| Which accounts are dropped |
data_rows plus hash |
A single filtered fixture list | When filters become an explicit policy module |
| Summary JSON shape | Canonical object compare after load | Snapshot of indent=2 text only |
If whitespace is declared insignificant |
| Filename stamp | Path list under a patched clock | Regex on payments_*.csv
|
Never, if finance globs the stamp |
Mutation testing of helpers is valuable later. It is the wrong first spend when the observable product is a folder of files. Example-based tests of format_amount become the second layer, once that function exists and the manifest still matches.
Smallest safe change after the tree is pinned
Do not extract a WeeklyExporter class on the first pass. Extract one pure formatter whose output is already visible in the golden CSV. For this fixture, that function is the amount string, and nothing else.
def format_amount(cents: int) -> str:
return f"${cents / 100:.2f}"
Then replace the inline f"${row['cents'] / 100:.2f}" with format_amount(row["cents"]). Re-run the frozen clock, rebuild the directory, and execute python tools/characterize_export.py check. If the manifest matches, the extract is a cleanup. If it fails, stop and treat the formatter as a behavior change.
A useful review rule is numerical, not aesthetic. Count files touched, functions extracted, and manifest fields at risk:
- One new pure function, with no filesystem access and no clock read
- One call-site replacement inside the existing writer loop
- Zero filename, header, or JSON-key edits in the same diff
- Manifest check green before the branch is eligible for review
Anything larger is a second change. Agents collapse those steps because the resulting code looks more like a tutorial. The manifest exists to reject that collapse.
Where a free agent run helps, and where it does not
Generating the first format_amount extract is cheap model work. Generating a stable clock wrapper and a manifest checker is also cheap, if a human reviews the golden file. Running those commands on a disposable machine keeps local locale settings from leaking into hashes.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access and free server option can host that disposable run: the agent drafts the one-function extract, executes the pinned-clock export, and reports the manifest diff without writing into a laptop working tree. The merge gate stays the same JSON file in tests/goldens/. The product does not replace the record step, and this article does not claim model names, quotas, hardware, or lasting availability.
If you already isolate agent jobs this way, keep the golden manifest in git and treat any record rewrite as a product review. The interesting output is the diff of weekly_export_manifest.json, not a score the model assigns itself.
Limitations
Directory hashing is brittle when exporters embed timestamps inside rows, not only in filenames. You must stub those clocks, or the manifest will flap every run and teams will start ignoring it. Binary artifacts such as PDFs and XLSX files also hash-unstable if a library embeds write times; prefer structured exports or a parsed logical snapshot for those formats.
The harness also assumes a hermetic fixture for load_rows(). A characterization run that hits shared staging data will snapshot whoever happened to pay that afternoon. Do not record goldens from production folders, and do not let an agent refresh goldens as a way to green a refactor.
Who should not use this first
Skip the manifest approach when the batch job has no stable consumers of filenames or headers. An internal debug dump that nobody parses can be covered with ordinary unit tests. Skip it when the legitimate change is a schema migration that finance already scheduled; in that case update the golden under review, then extract. Skip it when you cannot pin time, locale, and input rows, because a flapping hash trains people to delete the gate.
Teams that need many coordinated extracts should still start here, then add helper tests. The first merge remains one formatter, one call site, and an unchanged export tree. That sequencing is slower than an overnight rewrite, and it is the reason Monday spreadsheets keep resolving.
Top comments (0)