Dakota HuangDo not extract a mapper from a messy function yet. Pin nested return keys and exception classes...
Do not extract a mapper from a messy function yet. Pin nested return keys and exception classes first. A later extract is safe only against that frozen shape.
Coding models fail here for a structural reason. They rename keys that silent callers still index. The shape pin rejects that rename before merge.
Legacy Python services often return nested dicts from mixed layers. One function may merge ORM rows and HTTP JSON. Callers then index keys that never appear in types.
A local cleanup extract can still ship production breakage. Missing keys surface in another process hours later. Shape pins belong beside the messy entrypoint, not after rewrite.
This workflow does not score or rank coding models. It only records a contract you can rerun. The contract is nested keys, sequence lengths, and exception classes.
Freeze structure on every characterization run you add. Ignore most scalar values on purpose during pinning. Values drift with clocks, ids, and vendor payloads.
Freeze these four surfaces, and only these:
Do not freeze wall-clock timestamps as golden strings. Do not freeze set iteration order as a list. Do not freeze money floats as exact decimal text.
The helper below is labeled test utility code. Paste that helper into tests/shape_pin.py for this workflow. Production packages should not import this helper at all.
# tests/shape_pin.py
from __future__ import annotations
import json
from typing import Any
MAX_DEPTH = 8
HEAD_N = 5
def shape_of(value: Any, *, depth: int = 0) -> Any:
if depth > MAX_DEPTH:
return {"$max_depth": True}
if value is None or isinstance(value, (bool, int, str)):
return type(value).__name__
if isinstance(value, float):
return "float"
if isinstance(value, bytes):
return "bytes"
if isinstance(value, dict):
items = sorted(value.items(), key=lambda kv: str(kv[0]))
return {
str(key): shape_of(item, depth=depth + 1)
for key, item in items
}
if isinstance(value, (list, tuple)):
head = [shape_of(item, depth=depth + 1) for item in value[:HEAD_N]]
return {
"$seq": type(value).__name__,
"$n": len(value),
"$head": head,
}
if hasattr(value, "__dict__") and not isinstance(value, type):
return {
"$type": type(value).__name__,
"$fields": shape_of(vars(value), depth=depth + 1),
}
return {"$type": type(value).__name__}
def dump_shape(value: Any) -> str:
return json.dumps(shape_of(value), indent=2, sort_keys=True)
Keep pin generation out of runtime request paths. A test helper is not a schema library. Treat output as a reviewable snapshot, not an API.
Add one characterization test module per messy entrypoint. Do not open a rewrite branch first. Record the current shape, then assert on the next run.
# tests/test_build_report_shape.py
from __future__ import annotations
import json
import os
from pathlib import Path
import pytest
from shape_pin import dump_shape
from messy_report import build_report # stand-in; swap for the real entrypoint
PIN_DIR = Path(__file__).with_name("pins")
SHAPE_PIN = PIN_DIR / "build_report.shape.json"
ERROR_PIN = PIN_DIR / "build_report.errors.json"
def _write_if_missing(path: Path, payload: object) -> None:
if path.exists():
return
if os.environ.get("CI"):
raise AssertionError(f"missing pin: {path}")
path.parent.mkdir(parents=True, exist_ok=True)
text = json.dumps(payload, indent=2, sort_keys=True) + "\n"
path.write_text(text, encoding="utf-8")
def test_build_report_return_shape() -> None:
result = build_report(user_id=1, include_meta=True)
actual = json.loads(dump_shape(result))
_write_if_missing(SHAPE_PIN, actual)
expected = json.loads(SHAPE_PIN.read_text(encoding="utf-8"))
assert actual == expected
def test_build_report_error_classes() -> None:
cases = [
{"user_id": -1, "include_meta": True},
{"user_id": 1, "include_meta": "yes"},
{"user_id": None, "include_meta": False},
]
observed: list[dict[str, str]] = []
for kwargs in cases:
try:
build_report(**kwargs)
observed.append({"kwargs": repr(kwargs), "exc": ""})
except Exception as exc: # characterization only; not a swallow
observed.append(
{
"kwargs": repr(kwargs),
"exc": type(exc).__name__,
}
)
_write_if_missing(ERROR_PIN, observed)
expected = json.loads(ERROR_PIN.read_text(encoding="utf-8"))
assert observed == expected
The first local run writes pins and fails. The second run asserts JSON equality and nothing else. Commit both pin files with the tests.
The module below is a labeled example only. Replace it with your real entrypoint before a production extract. It exists so the tests above can run.
# messy_report.py — stand-in legacy entrypoint, not production code
from __future__ import annotations
from typing import Any
def build_report(user_id: Any, include_meta: Any) -> dict[str, Any]:
if user_id is None or user_id < 0:
raise ValueError("user_id")
if not isinstance(include_meta, bool):
raise TypeError("include_meta")
payload = {
"userId": user_id,
"items": [{"sku": "x", "qty": 1}],
"totals": {"count": 1, "currency": "USD"},
}
if include_meta:
payload["meta"] = {"source": "legacy", "version": 3}
return payload
Run the module from the tests directory with PYTHONPATH set. Confirm the pin JSON shows userId, not user_id. That camelCase key is the whole point.
Follow this order without skipping any step. A skipped pin makes the extract unearned.
Pick one public function, not a whole package. Write that import path in the test module. Leave private helpers untouched during this whole pass.
Replace HTTP and database with in-process fakes. Characterization still needs a real return object in memory. Network calls make pin files flaky and unreviewable.
Run the two tests once on your machine. Confirm the JSON pin files appear under tests/pins. Delete keys you refuse to freeze, then rerun.
PYTHONPATH=. pytest tests/test_build_report_shape.py -q
ls tests/pins
Create a branch that contains tests only. Reviewers should see the contract without a rewrite. Merge that commit, or keep it first on the branch.
Move the dict-shaping block into one function. Keep every key string identical in this change. Do not convert userId into user_id here.
pytest tests/test_build_report_shape.py -q
git diff --stat
Pin tests must stay green after the extract. The production diff must stay small on purpose. Revert extra modules listed by git diff --stat.
A model is optional once the pin exists. It is not a substitute for that pin. Give it the pin JSON and one function only.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode offers free model access and a free server option. Either option can draft the mapper against the pin. Neither option replaces characterization tests or key-name review.
Feed the model the pin and the single messy function. Reject diffs that change keys, exception classes, or extra files.
| Observation after extract | Action |
|---|---|
| Shape pin green, one new function | Keep the change |
| Shape pin green, renamed keys | Revert the rename |
| Shape pin red, missing nested key | Revert and restore the key |
| Error pin red, new exception class | Revert and keep the old class |
| Error pin red, same class, new message | Decide separately; messages are unpinned |
| Diff touches logging or I/O | Stop; this is not a mapper extract |
| Pin rewritten to match the model | Reject it as test deletion |
Use the table during the extract review. Do not argue from naming taste in review.
git status --short
git add tests/shape_pin.py tests/test_build_report_shape.py tests/pins
git commit -m "test: pin build_report return shape and error classes"
pytest tests/test_build_report_shape.py -q
git add -p
git diff --cached --stat
git add -p is part of the method. Staging hunks blocks drive-by cleanup from landing. Unstaged cleanup does not belong on this branch.
A second commit should contain the mapper extract only. Import lines for that mapper may move. No adjacent dead-code deletion belongs here.
This pin does not prove full behavioral equivalence. Two dicts can share keys and change meaning. A renamed nested value can keep the same type name.
Sequence pins use length and a short head. Later elements can change shape without a failure. Raise HEAD_N when the list itself is the contract.
Object pins call vars() and miss slots. Properties and C-extension types will under-report fields. Add explicit adapters before you trust those objects.
Exception pins record class names, not error messages. A ValueError can change text and pass. Pin messages only when callers parse them.
The write-if-missing path is a CI footgun. CI jobs must not create pins automatically. Gate pin writes with an environment check shown above.
Homogeneous list heads can hide a mixed tail. If index 6 carries extra keys, this pin stays green. Split those rows into a dedicated fixture when that tail matters.
Skip this on greenfield modules with real unit tests. Those tests already name behavior in executable form. A shape pin would only duplicate existing assertions.
Skip this workflow on cryptographic or auth boundaries. Key presence is not a security proof. Hashing and canonicalization still need their own dedicated review.
Skip this when the extract must change the contract. A contract migration needs explicit dual-read tests first. A shape pin will block that work correctly.
Do not outsource pin review to any model. Models often propose extra keys that look consistent. Callers in other languages will not share that taste.
The production diff should add one function and one call. Import lines may move with that function. Comments may move with that function as well.
If the mapper needs a second helper, stop immediately. Land the first extract as its own commit. Open a second branch only after pins stay green.
Shape drift after extract means it was a rewrite. Restore the original keys without a naming debate. Cleaner names wait for a later, explicit migration.
Count files in git diff --stat before you open review. One test module, pin JSON, and one production file is the budget. A fourth production file means the extract grew.
Pin nested return keys before you extract a mapper. Pin exception class names on the same entrypoint. Keep the production diff to one function.
Review every extract as a contract change first. Ugly keys that callers index still win. The pin file is the review checklist.