DEV Community

Dakota Huang
Dakota Huang

Posted on

Pin Argv, Workdir Creates, and Report Bytes Before One Extract

The smallest safe extract still mutates three I/O channels. Argv handling, workdir creates, and report bytes move together.

Characterization tests must freeze all three before any split. Green unit tests without those locks miss the real contract.

Why this extract fails in messy repos

A typical messy repo hides an entrypoint script. That script parses flags, makes directories, and writes JSON.

Teams extract a workdir helper after a cleanup pass. The helper looks pure under a local unit test.

The report path still depends on process cwd. Argv aliases still create extra sibling folders on disk.

Bytes in report.json change separator style after the extract. File-count tests stay green through that silent shift.

Downstream parsers then reject the rewritten JSON document. The extract stayed small while the unlocked contract drifted.

Lock the surface, not the internals

Do not start by renaming functions inside the script. Start by listing every observable the script already emits.

Treat those observables as the current production contract. The extract may proceed only after that list is pinned.

This article uses a labeled example, not a live incident. Copy the harness. Replace names with your entrypoint module.

1. Inventory the I/O surface

Open the entrypoint and ignore internal helper names. Record flags, default paths, created dirs, and printed lines.

Record report filenames, encodings, and JSON key order. Label this inventory as observed behavior, not desired design.

A compact inventory for the example looks like this.

Channel Observed contract in the example
argv --root, --name, --json-indent
workdir {root}/.work/{name}/ created with parents
report {workdir}/report.json, UTF-8, trailing newline
stdout one line: wrote {absolute_path}
cwd report path is resolved before print

Write the table before any test code. The table is the contract, not comments.

2. Build a throwaway fixture tree

Create a temporary root for every characterization run. Copy a tiny sample tree into that root before invocation.

Never run the messy script against your working tree. Side effects on a real repo poison later comparisons.

Seed only the files the report actually reads. Extra files hide missing-path behavior you still need.

Keep fixture contents tiny and checked into git. Large fixtures turn every extract into a noise diff.

3. Drive the script through one harness

Call the entrypoint in-process first for speed. Add one subprocess row if import-time code mutates disks.

The example below is a proposal. It is unexecuted here.

Adapt imports to the real module path in your repo. Keep the harness in a dedicated test file.

# test_characterize_entrypoint.py
# Proposal / unexecuted example. Not production telemetry.
from __future__ import annotations

import json
from pathlib import Path

import pytest

from example_messy_entrypoint import main

CASES = [
    # argv, expected_rel_workdir, indent
    (["--root", "{root}", "--name", "job-a"], ".work/job-a", 2),
    (["--root", "{root}", "--name", "job-a", "--json-indent", "0"], ".work/job-a", 0),
    (["--root", "{root}", "--name", "nested/job"], ".work/nested/job", 2),
]

@pytest.mark.parametrize("argv, rel_work, indent", CASES)
def test_argv_workdir_and_report_bytes(tmp_path: Path, capsys, argv, rel_work, indent):
    root = tmp_path / "repo"
    root.mkdir()
    (root / "input.txt").write_text("alpha\n", encoding="utf-8")

    filled = [root.as_posix() if token == "{root}" else token for token in argv]
    rc = main(filled)

    workdir = root / rel_work
    report = workdir / "report.json"
    out = capsys.readouterr().out

    assert rc == 0
    assert workdir.is_dir()
    assert report.is_file()
    assert list(_rel_files(root)) == sorted(
        [
            "input.txt",
            f"{rel_work}/report.json",
        ]
    )

    raw = report.read_bytes()
    assert raw.endswith(b"\n")
    assert raw.decode("utf-8") == _golden(root, workdir, indent)
    assert out == f"wrote {report.resolve()}\n"


def _rel_files(root: Path) -> list[str]:
    paths = [p.relative_to(root).as_posix() for p in root.rglob("*") if p.is_file()]
    return paths


def _golden(root: Path, workdir: Path, indent: int) -> str:
    payload = {
        "root": root.resolve().as_posix(),
        "workdir": workdir.resolve().as_posix(),
        "inputs": ["input.txt"],
    }
    body = json.dumps(payload, indent=indent if indent else None)
    return body + "\n"
Enter fullscreen mode Exit fullscreen mode

Run the harness from the repo root with pytest.

python -m pytest test_characterize_entrypoint.py -q
Enter fullscreen mode Exit fullscreen mode

Fail the run before any extract if goldens disagree. An extract on a red harness hides the old contract.

4. Freeze three assertions per row

Each row must check return code, created dirs, and bytes. Checking only the returned Path misses JSON drift.

Checking only JSON bytes still misses extra directories. Pin encoding, final newline, and indent width explicitly.

Do not parse JSON and dump it again inside the test. Re-dumping hides key order and separator changes.

Compare raw read_bytes() against a stored golden blob. Generated goldens in helpers must match today's serializer exactly.

If the script uses separators=(',', ': '), copy that pair. Pretty-print defaults are not a stable contract.

5. Encode argv as a table, not as prose

Prose test names hide alias collisions across flags. Put each argv vector in a list of tuples.

Expected workdir relative paths belong in the same tuple. Indent width belongs there as well.

Add a dedicated row for a missing --root flag. Add a dedicated row for an empty --name value.

If the script today creates ./.work in cwd, pin that. Do not "fix" that path during characterization.

A second table keeps error paths from mixing with happy paths.

