Pin header aliases before extracting a CSV exporter.
Mixed dumps hide dialect drift after the cut.
Characterization tests freeze the current byte-for-byte dump.
Only then is a one-function extract actually safe.
The failure this sequence prevents
Messy report modules often build CSV text inline.
They mix banners, aliases, and dialect flags together.
A later extract can look clean during review.
The new helper then changes quoting on empty rows.
Callers still pass the same list of dicts.
The output file hash still changes anyway.
That result is not a style problem.
It is an unpinned contract on disk.
Inline writers also hide header alias drift.
Full Name may become name only sometimes.
Reviewers read the new function as obvious cleanup.
Excel users then see shifted columns and broken imports.
What you must freeze
Freeze four observables before you move any writer.
- Freeze the header alias map from keys to columns.
- Freeze delimiter, quotechar, lineterminator, and extrasaction together.
- Freeze empty-row policy, including records that are all None.
- Freeze exact UTF-8 bytes, including the trailing newline.
Do not freeze wall-clock timestamps inside the row cells.
Strip or stub those time fields before pinning bytes.
Do not freeze row order if the source is a dict.
Sort by a stable key inside the harness.
Do not assume None renders like an empty string.
Pin whatever the messy module currently emits.
Decision table
Use this table before you touch production code.
| Signal in the dump path | Extract in this commit | Wait or stop |
|---|---|---|
| Tests pin aliases, dialect, empty rows, and bytes | Yes | No |
| Banner lines share the same write function | No | Split observations first |
| Cell values include local clock or timezone text | No | Stub those fields |
| Two call sites already share one writer helper | Stop | The extract already exists |
| Review asks only for renamed locals | No | Names are not the contract |
| Output may include a UTF-8 BOM for Excel | Pin the BOM | Do not strip it yet |
Extract only when the first column reads yes.
Treat every other signal as review noise.
Artifact: a characterization harness
Label this example as a proposed local harness.
It is not a measured production benchmark of speed.
Create tests/test_characterize_csv_dump.py in your tree.
Keep the import path pointed at the messy module.
# proposed harness — unexecuted against your tree
from __future__ import annotations
import io
from pathlib import Path
from reports import messy_dump # replace with the real module
PIN_DIR = Path(__file__).parent / "pins"
PIN_DIR.mkdir(exist_ok=True)
CASES = [
{
"name": "aliases_and_empty",
"rows": [
{"Full Name": "Ada", "e-mail": "ada@example.test", "Score": "1"},
{"Full Name": None, "e-mail": None, "Score": None},
{"Full Name": "Bob", "e-mail": "", "Score": "0"},
],
},
{
"name": "comma_inside_field",
"rows": [
{"Full Name": "Lovelace, Ada", "e-mail": "a@b.test", "Score": "9"},
],
},
{
"name": "non_ascii_name",
"rows": [
{"Full Name": "Søren", "e-mail": "s@example.test", "Score": "2"},
],
},
]
def dump_bytes(rows: list[dict]) -> bytes:
buf = io.StringIO()
messy_dump(rows, buf) # current inline writer
return buf.getvalue().encode("utf-8")
def test_pin_or_compare() -> None:
for case in CASES:
pin = PIN_DIR / f"{case['name']}.csv"
got = dump_bytes(case["rows"])
if not pin.exists():
pin.write_bytes(got)
continue
assert got == pin.read_bytes(), case["name"]
Run the harness once to write the pin files.
python -m pytest tests/test_characterize_csv_dump.py -q
Commit the pin files in that same change.
Do not combine that commit with the extract.
Numbered workflow
1. Locate the inline writer
Search for csv.writer and DictWriter in one pass.
Note every keyword argument on those constructor calls.
rg -n "csv\.(writer|DictWriter)|lineterminator|extrasaction|quoting" reports
Record delimiter and quoting in a scratch note.
Do not change those arguments yet.
2. Separate banner bytes from CSV body
Title lines often include a generated report date.
Dates will thrash pin files on every run.
Pin the body separately when a banner exists.
Leave banner formatting outside the extract.
# proposed split — characterization only, not a redesign
def split_banner(text: str) -> tuple[str, str]:
lines = text.splitlines(keepends=True)
if lines and lines[0].startswith("REPORT"):
return lines[0], "".join(lines[1:])
return "", text
Keep the split inside the test helper for now.
Production code still dumps through one messy function.
3. Record header aliases as input data
Do not decode aliases from memory or comments.
Drive them through the public dump function instead.
ALIASES = {
"Full Name": "name",
"e-mail": "email",
"Score": "score",
}
If the messy code uses a different map, pin that map.
Your extract must reproduce the same column order.
Column order is part of the file contract.
Dict iteration order is not a substitute for pins.
4. Add empty-row and quoting cases
Empty rows expose None versus "" rendering.
Quoted commas expose dialect mistakes after the move.
One happy-path row is not a characterization suite.
Three cases still miss Unicode and trailing newline rules.
Add a non-ASCII name as a fourth pinned case.
Keep the pin files UTF-8 with a known newline.
If Excel clients require a leading BOM, pin the BOM.
Do not strip it during the extract commit.
5. Fail the harness on purpose
Edit quotechar on a throwaway local branch.
Confirm the pin test fails before you trust it.
python -m pytest tests/test_characterize_csv_dump.py -q
If the test still passes, the pin is blind.
Fix the harness before any production extract.
Blind pins usually compare parsed rows, not bytes.
Parsed rows hide quoting, BOM, and terminator drift.
6. Draft the extract only after green pins
A second pair of eyes can draft the extract.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode offers free model access and a free server option.
Use that draft only after characterization tests already pass.
Do not let a model edit the messy file first.
Paste the messy function and the committed pin files.
Ask for one writer function that preserves the same bytes.
Reject extra renames of public callers in that draft.
Review the draft as a diff, not as narrative trust.
If any pin fails, discard the draft completely.
7. Apply one extract
Move only the CSV body writer in this commit.
Leave banners, clocks, and I/O in the original function.
# proposed extract — apply only after pins stay green
import csv
from typing import Mapping, Sequence, TextIO
def write_score_csv(
rows: Sequence[Mapping[str, object]],
out: TextIO,
*,
aliases: Mapping[str, str],
) -> None:
fieldnames = list(aliases.values())
writer = csv.DictWriter(
out,
fieldnames=fieldnames,
extrasaction="ignore",
lineterminator="\n",
quoting=csv.QUOTE_MINIMAL,
)
writer.writeheader()
for row in rows:
mapped = {aliases[k]: row.get(k, "") for k in aliases}
if all(v in (None, "") for v in mapped.values()):
continue
writer.writerow({k: "" if v is None else v for k, v in mapped.items()})
Call this helper from the original dump path only.
Do not change flag parsing in the same commit.
Match extrasaction, terminator, and quoting to the pins.
Copy those values from the messy constructor, not from taste.
8. Re-run pins, then stop
python -m pytest tests/test_characterize_csv_dump.py -q
Green means the extract preserved the pinned bytes.
Red means you restore the function and inspect dialect kwargs.
Do not “improve” quoting while the extract is in flight.
Quoting changes belong in a later, explicit commit.
If Windows clients need \r\n, pin that terminator.
Then set lineterminator in both test and helper.
Dialect traps the pins are meant to catch
QUOTE_MINIMAL and QUOTE_ALL can parse to equal rows.
They still fail a byte comparison on commas and blanks.
extrasaction="raise" explodes on unknown dict keys.
extrasaction="ignore" drops those keys without a trace.
Empty-row skipping is often an accidental continue.
Extracting it without a pin changes downstream line counts.
Header aliases are order-sensitive on disk.
A set of names is not enough to reconstruct the file.
Limitations
This workflow does not prove semantic correctness of scores.
It only proves the dump bytes did not move.
It will not catch numeric rounding inside already-built cells.
It will not catch timezone shifts you stubbed out first.
Pin files are fixtures, not product documentation.
They rot when owners change column names on purpose.
Large binary attachments do not belong beside these pins.
CSV text does, because humans can still diff it.
Naive pins break on \r\n versus \n across machines.
Set the terminator explicitly in tests and production code.
Who should not use this
Do not use this on a greenfield CSV writer.
There is no legacy byte contract to freeze.
Do not use this when two teams own competing schemas.
Pins cannot settle a product dispute about columns.
Do not use this as a substitute for schema tests.
If a formal schema already exists, prefer that contract.
Skip model drafts when the dump can include secrets.
Keep credential rows out of any prompt or paste.
Skip the extract when one helper already writes every path.
You would only shuffle names without changing risk.
What done means
Done is a pin commit plus one extract commit.
Done is not a renamed module and a hopeful glance.
If the next change alters empty-row policy, update pins first.
Then change the writer under those new bytes.
If pins already fail on your machine, skip drafts.
A free model on a free server can propose the extract after pins pass.
Keep acceptance on the byte comparison, not the prose.
Top comments (0)