DEV Community

Dakota Huang
Dakota Huang

Posted on

Pin In-Place Mutations Before One Pure Extract

Pin in-place mutation before any extract. Return-only goldens hide alias bugs. Freeze four ledger fields first. Then lift one pure helper.

This workflow targets messy Python services. It does not rewrite the module. It records mutation, then changes one seam.

Why return-only pins fail

Many helpers mutate a dict argument. They also return that same object. Callers keep both names alive. A later write hits every alias.

Characterization tests often assert equality only. Equality cannot see shared identity. A later "pure" extract copies the dict. Hidden aliases then stop updating. Production breaks without a red test.

The failure is mechanical, not stylistic. Object identity is a behavior. Mutation diffs are also behavior. Both belong in the golden file.

Four fields in the ledger

Record a snapshot before the call. Record the return payload after the call. Record whether input is return. Record a key-level mutation diff.

Identity true means a shared object. Diff nonempty means real mutation. Both facts must stay stable. Only then consider an extract.

Empty diffs mean a different seam. True identity plus a copy extract is unsafe. False identity plus a matching tree is already safer. Read those flags before editing.

Artifact: a mutation ledger

The harness below is a proposed example. It is not from a production run. It uses copy.deepcopy and JSON. Adjust types to your helper.

# tools/mutation_ledger.py
from __future__ import annotations

import copy
import json
from pathlib import Path
from typing import Any, Callable


def _jsonable(value: Any) -> Any:
    return json.loads(json.dumps(value, sort_keys=True, default=str))


def _flat_diff(before: dict, after: dict) -> dict[str, dict]:
    keys = set(before) | set(after)
    out: dict[str, dict] = {}
    for key in sorted(keys):
        if before.get(key) != after.get(key):
            out[str(key)] = {
                "before": _jsonable(before.get(key, "<missing>")),
                "after": _jsonable(after.get(key, "<missing>")),
            }
    return out


def capture(fn: Callable, payload: dict) -> dict:
    snapshot = copy.deepcopy(payload)
    inbound = copy.deepcopy(payload)
    result = fn(inbound)
    identity = inbound is result
    return {
        "snapshot": _jsonable(snapshot),
        "returned": _jsonable(result),
        "identity": identity,
        "diff": _flat_diff(snapshot, inbound),
    }


def write_golden(path: Path, rows: list[dict]) -> None:
    path.write_text(json.dumps(rows, indent=2, sort_keys=True) + "\n")
Enter fullscreen mode Exit fullscreen mode
# tests/test_mutation_ledger.py
import json
from pathlib import Path

from tools.mutation_ledger import capture

# Proposed stand-in for a messy billing helper.
def apply_discount(cart: dict) -> dict:
    rate = 0.1 if cart.get("coupon") == "SAVE10" else 0.0
    cart["discount"] = round(cart["subtotal"] * rate, 2)
    cart["total"] = round(cart["subtotal"] - cart["discount"], 2)
    return cart


FIXTURES = [
    {"subtotal": 40.0, "coupon": None},
    {"subtotal": 40.0, "coupon": "SAVE10"},
    {"subtotal": 0.01, "coupon": "SAVE10"},
]

GOLDEN = Path("goldens/apply_discount.json")


def test_ledger_matches_golden():
    rows = [capture(apply_discount, row) for row in FIXTURES]
    assert GOLDEN.exists(), "commit goldens before any extract"
    expected = json.loads(GOLDEN.read_text())
    assert rows == expected
Enter fullscreen mode Exit fullscreen mode

Run the capture once on a clean tree. Save the JSON goldens. Commit them before any extract. Later diffs then mean behavior drift.

A first golden for the coupon case looks like this. Identity stays true. The diff lists discount and total only.