Argv vector Expected rc Expected extra paths Expected stdout prefix
[] 2 none usage:
--root {root} 2 none usage:
--root {root} --name "" 2 none empty name
--root {root} --name job-a --json-indent -1 2 none indent

Implement error rows only after you capture real messages. Guessed wording is a new product, not a lock.

ERROR_CASES = [
    ([], 2, "usage:"),
    (["--root", "{root}"], 2, "usage:"),
    (["--root", "{root}", "--name", ""], 2, "empty name"),
]

@pytest.mark.parametrize("argv, rc, prefix", ERROR_CASES)
def test_error_argv_creates_no_workdir(tmp_path: Path, capsys, argv, rc, prefix):
    root = tmp_path / "repo"
    root.mkdir()
    filled = [root.as_posix() if token == "{root}" else token for token in argv]
    assert main(filled) == rc
    assert list(root.rglob("*")) == [root] or list(_rel_files(root)) == []
    assert capsys.readouterr().err.startswith(prefix) or capsys.readouterr().out.startswith(prefix)
Enter fullscreen mode Exit fullscreen mode

Fix the stdout/stderr capture in your copy. The snippet shows the intended assertions, not a polished helper.

6. Draft extra rows only after the lock exists

When the lock exists, extra argv rows still help. Disclosure: This article was prepared as part of MonkeyCode's product outreach.

MonkeyCode offers free model access and a free server option. A model can propose additional fixture rows from the inventory list. A free server can run the harness off your laptop.

Treat those rows as candidates until the harness passes. Do not let a model invent the contract.

Paste the inventory table, not the full repo. Ask only for argv vectors the script already parses.

7. Extract one function only

The allowed extract in this example is ensure_workdir. It must accept an explicit root and a name string.

It must not read sys.argv or write reports. Keep the report writer inside the entrypoint for this pass.

Keep argv parsing inside the entrypoint for this pass. Two extracts in one diff hide which lock broke.

# example_messy_entrypoint.py
# Proposal / unexecuted example. Starting mess, then one extract.
from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path


def ensure_workdir(root: Path, name: str) -> Path:
    workdir = root / ".work" / name
    workdir.mkdir(parents=True, exist_ok=True)
    return workdir


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(prog="report")
    parser.add_argument("--root", required=True)
    parser.add_argument("--name", required=True)
    parser.add_argument("--json-indent", type=int, default=2)
    args = parser.parse_args(argv)

    if args.name == "":
        sys.stderr.write("empty name\n")
        return 2
    if args.json_indent < 0:
        sys.stderr.write("indent\n")
        return 2

    root = Path(args.root)
    workdir = ensure_workdir(root, args.name)
    report = workdir / "report.json"
    payload = {
        "root": root.resolve().as_posix(),
        "workdir": workdir.resolve().as_posix(),
        "inputs": sorted(p.name for p in root.glob("*.txt")),
    }
    indent = args.json_indent if args.json_indent else None
    report.write_text(json.dumps(payload, indent=indent) + "\n", encoding="utf-8")
    sys.stdout.write(f"wrote {report.resolve()}\n")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode

After the extract, run the same characterization module. Do not add new assertions during the extract pass.

A new assertion is a new contract, not a refactor check. Save API design for a later, explicit change.

8. Fail the harness on extra filesystem noise

Assert the workdir exists. Also assert sibling absence. List the temporary root and compare a sorted path set.

Extra hidden files are still contract changes on disk. Ignore nothing except known OS junk you already documented.

Document .DS_Store only if the old script could emit it. Otherwise treat it as pollution from the test host.

If you must ignore host junk, list it in one constant. Do not glob-ignore * and hope the report survived.

ALLOWED_JUNK = frozenset()  # empty until an old run proves a filename


def _rel_files(root: Path) -> list[str]:
    out: list[str] = []
    for path in root.rglob("*"):
        if not path.is_file():
            continue
        rel = path.relative_to(root).as_posix()
        if path.name in ALLOWED_JUNK:
            continue
        out.append(rel)
    return sorted(out)
Enter fullscreen mode Exit fullscreen mode

Re-run after the extract with the same command. The path set, report bytes, and stdout line must match.

python -m pytest test_characterize_entrypoint.py -q --tb=short
Enter fullscreen mode Exit fullscreen mode

Limitations

This harness does not prove the report is correct. It only proves the next extract did not change bytes.

It will not catch races across parallel entrypoint runs. It will not catch clock values if timestamps enter JSON.

If reports embed timestamps, freeze time before the lock. If reports embed hostnames, freeze that source too.

Golden files rot when teams format JSON by habit. Keep goldens in git and review them as behavior diffs.

In-process tests miss import-time writes and if __name__ branches. Add one subprocess row when those paths exist.

Who should skip this approach

Do not use this flow on a greenfield module. Design a typed API and test that API directly instead.

Do not use this flow to bless security bugs as contracts. Pinning world-writable directories is not a refactor win.

Do not outsource the first inventory to a coding model. The first lock must come from the running script today.

Skip subprocess characterization when the script is a library. Call functions with explicit arguments in that case.

Skip this extract if --name still interpolates unsanitized paths. Characterization does not replace a path-safety review.

Close

Refactor the messy entrypoint only after three locks hold. Argv effects, workdir creates, and report bytes stay pinned.

Then extract ensure_workdir and leave every other call. The next extract needs a new row set, not optimism.

Top comments (0)