DEV Community

Dakota Huang
Dakota Huang

Posted on

Characterize Path Resolution Before You Move One open()

Do not extract file helpers from a messy module yet. Snapshot every resolved absolute path before the first edit. Relative opens form a hidden contract across three roots.

Most failed extracts start as a path-root mismatch. The helper looks cleaner after the extract lands. The files then land in a different directory tree.

Three roots, one unpublished API

A typical messy module mixes three path roots. None of them appear in the public function signature.

os.getcwd() follows the process, not the source file. Pytest, systemd, and cron each change that value.

Path(__file__).resolve() follows the module on disk. A later package move silently retargets every relative open.

An env var such as DATA_DIR may override both. Empty, relative, and absolute values all behave differently.

The public function still accepts only a basename. Callers believe the output path stays stable. That belief does not survive a chdir.

Teaching example: three writes, three roots

The listing below is a teaching example, not production code. It writes one report beside three different roots.

# report_kit.py — messy on purpose
from __future__ import annotations

import json
import os
from pathlib import Path

HERE = Path(__file__).resolve().parent


def write_daily_report(name: str) -> dict[str, str]:
    data_dir = os.environ.get("DATA_DIR", "data")
    cwd_out = Path("out") / name
    here_out = HERE / "out" / name
    env_out = Path(data_dir) / name

    payload = {"name": name, "pid": os.getpid()}
    text = json.dumps(payload, sort_keys=True) + "\n"

    cwd_out.parent.mkdir(parents=True, exist_ok=True)
    here_out.parent.mkdir(parents=True, exist_ok=True)
    env_out.parent.mkdir(parents=True, exist_ok=True)

    cwd_out.write_text(text, encoding="utf-8")
    here_out.write_text(text, encoding="utf-8")
    env_out.write_text(text, encoding="utf-8")

    return {
        "cwd": str(cwd_out),
        "here": str(here_out),
        "env": str(env_out),
    }
Enter fullscreen mode Exit fullscreen mode

A naive extract wraps the three write_text calls. It often introduces Path.cwd() in one place. One root then silently absorbs the other two.

Return values still look like relative strings. Tests that assert those strings stay green. The bytes move anyway.

Artifact: a path ledger

Build a ledger before any helper extract. Record caller, raw argument, and resolved absolute path. Hash the sorted ledger. Treat that hash as a characterization oracle.

# path_ledger.py — teaching harness
from __future__ import annotations

import hashlib
import json
import os
import traceback
from pathlib import Path

LEDGER: list[dict[str, str]] = []


def _caller() -> str:
    frames = traceback.extract_stack()
    for frame in reversed(frames[:-1]):
        if "path_ledger.py" not in frame.filename:
            return f"{frame.filename}:{frame.lineno}:{frame.name}"
    return "unknown"


def record(kind: str, raw: str, resolved: Path) -> None:
    LEDGER.append(
        {
            "kind": kind,
            "caller": _caller(),
            "raw": raw,
            "cwd": os.getcwd(),
            "resolved": str(resolved.resolve()),
        }
    )


def ledger_hash() -> str:
    blob = json.dumps(LEDGER, sort_keys=True, indent=2)
    return hashlib.sha256(blob.encode("utf-8")).hexdigest()


def dump(path: Path) -> str:
    path.write_text(
        json.dumps(LEDGER, indent=2, sort_keys=True) + "\n",
        encoding="utf-8",
    )
    return ledger_hash()
Enter fullscreen mode Exit fullscreen mode

Wrap writes at the test boundary only. Do not patch production code for this measurement. Run the same invocation the module already trusts.

# test_path_ledger.py — characterization, not a unit test
from __future__ import annotations

from pathlib import Path
from unittest.mock import patch

import path_ledger
import report_kit

GOLDEN = Path(__file__).parent / "goldens" / "report_kit_paths.json"
GOLDEN_HASH = Path(__file__).parent / "goldens" / "report_kit_paths.sha256"


def _traced_write_text(self: Path, *args, **kwargs):
    path_ledger.record("Path.write_text", str(self), self)
    return Path.write_text(self, *args, **kwargs)


