Path extracts fail when cwd still moves. Snapshot the filesystem before any helper move. Then change one path function against that snapshot.
Path helpers look isolated on a first read. Relative writes still bind to process cwd. A hidden os.chdir call will invalidate later extracts.
Do not extract a path helper yet. Capture file creation deltas first. Keep the extract tiny after that snapshot matches.
The failure is not the new function
Most messy modules mix three path jobs. They resolve input paths from mixed sources. They create output files with relative names.
They also mutate cwd during a single run. Later helpers can look cleaner in isolation. The parent script still assumes the old cwd.
Tests pass in the original nested layout. They fail when a caller changes directory. The extract did not record disk behavior first.
This article treats filesystem deltas as a contract. The contract is recorded before any extract. The extract is rejected if the delta changes.
What to pin before a path extract
Pin four observables, not the helper name. Each observable is cheap to record. Each one catches a different extract failure.
- Record absolute cwd before the module returns control.
- Record absolute cwd after the module returns control.
- Record files created, modified, or removed during the run.
- Record resolved absolute paths for every relative string.
Skip network, CPU, and log text here. Those belong to other characterization passes. Mixing them hides the path contract.
Decision table for a path extract
Use one row, then stop. Do not pin every observable at once. One new signal per change keeps blame clear.
| Symptom after extract | Likely unpinned fact | Smallest next pin |
|---|---|---|
| cwd differs in CI | hidden chdir or Path.cwd
|
cwd before and after |
extra file in /tmp
|
tempfile without cleanup | created-file set |
| missing sidecar json | relative write to old cwd | file delta by name |
| tests pass, scripts fail | argv path versus __file__ path |
resolved absolute map |
| identical names, wrong bytes | write to a copied fixture path | sha256 per relative file |
The table is a selection tool. It is not a full test plan. Rank rows by how often CI already flakes.
Artifact: a filesystem delta harness
The harness below is a labeled example. It is not production telemetry. Run it against a local messy script only.
It copies a fixture tree into a temp directory. It runs the target as a subprocess. It writes JSON for cwd roots and file hashes.
# fs_delta_harness.py — example, not a published package
from __future__ import annotations
import hashlib
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
def _tree_state(root: Path) -> dict[str, str]:
state: dict[str, str] = {}
for path in sorted(root.rglob("*")):
if not path.is_file():
continue
rel = path.relative_to(root).as_posix()
digest = hashlib.sha256(path.read_bytes()).hexdigest()
state[rel] = digest
return state
def _delta(before: dict[str, str], after: dict[str, str]) -> dict:
created = sorted(set(after) - set(before))
removed = sorted(set(before) - set(after))
modified = sorted(
p for p in set(before) & set(after) if before[p] != after[p]
)
return {
"created": created,
"removed": removed,
"modified": modified,
}
def snapshot_run(
fixture_src: Path,
command: list[str],
snapshot_path: Path,
) -> dict:
work = Path(os.environ.get("FS_DELTA_WORK", "/tmp")) / "fs_delta_work"
if work.exists():
shutil.rmtree(work)
shutil.copytree(fixture_src, work)
before_tree = _tree_state(work)
proc = subprocess.run(
command,
cwd=work,
capture_output=True,
text=True,
check=False,
)
after_tree = _tree_state(work)
payload = {
"command": command,
"returncode": proc.returncode,
"work_root": str(work.resolve()),
"delta": _delta(before_tree, after_tree),
"file_count_after": len(after_tree),
}
snapshot_path.write_text(json.dumps(payload, indent=2) + "\n")
return payload
def compare(old_path: Path, new_path: Path) -> dict:
old = json.loads(old_path.read_text())
new = json.loads(new_path.read_text())
keys = ("returncode", "work_root", "delta")
drift = {
k: {"old": old[k], "new": new[k]}
for k in keys
if old[k] != new[k]
}
return {"ok": not drift, "drift": drift}
if __name__ == "__main__":
if sys.argv[1] == "record":
snapshot_run(
fixture_src=Path(sys.argv[2]),
command=sys.argv[3:],
snapshot_path=Path("fs_delta.snapshot.json"),
)
elif sys.argv[1] == "compare":
report = compare(Path(sys.argv[2]), Path(sys.argv[3]))
print(json.dumps(report, indent=2))
raise SystemExit(0 if report["ok"] else 1)
else:
raise SystemExit("usage: record <fixture> <cmd...> | compare a b")
Note one gap in this example. The child process cwd is not read back. A later probe file can close that gap.
Stdout is intentionally absent from compare keys. Transcript pinning is a different contract. This pass only answers what hit disk.
Numbered workflow
Follow the steps in order. Do not skip the snapshot commit.
- Copy a realistic fixture tree into git. Include relative outputs the script already writes. Exclude secrets and large binaries from that fixture.
- Record a baseline snapshot with the harness. Commit
fs_delta.snapshot.jsonnext to the fixture. Treat the JSON as a characterization test, not documentation. - List path-related functions in the messy module. Rank them by relative writes, not by name length. Pick the smallest function that only formats or joins paths.
- Extract or rewrite that one function only. Keep signatures and call sites stable. Do not move
chdircalls in the same diff. - Re-run the harness against the same fixture and command. Compare the new snapshot to the committed baseline. Fail the change if created, removed, or modified sets drift.
- If the snapshot matches, keep the extract. If it drifts, revert the extract. Then pin the missing observable and repeat from step two.
The order is the method. Reversing it turns the extract into a guess. Guessing path behavior is how cwd drift ships.
Probe the child cwd when needed
The harness cannot see os.chdir inside the child. Add a probe only after the baseline exists. The probe must be a one-line write.
# probe_cwd.py — example child wrapper, not a library
from pathlib import Path
import runpy
import sys
target = sys.argv[1]
rest = sys.argv[2:]
Path("_cwd_probe_before.txt").write_text(str(Path.cwd().resolve()) + "\n")
sys.argv = [target, *rest]
runpy.run_path(target, run_name="__main__")
Path("_cwd_probe_after.txt").write_text(str(Path.cwd().resolve()) + "\n")
Record both probe files in the delta. Then the extract cannot hide a chdir. Remove the probe after the path helper is stable.
A probe is still characterization, not production logging. Leave it out of released scripts. Keep it in the fixture command only.
Commands to run locally
Use a dedicated fixture directory. Do not point the harness at a live home directory.
python fs_delta_harness.py record ./fixtures/report_job \
python messy_report.py --out report.json
git add fixtures/report_job fs_delta.snapshot.json
git commit -m "test: pin filesystem delta for report job"
# after the one-function extract
python fs_delta_harness.py record ./fixtures/report_job \
python messy_report.py --out report.json
mv fs_delta.snapshot.json fs_delta.after.json
python fs_delta_harness.py compare \
fs_delta.snapshot.json fs_delta.after.json
A non-zero compare exit means disk behavior changed. That is a failed characterization test. Do not argue with the JSON.
Restore the committed snapshot name after a pass. Keep one baseline file in git. Extra after-files belong in the worktree only.
How to read a drifting delta
Read created first, then removed, then modified. New files often mean a cwd shift. Missing files often mean a resolve-too-early change.
Modified hashes with stable names mean content drift. The path helper may have changed encoding or line endings. That is still a failed extract for this pass.
work_root drift means the harness environment moved. Fix FS_DELTA_WORK before blaming the module. Unstable work roots make every compare noisy.
returncode drift is not a path lesson by itself. Still treat it as a hard fail. Path extracts should not change process success.
Smallest safe change examples
Safe extracts usually join paths only. They do not call os.chdir. They do not create directories as a side effect.
# before: mixed resolve and write in one block
out = Path("report.json")
out.write_text(payload)
# after: one helper, same relative target
def report_path(name: str) -> Path:
return Path(name)
out = report_path("report.json")
out.write_text(payload)
The helper does not call Path.resolve. Resolving too early bakes in cwd. Keep resolution at the write site if the original did.
Unsafe in the same diff: adding mkdir, switching to tempfile, or using __file__. Those change the delta on purpose. Handle them as behavior changes, not extracts.
Another unsafe move is Path.cwd() / name when the original used a bare name. That can look more correct and still drift. Correctness here means matching the recorded delta.
Where a free coding workspace fits
Some teams generate candidate extracts with a coding model. The snapshot must remain the gate, not the model. A matching delta is the only pass signal.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode offers free model access and a free server option. Those can host the harness loop without extra local setup. The model may propose the one-function extract. The committed snapshot still accepts or rejects that extract.
Do not skip the compare step because a diff looks tidy. Tidy diffs still call chdir. Tidy diffs still write beside the old cwd.
Limitations
The harness hashes file bytes only. It ignores permission bits and timestamps. Two writes with identical bytes look unchanged.
It also ignores files outside the fixture root. Absolute writes to /tmp will not appear. Add an explicit allow-list of extra roots if needed.
Race conditions can poison the snapshot. Parallel tests that share one work directory will collide. Use one work directory per command.
Line endings and umask differ across operating systems. Record snapshots on the same OS you ship. Do not commit a macOS snapshot for a Linux job without a second record.
The harness does not pin environment variables. PYTHONPATH and HOME still affect path resolution. Pin those in a later pass if the script reads them.
Symbolic links can collapse in copytree on some platforms. If the messy module depends on symlink shape, the fixture is wrong. Recreate the link structure before recording.
Who should not use this approach
Skip this workflow for greenfield packages with no disk writes. There is no delta to pin. Unit tests on pure functions are enough.
Skip it when the change must alter output paths. Characterization tests freeze behavior. A planned path migration needs new fixtures, not a matching snapshot.
Skip it for binary formats you cannot copy into git. Large media fixtures will not stay reviewable. Use a smaller synthetic fixture or a different contract.
Skip it if the module must chdir to load plugins. Pin that chdir as required behavior first. Then extract helpers that do not touch cwd.
Skip it for multi-user servers that write under /var. Fixture copies will not match those roots. Use a dedicated path contract test instead.
What the snapshot is not
The snapshot is not a performance benchmark. It is not a security audit. It is not proof the helper is well designed.
It only answers one question after an extract. Did the process still touch the same files? If yes, the path extract is allowed to stay.
A clean snapshot also does not bless extra refactors. Do not bundle import cleanup with the path helper. One behavioral axis per diff remains the rule.
Close
Path extracts fail from cwd drift, not from ugly names. Snapshot file deltas before the first helper move. Change one function only after the JSON matches.
Keep the harness boring and local. Keep the extract tiny. Let the filesystem delta reject confident but wrong diffs.
Commit the snapshot, then extract one path helper against it.
Top comments (0)