DEV Community

Dakota Huang
Dakota Huang

Posted on

A Process Contract Table Makes One argparse Extract Safe

A messy CLI is not a refactor target.
It is a process contract you have not measured.
Freeze exit status, stdout bytes, and stderr class first.

Then change one parse concern. Stop after that extract.
Users depend on process results, not function names.
An argparse split without that table remains a guess.

This walkthrough uses a constructed Python example.
It is a method, not a claimed production incident.
Swap the fixtures for your corpus before you trust it.

What the table measures

Measure three fields for every fixture row.
Record the integer process exit code first.
Then hash stdout bytes and classify stderr.

Do not start with class extraction work.
Do not start with clean architecture diagrams.
Those moves enlarge the diff without a check.

The outcome table is the only oracle.
The extract is allowed only when rows stay equal.
One parse concern is the change budget here.

The artifact

The artifact is a frozen process contract table.
Each row stores one fixture id and three results.
Tests replay the same launcher and compare fields.

Label: the files below are a worked example.
They are not copied from a customer repository.
Rename paths when you apply the same method.

Constructed messy entrypoint

# cli_messy.py — constructed example, not production code
import argparse
import sys
from pathlib import Path

def main(argv=None):
    argv = argv if argv is not None else sys.argv[1:]
    parser = argparse.ArgumentParser(add_help=False)
    parser.add_argument("path")
    parser.add_argument("-q", action="store_true")
    parser.add_argument("--strict", action="store_true")
    parser.add_argument("--limit", default="10")
    args, unknown = parser.parse_known_args(argv)

    if unknown:
        sys.stderr.write("unknown-flag\n")
        return 2

    path = Path(args.path)
    if not path.exists():
        sys.stderr.write("missing-input\n")
        return 2

    data = path.read_bytes()
    if args.strict and b"\x00" in data:
        sys.stderr.write("nul-byte\n")
        return 1

    text = data.decode("utf-8", errors="replace")
    lines = [ln for ln in text.splitlines() if ln.strip()]
    try:
        limit = int(args.limit)
    except ValueError:
        sys.stderr.write("bad-limit\n")
        return 2

    selected = lines[:limit]
    if not args.q:
        sys.stdout.write("\n".join(selected) + ("\n" if selected else ""))
    return 0

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

Parser, validator, and printer share one function.
That mix is why later extracts drift user output.
This pass will not move the printer at all.

Step 1: Isolate one launcher

Pick a single invocation shape and keep it.
Use one interpreter binary for every table row.
Do not mix wrappers inside the same contract file.

python cli_messy.py FIXTURE --strict --limit 10
Enter fullscreen mode Exit fullscreen mode

Write that launcher into the recorder script.
Changing the launcher later invalidates stdout hashes.
Treat the launcher as part of the measured contract.

Step 2: Freeze a fixture corpus

Create a directory of small deterministic inputs.
Keep a short file, a blank file, and a NUL file.
Keep missing-path and unknown-flag rows in the runner.

fixtures/
  ok_short.txt
  ok_blank.txt
  has_nul.bin
Enter fullscreen mode Exit fullscreen mode

You choose the corpus. Tests only record outcomes.
A model may suggest extra cases. You still own files.
Hash the corpus after the set is frozen.

find fixtures -type f -print0 | sort -z | xargs -0 sha256sum > fixtures.sha256
Enter fullscreen mode Exit fullscreen mode

Commit that listing beside the contract tests.
A silent fixture edit breaks the oracle first.
Catch that before you blame the argparse extract.

Step 3: Record bytes, not imported functions

Run one subprocess per row in the table.
Do not import main for this first oracle.
Users run a process, so the oracle runs one.

Capture stdout as raw bytes before hashing.
Text mode can rewrite newlines across platforms.
That rewrite would look like an extract failure.

# record_contract.py — constructed example
import hashlib
import json
import subprocess
import sys
from pathlib import Path

CASES = [
    {"id": "ok_short", "args": ["fixtures/ok_short.txt", "--strict", "--limit", "10"]},
    {"id": "ok_quiet", "args": ["fixtures/ok_short.txt", "-q", "--limit", "10"]},
    {"id": "blank", "args": ["fixtures/ok_blank.txt", "--limit", "3"]},
    {"id": "missing", "args": ["fixtures/nope.txt", "--strict"]},
    {"id": "bad_limit", "args": ["fixtures/ok_short.txt", "--limit", "x"]},
    {"id": "unknown", "args": ["fixtures/ok_short.txt", "--explode"]},
    {"id": "nul", "args": ["fixtures/has_nul.bin", "--strict"]},
]

ALLOWED = {"unknown-flag", "missing-input", "nul-byte", "bad-limit"}

def classify_stderr(raw: bytes) -> str:
    text = raw.decode("utf-8", errors="replace").strip()
    if not text:
        return "empty"
    first = text.splitlines()[0]
    return first if first in ALLOWED else "other"

def run_case(args):
    proc = subprocess.run(
        [sys.executable, "cli_messy.py", *args],
        capture_output=True,
    )
    return {
        "exit": proc.returncode,
        "stdout_sha16": hashlib.sha256(proc.stdout).hexdigest()[:16],
        "stderr_class": classify_stderr(proc.stderr),
    }