{
  "snapshot": {"coupon": "SAVE10", "subtotal": 40.0},
  "returned": {"coupon": "SAVE10", "discount": 4.0, "subtotal": 40.0, "total": 36.0},
  "identity": true,
  "diff": {
    "discount": {"before": "<missing>", "after": 4.0},
    "total": {"before": "<missing>", "after": 36.0}
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 1: Pick a mutating helper

Choose a function with a dict or list argument. Prefer a helper with several callers. Skip constructors and tiny getters. One helper per change is enough.

Ignore helpers that only read fields. Ignore helpers that already copy. The ledger will tell you if you guessed wrong. Trust the identity flag over naming.

Step 2: Wrap without editing behavior

Keep the original function name public. Add capture around the live helper. Do not alter return values yet. The wrap is instrumentation only.

Leave logging, I/O, and imports untouched. Those are other pins. Mixing pins hides the mutation signal. Isolate this seam on purpose.

Step 3: Replay three real payloads

Use two typical carts. Add one ugly edge cart. Label synthetic fixtures as synthetic. Prefer structures copied from logs.

Do not generate payloads from model vibes. Three honest fixtures beat fifty guessed ones. Cover the zero-money path once. Cover the missing-key path once if it exists.

Step 4: Freeze the four fields

Write goldens as JSON files. Assert snapshot, return, identity, and diff. Fail the test if any field drifts. Keep assertion messages short.

mkdir -p goldens
python - <<'PY'
from pathlib import Path
from tests.test_mutation_ledger import FIXTURES, apply_discount
from tools.mutation_ledger import capture, write_golden
write_golden(Path("goldens/apply_discount.json"),
             [capture(apply_discount, row) for row in FIXTURES])
PY
python -m pytest tests/test_mutation_ledger.py -q
Enter fullscreen mode Exit fullscreen mode

Re-run after every edit. Wrapper goldens must stay byte-stable. New files belong only to the pure path.

Step 5: Extract one pure function

Add a new helper that copies first. Apply the same field edits to the copy. Return the new object only. Leave the old wrapper in place.

def apply_discount_pure(cart: dict) -> dict:
    out = copy.deepcopy(cart)
    rate = 0.1 if out.get("coupon") == "SAVE10" else 0.0
    out["discount"] = round(out["subtotal"] * rate, 2)
    out["total"] = round(out["subtotal"] - out["discount"], 2)
    return out


def apply_discount(cart: dict) -> dict:
    updated = apply_discount_pure(cart)
    cart.clear()
    cart.update(updated)
    return cart
Enter fullscreen mode Exit fullscreen mode

Callers that rely on mutation still work. New callers can take the pure path. That is the smallest safe change. Stop after this extract.

Step 6: Prove identity flipped on the pure path

Call the pure helper in a new test. Identity must be false there. The inbound mutation diff must be empty. The return payload must match the golden return.

def test_pure_path_breaks_identity():
    payload = {"subtotal": 40.0, "coupon": "SAVE10"}
    inbound = copy.deepcopy(payload)
    result = apply_discount_pure(inbound)
    assert inbound is not result
    assert inbound == payload
    assert result["total"] == 36.0
Enter fullscreen mode Exit fullscreen mode

If identity stays true, the extract failed. If the input still changes, copies are missing. Fix the pure helper only. Do not touch other functions.

Decision table

identity diff empty next action
true no pin, then copy-on-write extract
true yes no mutation seam; pick another helper
false no already copies; pin both trees
false yes likely pure; extract is optional

Read the table before you extract. Do not extract on empty diffs. Do not claim purity when identity is true. Do not merge rows across helpers.

Treat a changed key set as a red flag. New keys mean extra writes. Missing keys mean dropped writes. Either case blocks the extract.

Commands after the extract

python -m pytest tests/test_mutation_ledger.py tests/test_pure_path.py -q
diff -u goldens/apply_discount.json <(python -c 'import json,pathlib; from tests.test_mutation_ledger import FIXTURES, apply_discount; from tools.mutation_ledger import capture; print(json.dumps([capture(apply_discount, r) for r in FIXTURES], indent=2, sort_keys=True))')
git diff --stat -- tests/ tools/ goldens/
Enter fullscreen mode Exit fullscreen mode

The wrapper ledger must match the committed golden. The pure-path test is extra coverage. Diff stat should list one helper and tests. A wider stat means the change grew.

Remote run when the tree is dirty

Local worktrees often mix debug prints. Ledger files then pick up noise. Run the harness on a clean machine. Keep goldens off the messy branch.

MonkeyCode provides free model access and a free server option. Disclosure: This article was prepared as part of MonkeyCode's product outreach.

Use the free server to execute the ledger script. Store JSON goldens as artifacts there. Bring back only the committed files. Skip extra refactors the model suggests.

A free model can propose fixture names. Feed it ledger JSON only. Do not paste the full module. Do not accept an extract plan from it.

Human review of identity flags is required. Models miss aliasing more than equality. The ledger exists for that gap. Availability of free access can change.

Limitations

deepcopy fails on sockets, locks, and some C objects. Custom classes need a manual snapshot. Key order can churn the diff. Normalize dicts before compare.

The ledger ignores stdout and files. It ignores network and clocks. Combine other pins if those matter. This article covers mutation only.

The sample assumes CPython and pytest. It assumes JSON-serializable carts. Binary blobs need another encoder. Do not force JSON on sockets.

Nested list identity is not expanded here. Shared inner objects can still alias. Add an id() map if nests mutate. Stop if that map exceeds one helper.

Who should skip this

Skip this if helpers are already pure. Skip this if tests already check identity. Skip this for a one-line bugfix. Skip this if deepcopy cannot run.

Do not use this to justify a large rewrite. Do not extract three helpers at once. Do not rename while extracting. One pure function is the stop line.

If the helper must mutate for API reasons, keep the wrapper. Publish the pure function beside it. Move callers later, one at a time. That sequence stays reviewable.

The method is a pin, not a cleanup campaign. Green identity flags are the exit. Extra style edits are out of scope. Leave formatting for another branch.

Top comments (0)