DEV Community

Dakota Huang
Dakota Huang

Posted on

Pin Argv Defaults and Conflict Errors Before One Parser Extract

Do not extract a CLI parser from messy main() yet. Frozen argv cases must exist before that move. Unpinned defaults and conflict errors drift under cleanups.

Why parser extracts fail in messy repos

Messy Python tools grow argparse blocks inside main(). Flag definitions sit beside logging, I/O, and retries. The file looks unreadable, so a refactor starts there.

The first cleanup is usually "extract the parser." Dest names often get renamed during the extract move. A store_true default can flip from False to None.

Mutually exclusive groups can lose a required flag. Callers keep the same shell flags after the extract. The Namespace they depend on is no longer identical.

Help text can still look right after the break. Observed Namespace fields are what actually changed under callers.

Happy-path tests usually pass one command line only. Exclusive-group failures and SUPPRESS defaults stay unpinned. That coverage gap is where parser refactors ship breakage.

The rule

Characterize public argv combinations before any parser extract. Pin coerced types, omitted keys, and error classes. Then move construction code and change no flag semantics.

This is a characterization suite, not a design suite. It records current behavior, including odd legacy defaults. It does not "improve" flags in the same diff.

Scope

Use this on argparse CLIs with existing scripts. Do not mix a Click migration into this change. Throwaway one-off scripts do not need this harness.

Python 3.9 added the exit_on_error flag on ArgumentParser. That switch raises ArgumentError instead of exiting the process. Older interpreters still call sys.exit on bad flags.

Artifact: argv matrix plus golden JSON

The artifact is a table of argv lists. Each row records a Namespace dict or an error. Default paths, zero limits, and exclusive groups all appear.

The following probe is a worked example, not a production dump. Copy real add_argument calls into the stand-in. Do not invent a cleaner flag surface here.

Step 1 — Stop process exit during parse

Parsing must not kill the test process. Pass exit_on_error=False when the runtime allows it. Catch SystemExit as a fallback on older Pythons.

# parse_probe.py
from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path
from typing import Any


def build_parser() -> argparse.ArgumentParser:
    # Stand-in: paste the messy module's add_argument calls unchanged.
    parser = argparse.ArgumentParser(prog="syncjob", exit_on_error=False)
    parser.add_argument("--root", type=Path, default=Path("data"))
    parser.add_argument("--limit", type=int, default=100)
    parser.add_argument("--json", action="store_true")
    group = parser.add_mutually_exclusive_group()
    group.add_argument("--fast", action="store_true")
    group.add_argument("--full", action="store_true")
    parser.add_argument(
        "--token",
        default=argparse.SUPPRESS,
        help="omitted from Namespace when unset",
    )
    return parser
Enter fullscreen mode Exit fullscreen mode

Label this parser as a stand-in for the messy module. Copy the real add_argument calls into build_parser(). Do not tidy dest names while copying those calls.

Step 2 — Freeze one row at a time

def namespace_to_dict(ns: argparse.Namespace) -> dict[str, Any]:
    out: dict[str, Any] = {}
    for key, value in vars(ns).items():
        if isinstance(value, Path):
            out[key] = {"path": value.as_posix()}
        else:
            out[key] = value
    return dict(sorted(out.items()))


def probe(argv: list[str]) -> dict[str, Any]:
    parser = build_parser()
    try:
        ns = parser.parse_args(argv)
        return {"ok": True, "ns": namespace_to_dict(ns)}
    except argparse.ArgumentError as exc:
        return {
            "ok": False,
            "error_type": type(exc).__name__,
            "message": str(exc),
        }
    except SystemExit as exc:
        return {
            "ok": False,
            "error_type": "SystemExit",
            "code": exc.code,
        }
Enter fullscreen mode Exit fullscreen mode

Record Path values as POSIX strings in the golden. That encoding avoids Windows slash noise in goldens. Sort keys so the JSON diffs stay stable.

Step 3 — Build the argv matrix

CASES = {
    "defaults": [],
    "root_only": ["--root", "tmp/in"],
    "limit_zero": ["--limit", "0"],
    "limit_bad": ["--limit", "nope"],
    "json_flag": ["--json"],
    "fast": ["--fast"],
    "full": ["--full"],
    "fast_and_full": ["--fast", "--full"],
    "token_set": ["--token", "abc"],
    "unknown_flag": ["--nope"],
}


def main() -> int:
    mode = sys.argv[1] if len(sys.argv) > 1 else "check"
    golden = Path("testdata/cli_argv_golden.json")
    observed = {name: probe(argv) for name, argv in CASES.items()}
    if mode == "record":
        golden.parent.mkdir(parents=True, exist_ok=True)
        golden.write_text(json.dumps(observed, indent=2) + "\n")
        print(f"wrote {golden}")
        return 0
    expected = json.loads(golden.read_text())
    if observed != expected:
        print("cli characterization mismatch")
        print(json.dumps({"expected": expected, "observed": observed}, indent=2))
        return 1
    print(f"{len(CASES)} argv cases pinned")
    return 0


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

