DEV Community

Dakota Huang
Dakota Huang

Posted on

Pin Nested Return Keys Before One Mapper Extract

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.

The failure this workflow blocks

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.

What you freeze, and what you skip

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:

  1. Nested dictionary keys, sorted, walked recursively.
  2. Sequence kind, length, and a short head shape.
  3. Exception class names on known failing inputs.
  4. Top-level type names for scalars and bytes.

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.

Artifact: a JSON shape helper

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)
Enter fullscreen mode Exit fullscreen mode

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.

Artifact: two characterization tests

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
Enter fullscreen mode Exit fullscreen mode

The first local run writes pins and fails. The second run asserts JSON equality and nothing else. Commit both pin files with the tests.

Stand-in entrypoint for local reproduction

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
Enter fullscreen mode Exit fullscreen mode

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.

Numbered workflow

Follow this order without skipping any step. A skipped pin makes the extract unearned.

1. Isolate one entrypoint

Pick one public function, not a whole package. Write that import path in the test module. Leave private helpers untouched during this whole pass.

2. Build a fixture that never dials out

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.

3. Capture shape and exception names

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
Enter fullscreen mode Exit fullscreen mode

4. Land pins before any production diff

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.

5. Extract one mapper only

Move the dict-shaping block into one function. Keep every key string identical in this change. Do not convert userId into user_id here.

6. Re-run pins and inspect the stat

pytest tests/test_build_report_shape.py -q
git diff --stat
Enter fullscreen mode Exit fullscreen mode

Pin tests must stay green after the extract. The production diff must stay small on purpose. Revert extra modules listed by git diff --stat.

7. Optional model draft after the pin

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.

Decision table

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.

Commands that keep the diff small

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
Enter fullscreen mode Exit fullscreen mode

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.

Limitations

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.

Who should not use this approach

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.

What smallest safe change means here

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.

Closing

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.

Top comments (0)