Extract one mutator only after tests pin object identity. Value equality alone hides alias bugs in shared containers. A copied list can match every item and still break a later caller.
That constraint fits a messy module with shared lists and dicts. The smallest safe change is one leaf extract with stable ids. Wider splits wait until those identity contracts stay green.
Why value checks miss the bug
Many helpers mutate a list that another function still holds. One path sorts that list during a report build. A later path expects the caller's original order to remain.
A value assertion can pass on the returned rows alone. The caller then reads the same object and sees a new order. The failure is an identity change, not a wrong aggregate.
Scope of this pin
This note covers in-place container identity and nothing else. It does not cover clocks, argv arrays, env diffs, or stream bytes. Those other pins answer different risks during an extract.
Pin three facts at each public entry point you still call. Capture object ids for each mutable argument before the call. Capture whether those same ids still match after return.
Also capture the set of changed mapping keys. Capture whether the return value is a new object. Leave file paths and process status codes out of this harness.
Decision table for one leaf
Use the table below before any code move. A leaf may be extracted only when its row says pass. A fail row means you keep that code in place.
| Observed leaf behavior | Identity result | Smallest safe move |
|---|---|---|
| Reads inputs and returns a new list | Input ids unchanged | Extract that leaf alone |
| Sorts a caller list in place | Same id, new order | Keep it and pin the order |
| Copies a dict, then updates the copy | Input id unchanged | Extract and assert a new id |
| Writes through a nested dict alias | Nested id changed | Do not extract until alias is named |
| Rebinds a local name only | Caller id unchanged | Extract; the rebind stays local |
| Replaces one list element object | Same list id, new item id | Extract only if item ids are pinned |
The table is a review proposal, not a measured benchmark. It is not a product score and not a quota claim. Your repository rows may differ after the first local run.
Step 1: Name one candidate leaf
Pick the smallest function that touches one container. Prefer a leaf with no further calls into the same module. Reject a candidate that opens files or starts processes.
Write the current name and line range in a short note. State each mutable argument on one following line. Stop if you cannot name a single leaf yet.
Step 2: Record ids around the entry point
Wrap the public function with a thin local probe. Store the id of each mutable argument before the call. Store the id of each argument again after return.
Compare those pairs inside the test, not in production logs. Fail the test when a pinned id changes unexpectedly. Fail it when a new key appears in a watched dict.
Step 3: Add a local probe module
Keep the probe outside the messy production module. Pass the entry point in as a plain callable. Do not import the probe from production code paths.
The sample below is an unexecuted local proposal. Run it only inside a disposable checkout you can delete. Adapt every name to your module before you trust a result.
"""Proposal harness for container identity. Not executed in this article."""
def snapshot(args):
rows = []
for arg in args:
if isinstance(arg, dict):
keys = tuple(sorted(repr(key) for key in arg))
rows.append(("dict", id(arg), keys))
elif isinstance(arg, list):
rows.append(("list", id(arg), len(arg)))
else:
rows.append(("other", id(arg), type(arg).__name__))
return tuple(rows)
def changed_keys(before, after):
lost = set(before) - set(after)
gained = set(after) - set(before)
shared = before.keys() & after.keys()
edited = {key for key in shared if before[key] != after[key]}
names = (repr(key) for key in lost | gained | edited)
return tuple(sorted(names))
def pin_call(func, args, dict_index):
before = snapshot(args)
watched = args[dict_index]
keys_before = dict(watched)
result = func(*args)
after = snapshot(args)
same_ids = all(before[i][1] == after[i][1] for i in range(len(args)))
return {
"same_ids": same_ids,
"changed_keys": changed_keys(keys_before, watched),
"result_id": id(result),
"result_is_arg": any(result is arg for arg in args),
}
Step 4: Score the leaf with fixed rules
Treat the probe output as data, not as a hunch. Allow an extract only when the same ids flag stays true. Require the result object to differ from every input object.
If changed keys are non-empty, keep the mutator in place. Name that mutation in the test before any move. Extract only after the test expects those exact keys.
The return shape below is a schema example, not a captured run. Use it to name fields before you write assertions. Replace the placeholder id when you run the probe locally.
# Schema example only. Not a captured run from any repository.
{
"same_ids": True,
"changed_keys": ("status",),
"result_id": 0,
"result_is_arg": False,
}
Step 5: Apply the smallest edit
Move one passing leaf into a new function body. Keep the old name as a one-line wrapper call. Do not rename callers in that same change.
Preserve argument order and every existing default value. Do not clean up nearby branches in the same patch. A second edit hides which line broke object identity.
Step 6: Re-run the same probe
Run the probe on the wrapper after the move. Compare the same ids flag, changed keys, and result aliasing. Accept the change only when those three fields match.
If any field flips, revert the extract immediately. Add a tighter pin for the field that moved. Retry with a smaller leaf, or stop the split.
Commands to run locally
Use the test runner your repository already trusts. The commands below are a pattern, not a timed result. They do not claim a pass rate or a duration.
python -m pytest tests/test_identity_pin.py -q --tb=short
python -m compileall -q src
Review the failure list before you edit production code. A red identity pin is a hard stop sign. Do not silence it with a broader value assertion.
Where a draft assistant can sit
Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode provides free model access and a free server option. Those two availability facts are the only product claims here.
Model names, quotas, hardware, and duration stay out of scope. A free model can draft probe comments and empty table rows. Keep that draft off the repository when a free server is available.
Keep the repository and the test run on your machine. Do not let the draft choose which leaf to extract. Alias rules are easy for a generated draft to miss.
The score function remains the only merge gate. If a second table draft would help, use the free server option there. Paste probe output only, and do not paste secrets or private paths.
Merge nothing until the local re-run matches every pin. A generated row is a suggestion, not a passing characterization. Your local probe output remains the record you keep.
Limits of the probe
An object id is meaningful only during that object's life. A collected object can have its id reused later. Compare ids inside one call, not across separate process runs.
The probe misses mutations that happen inside C extensions. It misses memory changes made through ctypes views. It misses list edits that keep length and equal values.
Nested containers need their own separate identity snapshots. A shallow key check ignores inner list order. Add a nested walk only for the leaf you plan to move.
This method assumes a single thread during the probe. Another thread can mutate a container between the two snapshots. Do not use these pins to bless a concurrent extract.
Who should not use this
Skip the method when every function already returns new objects. You would spend time pinning a contract you do not break. A smaller diff review is enough in that pure module.
Skip it when you cannot call the entry point in a test. A probe that never runs is not evidence of safety. Build a caller harness first, then return to identity pins.
Skip it when fresh objects are the intended contract. Caches, pools, and factories mint new ids on purpose. Pinning stable ids there would freeze the wrong rule.
Skip it for permission checks and other security boundaries. Identity pins do not prove authorization behavior at all. Use dedicated tests for those sensitive paths instead.
Close
Start from the identity conclusion, not from a broad rewrite. Pin object ids, changed keys, and result aliasing first. Extract one passing leaf, then re-pin those same fields.
Leave every other cleanup for a later, separate change. The decision table tells you when to stop moving code. A green value test is not permission to move a mutator.
Top comments (0)