Helper extracts fail at import time more often than call time. Snapshot cwd, env, and sys.path before any edit. Then change one helper under that freeze.
Messy modules often execute real work during import. They mutate process globals long before main starts. A green unit test can still hide that drift.
The failure mode this harness targets
A simple extract moves one function to another file. Import order then changes under the test runner. Relative files resolve against a new working directory.
Environment flags get read twice after the move. sys.path gains a duplicate project root entry. Return-value tests will not catch this class.
This is not a style lecture about imports. It is a characterization gate for messy repos. Current mutations stay current until you choose otherwise.
Four fields to freeze
Record four process fields before any production edit. Store that record next to the module tests.
- Current working directory as an absolute resolved path.
- A pinned allowlist of environment keys only.
- sys.path entries that point inside the repo.
- Files created under a temporary sandbox directory.
Do not freeze the entire os.environ mapping. That couples tests to one developer laptop. Pin only keys the messy module actually reads.
Name the keys in one shared tuple. Treat unknown keys as out of scope. Expand the tuple when a test proves a new read.
Artifact: process snapshot harness
The harness below is a labeled example. Adapt module names to your own tree. It does not claim any production metrics.
# labeled example: process_snapshot.py
from __future__ import annotations
import os
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
PINNED_ENV_KEYS = ("APP_ENV", "CONFIG_PATH", "NO_COLOR")
@dataclass(frozen=True)
class ProcessSnapshot:
cwd: str
env: dict[str, str | None]
repo_path_entries: tuple[str, ...]
extra_files: tuple[str, ...]
def _env_view(keys: Iterable[str]) -> dict[str, str | None]:
return {key: os.environ.get(key) for key in keys}
def _repo_path_entries(repo_root: Path) -> tuple[str, ...]:
root = repo_root.resolve()
found: list[str] = []
for entry in sys.path:
if not entry:
continue
try:
resolved = Path(entry).resolve()
except OSError:
continue
if resolved == root or root in resolved.parents:
found.append(str(resolved))
return tuple(found)
def snapshot(repo_root: Path, extra_root: Path) -> ProcessSnapshot:
files = tuple(
sorted(
str(path.relative_to(extra_root))
for path in extra_root.rglob("*")
if path.is_file()
)
)
return ProcessSnapshot(
cwd=str(Path.cwd().resolve()),
env=_env_view(PINNED_ENV_KEYS),
repo_path_entries=_repo_path_entries(repo_root),
extra_files=files,
)
Replace messy_app with your actual module name. The dataclass is the oracle for this workflow. Tests compare two snapshots with strict equality.
A failed equality check blocks the helper extract. Dump JSON only when a failure is hard to read. Keep that dump out of the committed suite.
# labeled example: print_snapshot.py
import json
from pathlib import Path
from process_snapshot import snapshot
repo = Path(__file__).resolve().parents[1]
sandbox = repo / "tmp_sandbox"
sandbox.mkdir(exist_ok=True)
print(json.dumps(snapshot(repo, sandbox).__dict__, indent=2, default=str))
Capture import time versus call time
Run the snapshot three times inside one test. Import is not the same event as calling main.
# labeled example: test_process_snapshot.py
import importlib
from pathlib import Path
from process_snapshot import snapshot
REPO = Path(__file__).resolve().parents[1]
def test_import_does_not_mutate_process_globals(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("APP_ENV", "test")
monkeypatch.setenv("CONFIG_PATH", str(tmp_path / "cfg.json"))
(tmp_path / "cfg.json").write_text("{}", encoding="utf-8")
before = snapshot(REPO, tmp_path)
module = importlib.import_module("messy_app")
after_import = snapshot(REPO, tmp_path)
assert after_import == before
module.main()
after_main = snapshot(REPO, tmp_path)
assert after_main.cwd == before.cwd
assert after_main.env == before.env
This test will fail on many legacy scripts. That failure is the useful baseline result. Do not fix cwd inside the test helper.
If import already changes cwd, pin that change. Characterization tests document the current truth only. They are not a cleanup checklist for imports.
Numbered workflow
Follow this numbered order without skipping steps. A skipped step makes the extract a guess.
- Create a branch that contains zero production edits.
- Add the snapshot helper and one import test.
- Run the test once and save the failure output.
- Encode the observed mutations as explicit assertions.
- Change only one helper after those assertions stay green.
- Re-run the snapshot test before any second extract.
Use this command sequence for a local pytest run.
git switch -c snapshot-import-globals
git status --short
python -m pytest tests/test_process_snapshot.py -q
If pytest is missing, install it inside a venv. Do not install test tools into the system interpreter.
python -m venv .venv
source .venv/bin/activate
python -m pip install pytest
python -m pytest tests/test_process_snapshot.py -q
Windows activation uses the Scripts activate path. Keep the pytest invocation identical after that.
.venv\Scripts\activate
Turn pytest output into assertions
Read the first failure as data, not as a cleanup task. Encode one field at a time. Stop after the four fields are pinned.
- Copy the assertion error for cwd first.
- Copy the env dict mismatch second, then stop.
- Ignore sys.path noise outside the repo root.
- Commit the pinned assertions before any helper move.
Commit tests before the production edit lands. The branch then has an oracle. Later diffs have something exact to fail against.
Encode observed mutations as data
Suppose import changes cwd to the module directory. Do not hide that fact in extra fixtures. Write it as a direct assertion instead.
# labeled example: observed import chdir
from pathlib import Path
def test_import_chdirs_to_module_dir(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
before = Path.cwd().resolve()
import messy_app # noqa: F401
after = Path.cwd().resolve()
module_dir = Path(messy_app.__file__).resolve().parent
assert before == tmp_path.resolve()
assert after == module_dir
The extract now has a concrete tripwire. Moving the helper to a new file must keep this chdir. Or the same commit must update the assertion.
Two edits still count as one behavioral change. Do not mix a move with a behavior fix. Split those intents across two separate commits.
Decision table for the smallest change
Use this table before you accept any patch.
| Snapshot field | Import drift | Allowed change | Forbidden change |
|---|---|---|---|
| cwd | chdir at import | keep chdir in the same module | move chdir into a new helper |
| env | reads APP_ENV once | keep the single read site | add a second getenv call |
| sys.path | inserts repo root | keep one insert | insert from two files |
| extra files | writes cache.json | keep write inside main | write during import |
The table is a gate, not a suggestion. A patch that touches two rows is too large. Split that patch before any review starts.
Line count is not the safety metric here. A three-line patch can still break import. A twenty-line patch can still preserve all four fields.
After the oracle exists
A coding model can review one diff after snapshots are green. It should not invent the oracle or the assertions.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode provides free model access and a free server option. Use that pair only after the snapshot test exists. Feed the test file plus one candidate patch.
Ask for a single-helper diff that preserves the four fields. Reject patches that edit tests and production code together. Also reject prompts that say clean up the whole module.
This remains a review aid for one patch. It is not a substitute for local pytest. If you cannot run the tests, skip the model.
Limitations
The four-field snapshot is not a full characterization suite. It ignores return values, stdout, and exit codes. Combine those oracles when the module is a CLI.
It also ignores network calls and wall-clock time. Do not treat it as a load test. It will not catch race conditions under concurrency.
Monkeypatched tests can hide real import order bugs. Prefer a subprocess probe when import mutates the interpreter. A child process is a cleaner sandbox for imports.
# labeled example: subprocess probe
import subprocess
import sys
from pathlib import Path
def test_import_in_child_process(tmp_path):
probe = (
"from pathlib import Path\n"
"print(str(Path.cwd().resolve()))\n"
"import messy_app\n"
"print(str(Path.cwd().resolve()))\n"
)
result = subprocess.run(
[sys.executable, "-c", probe],
cwd=tmp_path,
check=True,
capture_output=True,
text=True,
)
lines = result.stdout.strip().splitlines()
assert lines[0] == str(Path(tmp_path).resolve())
Parse subprocess output as structured data only. Do not eyeball logs for pass or fail. Split lines and assert on concrete strings.
Who should not use this approach
Skip this workflow on a greenfield package without import side effects. The extra harness would add noise without signal. A normal unit test of the helper is enough.
Skip it for long-running services with worker processes. Process globals are the wrong surface there. Prefer contract tests on the public API instead.
Skip model review if you cannot execute pytest locally. An unread snapshot is not evidence of safety. Local green tests are the only merge gate.
What smallest safe change means here
One helper is the change budget for this method. One snapshot-preserving commit is the delivery unit. Leave drive-by renames out of that commit.
Keep import side effects in the old module after a move. Re-export names if callers import the old path. Measure safety by snapshot equality, not by diff size.
Closing
Freeze cwd, env, repo path entries, and extra files. Turn import mutations into committed test assertions. Then change one helper and stop there.
Keep any model on the patch, not on the oracle. The test file remains the source of truth.
Top comments (0)