def test_write_daily_report_path_ledger(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    monkeypatch.setenv("DATA_DIR", str(tmp_path / "env-data"))
    monkeypatch.setattr(report_kit, "HERE", tmp_path / "pkg")
    (tmp_path / "pkg").mkdir()

    with patch.object(Path, "write_text", _traced_write_text):
        report_kit.write_daily_report("daily.json")

    digest = path_ledger.dump(tmp_path / "ledger.json")
    if not GOLDEN.exists():
        GOLDEN.parent.mkdir(parents=True, exist_ok=True)
        GOLDEN.write_text(
            (tmp_path / "ledger.json").read_text(encoding="utf-8"),
            encoding="utf-8",
        )
        GOLDEN_HASH.write_text(digest + "\n", encoding="utf-8")
        raise AssertionError("golden created; rerun to pin")

    assert digest == GOLDEN_HASH.read_text(encoding="utf-8").strip()
Enter fullscreen mode Exit fullscreen mode

Label this pin as an oracle, not as coverage. The first run writes goldens on purpose. The second run fails on any resolved-path drift.

Trace mkdir in the same harness when directories matter. A helper can reuse a folder the original code created. That reuse still retargets later writes.

Decision table for one extract

Use the table before any patch is accepted. Each row is a veto, not a preference.

Signal in the ledger Safe extract? Required pin
cwd-relative out/name Not yet chdir plus expected absolute
__file__-relative out/name Not yet frozen HERE
env-relative DATA_DIR/name Not yet empty, relative, absolute env
mixed roots in one function No split by root, not call shape
only basenames change Yes hash still matches

A model often groups the three writes together. They share write_text, so the grouping looks obvious. The ledger groups them by root instead.

Root grouping is the correct split. Call-shape grouping is the usual defect.

Numbered workflow

Follow these steps in order and skip none.

  1. Record one real invocation with cwd, env, and argv stored together.
  2. Trace every open, write_text, mkdir, and Path constructor you might move.
  3. Resolve each raw path against the recorded cwd, storing absolutes only.
  4. Hash the sorted ledger, then commit both the hash and JSON.
  5. Add three extra invocations: new cwd, unset DATA_DIR, absolute DATA_DIR.
  6. Refuse every extract until all four hashes stay stable across reruns.
  7. Extract one root only, and keep the other two writes inline.
  8. Diff the ledger JSON, not the source, before any merge.

Relative goldens will lie after a machine change. Absolute goldens survive a different checkout path. That is the entire point of the pin.

Commands for the first pin:

mkdir -p goldens
python -m pytest test_path_ledger.py -q
# first run creates goldens and fails
python -m pytest test_path_ledger.py -q
# second run must pass before any extract
Enter fullscreen mode Exit fullscreen mode

Commands after a proposed extract:

python -m pytest test_path_ledger.py -q
git diff -- goldens/report_kit_paths.json
# any resolved-path line change is a rejected patch
Enter fullscreen mode Exit fullscreen mode

A smoking-gun diff looks like this fragment:

-  "resolved": "/tmp/pytest-of-dev/test0/out/daily.json"
+  "resolved": "/home/ci/project/out/daily.json"
Enter fullscreen mode Exit fullscreen mode

The source diff can still look like a tidy helper. The ledger line is the reject signal.

Smallest safe change

The smallest change moves one root. It does not introduce a generic writer yet.

def _write_cwd_report(name: str, payload: str) -> str:
    target = Path("out") / name
    target.parent.mkdir(parents=True, exist_ok=True)
    target.write_text(payload, encoding="utf-8")
    return str(target)
Enter fullscreen mode Exit fullscreen mode

Leave __file__ and DATA_DIR writes in the original function. A later extract can take the second root. Each extract must keep the ledger hash unchanged.

Do not normalize paths inside the new helper. Normalization is a behavior change, not cleanup. Record it as a new ledger when you truly need it.

Avoid .resolve() in the extracted helper. resolve() follows symlinks and can rewrite goldens. Prefer the same construction the messy module already used.

Mixed open() and Path modules

Some messy modules still call open() directly. Trace that path with a thin wrapper. Keep the same ledger schema for both styles.

import builtins
from pathlib import Path

_real_open = builtins.open

def traced_open(file, mode="r", *args, **kwargs):
    raw = str(file)
    path_ledger.record("open", raw, Path(file))
    return _real_open(file, mode, *args, **kwargs)
Enter fullscreen mode Exit fullscreen mode

Install the wrapper only inside the characterization test. Production code should keep its original open. The oracle lives in the test process.

If both open and Path.write_text appear, keep both kinds. Dropping one kind hides a root. Hidden roots are how extracts ship broken CI writes.

Where a free model fits

A free coding model can draft the one-root helper. It cannot choose the root. The ledger already chose.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

MonkeyCode provides free model access and a free server option. Use the server to run the four pinned invocations. Feed the model the messy function, the ledger JSON, and the decision table. Reject any patch that alters a resolved field.

The model is a diff generator in this workflow. The hash remains the reviewer for every patch. Do not skip that reviewer for a cleaner diff.

Failure analysis

Hash changes but the source looks equivalent. A Path constructor started calling .resolve() early. Absolute strings then diverge on symlinks.

Hash stays stable on a laptop and fails in CI. The golden cwd was never isolated. Rerun the pin under tmp_path.

Only two of three writes appear in the ledger. mkdir created a directory the extract later reuses. Trace mkdir as a first-class event.

Env values look absolute in every golden row. The test set an absolute DATA_DIR only. Add the unset and relative cases before review.

Returned relative strings still match after the extract. Callers never saw the absolute target. Assert the ledger, not the return map.

Limitations

This ledger does not prove functional correctness of the report. File contents can still rot under this pin. Pair it with a payload hash when bytes matter.

It misses networked I/O by design. HTTP and object-store clients need a different oracle. Do not reuse this hash for those calls.

It is weak against symlink farms in deploy trees. resolve() follows links; absolute() does not. Pick one rule and keep it fixed.

Race conditions remain outside the ledger. Two processes can share one cwd. The ledger is per-process and will not serialize them.

Windows drive letters and UNC paths need extra goldens. Do not copy a POSIX hash onto Windows runners. Split those hashes by platform.

Who should not use this

Do not use this workflow on a greenfield module. Write explicit path arguments first in new code. There is nothing useful to characterize there.

Do not use it when output locations must change on purpose. Update the golden in the same commit as the move. Do not treat the hash as sacred then.

Do not use it as a substitute for backup policy. Characterization does not recover overwritten files. Keep real backups for destructive jobs.

Skip it for one-off notebooks and scratch CLIs. The process cwd is the product in those tools. A ledger mostly adds noise there.

Checklist before merge

  1. Four invocations produced four hashes, and all four stayed green.
  2. One root was extracted, and two roots remained inline.
  3. The ledger JSON diff is empty after the helper lands.
  4. The helper introduced no new .resolve() calls on write paths.
  5. DATA_DIR cases include unset, relative, and absolute values.

If any box is still open, keep the helper on the branch. Ship the path pin first. The extract can wait for a green hash.

Run the ledger on a free server if you already have one. Keep the extract behind that green hash.

Top comments (0)