def main():
    table = []
    for case in CASES:
        row = {"id": case["id"], **run_case(case["args"])}
        table.append(row)
        print(json.dumps(row, sort_keys=True))
    Path("contract_table.json").write_text(
        json.dumps(table, indent=2, sort_keys=True) + "\n",
        encoding="utf-8",
    )
    Path("contract_cases.json").write_text(
        json.dumps(CASES, indent=2, sort_keys=True) + "\n",
        encoding="utf-8",
    )

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

stderr classification uses a closed string set.
Unknown text becomes class other on purpose.
Wording churn cannot hide inside a free-form blob.

Step 4: Commit the table, then test it

Commit contract_table.json as expected values.
The test file must not refresh those values itself.
A failing row is a contract break. Investigate it.

# test_contract.py — constructed example
import hashlib
import json
import subprocess
import sys
from pathlib import Path

CASES = json.loads(Path("contract_cases.json").read_text(encoding="utf-8"))
EXPECTED = {
    row["id"]: row
    for row in json.loads(Path("contract_table.json").read_text(encoding="utf-8"))
}
ALLOWED = {"unknown-flag", "missing-input", "nul-byte", "bad-limit"}

def classify_stderr(raw: bytes) -> str:
    text = raw.decode("utf-8", errors="replace").strip()
    if not text:
        return "empty"
    first = text.splitlines()[0]
    return first if first in ALLOWED else "other"

def test_process_contract():
    failures = []
    for case in CASES:
        proc = subprocess.run(
            [sys.executable, "cli_messy.py", *case["args"]],
            capture_output=True,
        )
        got = {
            "id": case["id"],
            "exit": proc.returncode,
            "stdout_sha16": hashlib.sha256(proc.stdout).hexdigest()[:16],
            "stderr_class": classify_stderr(proc.stderr),
        }
        exp = EXPECTED[case["id"]]
        if got != exp:
            failures.append({"got": got, "exp": exp})
    assert failures == [], failures
Enter fullscreen mode Exit fullscreen mode

Run the recorder once before any source edit.

python record_contract.py
python -m pytest test_contract.py -q
Enter fullscreen mode Exit fullscreen mode

If this run is red, stop the refactor.
Your recorder and your tests already disagree.
Fix the harness. Do not extract argparse yet.

Step 5: Extract one parse concern

The allowed change is --limit integer parsing.
Leave quiet mode and strict mode inside main.
Leave unknown-flag handling on parse_known_args.

# labeled extract — still a constructed example
def parse_limit(raw: str):
    try:
        return 0, int(raw)
    except ValueError:
        return 2, None
Enter fullscreen mode Exit fullscreen mode

Wire that helper at one call site only.

code, limit = parse_limit(args.limit)
if code != 0:
    sys.stderr.write("bad-limit\n")
    return code
Enter fullscreen mode Exit fullscreen mode

Do not rename flags during this pass.
Do not add help text during this pass.
Do not replace parse_known_args during this pass.

Those edits change user-visible process behavior.
They need new rows, not a reused contract file.
This pass isolates integer parsing and nothing else.

Step 6: Replay the same rows

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

Green means the process contract still holds.
Red means the extract leaked into user results.
Revert, then shrink the helper until rows match.

If stdout hashes drift, inspect the raw bytes.
Trailing newlines are a common silent contract break.
Do not rewrite contract_table.json to hide drift.

Decision table

Fixture id Expected exit stderr class Gate after extract
ok_short 0 empty stdout hash unchanged
ok_quiet 0 empty stdout stays empty
blank 0 empty newline policy unchanged
missing 2 missing-input class unchanged
bad_limit 2 bad-limit helper returns 2
unknown 2 unknown-flag argparse path untouched
nul 1 nul-byte strict check stays in main

Read the last column as a gate, not a wish.
If a row needs a new stderr class, scope grew.
Open a different table for that later change.

Optional remote loop

Local pytest is enough for this method.
Remote compute is optional. It is not an oracle.
The committed table remains the authority either way.

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

MonkeyCode offers free model access and a free server option.
You can place the messy file and frozen table there.
Ask only for the --limit extract, then paste locally.
Re-run test_contract.py on your machine before keeping it.

Do not upload secrets inside fixture files.
Do not treat model prose as a passing test.
If the remote loop is down, the local table still works.

Limitations

This oracle ignores wall clock and memory use.
It ignores logs written to sidecar files.
It ignores environment variables you did not pass.

stderr classification collapses unknown text on purpose.
A useful traceback can become class other.
Tighten the classifier only after adding explicit rows.

Process tests are slower than in-process unit tests.
Keep the corpus small, local, and deterministic.
Do not characterize a live network CLI this way.

Hashing stdout is brittle when clocks are printed.
Strip timestamps before hashing, or skip those tools.
If clocks cannot be stripped, this method does not apply.

Who should skip this

Skip this if a stable CLI suite already exists.
Skip this if the tool still has no users.
Greenfield parsers can use ordinary unit tests.

Skip this if you cannot execute the binary.
Skip this if fixtures contain production records.
Skip this if the flag set must change in this commit.

A contract table protects behavior you intend to keep.
It is the wrong tool for an intentional flag break.
Version that break. Do not hide it inside an extract.

Close the loop

Keep the table after the --limit extract lands.
The next concern might be the NUL check alone.
It is not a blob rewrite of argparse in one diff.

Narrow diffs fail on one obvious table row.
Wide diffs fail everywhere and teach nothing.
Replay the same three fields until the row is boring.

Top comments (0)