Do not extract a helper from a dirty import. Module load already mutates env, cache, and files. Characterization tests must pin that load first.
A clean extract can still change process startup. Tests that only call the new helper miss it. Import-time I/O is part of the contract.
The failure this workflow targets
Many messy repos hide work in module scope. Path probes often run before main exists. Caches fill before any test calls a function.
AI diffs often look local and safe. They move a path join into a helper. They also shift when os.environ is read.
That timing shift is a real behavior change. Downstream scripts still import the module for constants. Those scripts now miss a directory check.
Freeze five observables
Record these five facts before any extract.
- Record environment keys that import actually reads.
- Record paths the import probes, reads, or creates.
- Record module globals that behave like caches.
- Record warnings and stderr lines emitted on load.
- Record public names visible through
dir(module).
Skip call-result shapes during this import pass. Other tests can pin those call shapes later. This pass locks import-time state and nothing else.
Artifact: a messy inventory module
Treat the next file as a teaching stand-in only. Label the sample as an unexecuted teaching example. Do not paste it into production as-is.
# inventory_legacy.py
from __future__ import annotations
import os
import warnings
from pathlib import Path
_DATA_ROOT = os.environ.get("DATA_ROOT", "/var/inventory")
_ROOT_PATH = Path(_DATA_ROOT)
_CACHE: dict[str, int | str] = {}
if not _ROOT_PATH.exists():
warnings.warn(f"missing data root: {_ROOT_PATH}", RuntimeWarning)
_CACHE["root_missing"] = 1
else:
_CACHE["root_missing"] = 0
_CACHE["root"] = str(_ROOT_PATH)
def sku_count(name: str) -> int:
key = name.strip().lower()
if key in _CACHE and isinstance(_CACHE[key], int) and key not in {"root_missing"}:
return int(_CACHE[key])
path = _ROOT_PATH / f"{key}.txt"
if not path.is_file():
_CACHE[key] = 0
return 0
_CACHE[key] = sum(1 for _ in path.open())
return int(_CACHE[key])
The module reads environment keys at import time. It probes a filesystem path at import. It mutates a process-wide cache at import.
sku_count looks like the obvious extract target. Import state is the real coupling here. Moving the helper without a snapshot is unsafe.
Step 1: Capture an import snapshot
Run the import inside a controlled child process. Isolate environment, cwd, and warning filters first. Write JSON that later tests can hash.
The listing below stays in-process for readability. Prefer subprocess on modules that start threads. A later section shows that wrapper pattern.
# snapshot_import.py
from __future__ import annotations
import hashlib
import importlib
import io
import json
import os
import sys
import warnings
from pathlib import Path
def capture(module_name: str, env: dict[str, str], cwd: str) -> dict:
old_env = os.environ.copy()
old_cwd = os.getcwd()
stderr = io.StringIO()
try:
os.environ.clear()
os.environ.update(env)
os.chdir(cwd)
sys.modules.pop(module_name, None)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
old_stderr = sys.stderr
sys.stderr = stderr
mod = importlib.import_module(module_name)
sys.stderr = old_stderr
public = sorted(n for n in dir(mod) if not n.startswith("_"))
cache = dict(getattr(mod, "_CACHE", {}))
return {
"env_keys_present": sorted(env.keys()),
"cwd": cwd,
"public_names": public,
"cache": cache,
"warnings": [f"{w.category.__name__}:{w.message}" for w in caught],
"stderr": stderr.getvalue(),
}
finally:
os.environ.clear()
os.environ.update(old_env)
os.chdir(old_cwd)
def dump(path: Path, payload: dict) -> str:
text = json.dumps(payload, sort_keys=True, indent=2)
digest = hashlib.sha256(text.encode()).hexdigest()
path.write_text(text + "\n", encoding="utf-8")
return digest
if __name__ == "__main__":
root = Path("tmp_inventory")
root.mkdir(exist_ok=True)
payload = capture(
"inventory_legacy",
{
"DATA_ROOT": str(root.resolve()),
"PATH": os.environ.get("PATH", ""),
},
str(Path.cwd()),
)
digest = dump(Path("import_snapshot.json"), payload)
print(digest)
Run the script twice before you edit code. Compare the SHA-256 digest on both runs. A moving digest means the snapshot is not stable.
python snapshot_import.py
python snapshot_import.py
If the digest drifts, stop the extract. Find the hidden clock, network, or random seed. Pin those hidden inputs, then recapture the snapshot.
Step 2: Turn the snapshot into tests
Load the JSON as a frozen oracle. Assert import results against that frozen oracle. Do not assert a guessed ideal behavior.
# test_import_contract.py
from __future__ import annotations
import json
import os
import unittest
from pathlib import Path
from snapshot_import import capture
SNAPSHOT = json.loads(Path("import_snapshot.json").read_text(encoding="utf-8"))
class ImportContractTest(unittest.TestCase):
def test_import_matches_snapshot(self) -> None:
root = Path("tmp_inventory")
payload = capture(
"inventory_legacy",
{
"DATA_ROOT": str(root.resolve()),
"PATH": os.environ.get("PATH", ""),
},
str(Path.cwd()),
)
self.assertEqual(payload["public_names"], SNAPSHOT["public_names"])
self.assertEqual(payload["cache"], SNAPSHOT["cache"])
self.assertEqual(payload["warnings"], SNAPSHOT["warnings"])
self.assertEqual(payload["stderr"], SNAPSHOT["stderr"])
if __name__ == "__main__":
unittest.main()
These tests fail when import behavior regresses. They will not fail on later call bugs. Keep a second suite for sku_count later.
Step 3: Score each candidate change
Use this table before you accept a patch.
| Candidate change | Import snapshot stays equal | Safe in this pass |
|---|---|---|
| Extract a pure string normalizer | Yes | Yes |
| Rename a private local variable | Yes | Yes |
Delay DATA_ROOT read until call time |
No | No |
| Create the data root on import | No | No |
| Add a new exported name | No | No |
Seed _CACHE with extra keys |
No | No |
Reject any patch that moves env reads. Reject any patch that adds file writes. Reject any patch that changes public dir(module) names.
Accept only patches that keep the JSON equal. Equality is the gate, not review taste. Taste is how bugs escape this stage.
Step 4: Make the smallest safe extract
Extract one pure function and nothing else. Leave the environment reads at module scope still. Leave the cache mutations at module scope.
def normalize_sku(name: str) -> str:
return name.strip().lower()
Then change one line inside sku_count only. Keep _DATA_ROOT and _CACHE fully untouched.
def sku_count(name: str) -> int:
key = normalize_sku(name)
if key in _CACHE and isinstance(_CACHE[key], int) and key not in {"root_missing"}:
return int(_CACHE[key])
path = _ROOT_PATH / f"{key}.txt"
if not path.is_file():
_CACHE[key] = 0
return 0
_CACHE[key] = sum(1 for _ in path.open())
return int(_CACHE[key])
Re-run the snapshot test after the extract. Re-run the digest command after the extract. Stop the work if either result moves.
python -m unittest test_import_contract.py
python snapshot_import.py
That extract is the whole change set. Do not chain a second extract in this commit. Do not relocate _CACHE in the same commit.
False positives in the digest
A digest can move for boring reasons. Python writes __pycache__ files during import. Another test may have filled _CACHE.
Reset module caches between every capture run. Delete .pyc files before each capture. Use a fresh temp directory every job.
find . -name '__pycache__' -type d -prune -exec rm -rf {} +
python snapshot_import.py
Path.resolve() can change across symlink hosts. Record the logical path when hosts differ. Do not compare resolved paths across machines.
Optional subprocess wrapper
In-process env swaps can leak into extensions. A child process gives a cleaner boundary. Treat this wrapper as an unexecuted proposal.
# run_snapshot_job.py — proposal, not a measured harness
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
def run_job() -> str:
proc = subprocess.run(
[sys.executable, "snapshot_import.py"],
check=True,
capture_output=True,
text=True,
cwd=str(Path.cwd()),
)
digest = proc.stdout.strip()
Path("import_snapshot.sha256").write_text(digest + "\n", encoding="utf-8")
return digest
Pass the same env mapping into the child. Fail the job on a non-zero exit. Store stdout digest next to the JSON file.
Where a free coding model helps
A model can draft asserts from JSON. It cannot decide which side effects are required. You still own the snapshot gate after generation.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode provides free model access and a free server option. The model can turn import_snapshot.json into extra unittest methods. The free server option can run the isolated import job. Use it when your laptop environment is noisy.
Feed the model the snapshot JSON only. Do not feed it the intended redesign sketch. Ask it to emit assertions, not a new architecture.
Review every generated assert against the snapshot. Delete generated asserts that check call results. Keep only asserts that check import-time fields.
Limitations
This harness does not freeze thread timing at all. It also does not freeze outbound network calls. It does not freeze the process wall clock.
The snapshot also freezes current production bugs. A missing data root stays missing on purpose. Do not treat the snapshot as a product spec.
Child-process isolation is stronger than in-process tricks. The sample uses in-process env swaps for brevity. Use subprocess if the module starts threads.
Clearing os.environ can break extension imports. Keep PATH and other required library roots. Record those keys in the snapshot too.
Who should not use this
Skip this if the module has no import side effects. Skip this if you intend to change startup behavior. Skip this if you cannot run tests at all.
Also skip this workflow for one-off notebooks. Import contracts rarely matter in that setting. Spend the time on data checks instead.
What to do next
Pick one messy file with module-level work. Capture two identical import snapshots before edits. Extract one pure function only after that.
Keep the snapshot file in the pull request. Reviewers can diff JSON instead of vague vibes. The next extract waits for a green digest.
Top comments (0)