Run record once against the current messy behavior. Commit the JSON file beside the probe test. After that, check is the merge gate.

python parse_probe.py record
git add testdata/cli_argv_golden.json parse_probe.py
python parse_probe.py check
Enter fullscreen mode Exit fullscreen mode

Step 4 — Read the golden before you extract

Open the golden JSON and confirm each row. The token key must be absent on defaults. A bad --limit value must not become limit=None.

The --fast --full pair must stay an error row. If a row looks wrong, that is product truth today. Fix surprising semantics in a later dedicated change.

Step 5 — Smallest safe change

Move parser construction out of the messy main(). Keep the add_argument lines byte-identical during the move. Return a Namespace object from parse_argv only.

# cli_parser.py
from __future__ import annotations

import argparse
import sys
from pathlib import Path


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(prog="syncjob", exit_on_error=False)
    parser.add_argument("--root", type=Path, default=Path("data"))
    parser.add_argument("--limit", type=int, default=100)
    parser.add_argument("--json", action="store_true")
    group = parser.add_mutually_exclusive_group()
    group.add_argument("--fast", action="store_true")
    group.add_argument("--full", action="store_true")
    parser.add_argument("--token", default=argparse.SUPPRESS)
    return parser


def parse_argv(argv: list[str] | None = None) -> argparse.Namespace:
    parser = build_parser()
    return parser.parse_args(argv)


def main() -> int:
    args = parse_argv(sys.argv[1:])
    # Existing job logic stays here, untouched.
    _ = args
    return 0
Enter fullscreen mode Exit fullscreen mode

Point parse_probe.build_parser at the extracted function next. Run python parse_probe.py check after the import switch. If the JSON mismatches, revert the extract immediately.

Do not rename dest fields during that import switch. Keep one structural move and zero flag edits only.

Decision table

Case Must pin Fail if you only test happy path
defaults root, limit, json==False, no token default Path or limit shifts
--limit 0 integer zero, not a missing value if limit: treats zero as unset
--limit nope ArgumentError or SystemExit invalid int becomes a string
--fast --full exclusive-group error both flags true together
--token abc key present only when set SUPPRESS becomes None
unknown flag error type frozen parser swallows unknown dests

Use the table when adding new public cases. Each new public flag should get two matrix rows. One row covers the default path for that flag.

One row covers a conflict or a bad type. Unknown flags need an error-type row as well.

What a coding model may do after the pin

Drafting tools can propose the extract after goldens exist. They should not invent flags or rewrite help text.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode provides free model access and a free server option. That pairing is useful when the messy CLI lives in CI-like isolation.

Feed the current build_parser body and the golden JSON. Ask only for a move of construction code. Do not ask it to redesign the flag surface.

Re-run python parse_probe.py check on any proposed diff. Reject patches that touch dest names or defaults. The golden file remains the authority, not the model.

Limitations

This harness does not pin help wrapping or usage text. Those usage strings change across Python minor versions. Do not golden the full --help output in this harness.

The exit_on_error switch is not available before Python 3.9. Catch SystemExit and pin the code instead. Do not compare full stderr dumps across platforms.

Some invalid argv paths still exit on older CPython builds. The probe already records SystemExit as a first-class row. Do not assume ArgumentError for every bad flag.

Subparsers need a separate case table per command. Shared parent flags still need their own default rows. Skip subparser expansion during the first parser extract.

Environment-backed flags stay outside this argv matrix. Pin os.environ keys in a later harness. Do not mix env mutation into parser extraction.

type=Path does not check that the path exists. Existence checks belong in the job logic instead. Keep those checks out of parse_argv entirely.

Who should not use this

Do not use this for a greenfield CLI with no users. Design that parser in the open instead of pinning. Characterization here exists to freeze legacy CLI contracts.

Do not use this while switching to Click or Typer. That is a semantic rewrite, not an extract. Give that rewrite its own tests and rollout.

Do not use this if you will "clean" dest names now. Rename is a second change with a deprecation window. Mixing rename and extract hides the real break.

Do not skip the JSON gate because the diff looks trivial. Parser extracts fail in dest maps, not in line count. Trivial looking diffs can still change the Namespace.

Close

Parser extracts fail when argv contracts stay implicit. Pin defaults, types, omitted keys, and conflict errors. Then move one constructor and leave flag semantics untouched.

Keep parse_probe.py check on the extract commit. Add matrix rows when you later change flags. The matrix is cheaper than a broken user script.

Top comments (0)