DEV Community

Dakota Huang
Dakota Huang

Posted on

Pin Env, Cwd, and Argv Before One CLI Extract

Messy CLI scripts bake behavior into environment and working directory. A parser extract without those pins ships silent flag drift. Freeze env, cwd, and argv into one snapshot first.

Then extract a single config builder and nothing else. Leave every other function in the script untouched.

The contract you will break

Production wrappers export flags through environment variables, not argparse. Cron entries start in a different working directory than laptops. Duplicate argv tokens change path resolution in subtle ways.

Callers keep the old contract after a clean extract. Function names still match after the extract lands. Downstream values often do not match the prior contract.

Docker WORKDIR and Makefile cd lines count as cwd state. Missing cwd in the pin hides relative path bugs. Those bugs appear only after the first deploy.

What to freeze

Pin only keys the entrypoint reads on this path. Unused shell variables must not enter the snapshot. Record raw inputs and the derived config together.

Capture five fields in one canonical document:

  1. Environment keys the script actually reads.
  2. Absolute cwd after startup path resolution.
  3. Argv tokens after the program name.
  4. Derived config after existing ad-hoc normalization.
  5. SHA-256 of sorted JSON for those fields.

Store the document beside the test, not in comments. Frozen digests stay stable when comments drift.

Decision table

Use this table before touching run.py.

Signal in the script Pin it? Extract it this change?
Env read used on the happy path yes no, snapshot only
Env read on a dead branch no no
Cwd joined into a Path yes no, snapshot only
Argv alias such as --mode twice yes no, snapshot only
HTTP client construction no no
build_config() after pins stay green already pinned yes, one function

Skip rows that need network, clock, or threads. Those cases need a different pin family. Mixing them hides CLI contract failures during review.

Step 1: List the reads

Open the entry script and mark every config source. Search for os.environ, os.getenv, and Path.cwd. Search for sys.argv and argparse if present.

Write the used key names into a frozen tuple. Do not dump the entire process environment. Developer laptops pollute that map with unused keys.

Proposed command:

grep -nE 'os\.environ|os\.getenv|Path\.cwd|os\.getcwd|sys\.argv' run.py
Enter fullscreen mode Exit fullscreen mode

Treat the output as a checklist, not as a refactor plan.

Step 2: Insert a dump hook only

Add a dump hook after the existing reads. Do not move those reads in this step. The hook must exit before side effects.

Proposed fragment inside run.py:

# Proposed dump hook. Place after derived config exists.
if os.environ.get("PIN_CLI_CONFIG") == "1":
    import json
    from pathlib import Path

    payload = {
        "env": {
            "APP_ENV": os.environ.get("APP_ENV", ""),
            "APP_ROOT": os.environ.get("APP_ROOT", ""),
            "APP_FLAG_DEBUG": os.environ.get("APP_FLAG_DEBUG", ""),
        },
        "cwd": str(Path.cwd().resolve()),
        "argv": sys.argv[1:],
        "derived": derived,
    }
    out = Path(os.environ["PIN_CLI_OUT"])
    out.parent.mkdir(parents=True, exist_ok=True)
    text = json.dumps(payload, sort_keys=True, separators=(",", ":"))
    out.write_text(text + "\n", encoding="utf-8")
    raise SystemExit(0)
Enter fullscreen mode Exit fullscreen mode

Label this fragment as a proposed dump hook. Wire key names to the local script. Keep derived as the object the rest already uses.

Step 3: Record the golden file

Create a fixtures directory before the first run. Run the script under controlled env, cwd, and argv. Hash the JSON, not a pretty printed dump.

Proposed commands:

mkdir -p tests/pins
mkdir -p /tmp/cli-pin-root
cd /tmp/cli-pin-root

PIN_CLI_CONFIG=1 \
PIN_CLI_OUT=/abs/repo/tests/pins/cli_config.json \
APP_ENV=prod \
APP_ROOT=/tmp/cli-pin-root \
APP_FLAG_DEBUG=0 \
python /abs/repo/run.py --mode batch --mode batch

python - <<'PY'
from hashlib import sha256
from pathlib import Path
p = Path("/abs/repo/tests/pins/cli_config.json")
raw = p.read_bytes()
print(len(raw), sha256(raw).hexdigest())
PY
Enter fullscreen mode Exit fullscreen mode

Commit both the JSON and the digest line. Reviewers need the payload, not only the hash.

Step 4: Prove the pin can fail

A pin that never fails does not protect the extract. Flip one consumed value and rerun the dump. The digest must change after that single flip.

Set APP_FLAG_DEBUG to one and keep argv fixed. Compare the new hash against the committed digest. If they match, the script ignores that key.

Add a test that asserts inequality under mutation. Add a test that asserts equality under replay. Both tests must exist before the extract.

Proposed pytest module:

from __future__ import annotations

import json
import os
import subprocess
import sys
from pathlib import Path

REPO = Path(__file__).resolve().parents[1]
SCRIPT = REPO / "run.py"
PIN = REPO / "tests" / "pins" / "cli_config.json"


def _run(env: dict[str, str], cwd: Path, argv: list[str], out: Path) -> dict:
    merged = os.environ.copy()
    merged.update(env)
    merged["PIN_CLI_CONFIG"] = "1"
    merged["PIN_CLI_OUT"] = str(out)
    subprocess.check_call(
        [sys.executable, str(SCRIPT), *argv],
        cwd=str(cwd),
        env=merged,
    )
    return json.loads(out.read_text(encoding="utf-8"))


def test_replay_pins_env_argv_and_derived(tmp_path: Path) -> None:
    env = {
        "APP_ENV": "prod",
        "APP_ROOT": str(tmp_path.resolve()),
        "APP_FLAG_DEBUG": "0",
    }
    payload = _run(
        env,
        tmp_path,
        ["--mode", "batch", "--mode", "batch"],
        tmp_path / "out.json",
    )
    committed = json.loads(PIN.read_text(encoding="utf-8"))
    assert payload["env"]["APP_ENV"] == committed["env"]["APP_ENV"]
    assert payload["argv"] == committed["argv"]
    assert payload["derived"]["debug"] is False
    assert payload["derived"]["mode"] == "batch"
    assert payload["derived"]["root"] == str(tmp_path.resolve())


def test_debug_flag_mutation_changes_derived(tmp_path: Path) -> None:
    env = {
        "APP_ENV": "prod",
        "APP_ROOT": str(tmp_path.resolve()),
        "APP_FLAG_DEBUG": "1",
    }
    payload = _run(
        env,
        tmp_path,
        ["--mode", "batch", "--mode", "batch"],
        tmp_path / "out.json",
    )
    assert payload["derived"]["debug"] is True
Enter fullscreen mode Exit fullscreen mode

The replay test must not compare absolute cwd against a laptop path. Compare env, argv, and derived fields in replay. Cwd belongs in a dedicated tmp_path assertion.

Absolute APP_ROOT in the committed JSON is a human fixture. Pytest must rebuild paths from tmp_path. That split keeps the pin portable across machines.

Step 5: Extract one builder

Move only the read-and-normalize block into build_config(). Keep the dump hook in place after the move. Point the dump hook at that new function.

Do not introduce argparse during this extract change. Do not rename flags or env key spellings. Do not add defaults that the old script lacked.

Proposed signature:

def _last_mode(argv: list[str]) -> str:
    mode = ""
    i = 0
    while i < len(argv):
        if argv[i] == "--mode" and i + 1 < len(argv):
            mode = argv[i + 1]
            i += 2
            continue
        i += 1
    return mode


def build_config(argv: list[str] | None = None) -> dict:
    argv = sys.argv[1:] if argv is None else argv
    raw_root = os.environ.get("APP_ROOT", str(Path.cwd()))
    return {
        "env_name": os.environ.get("APP_ENV", ""),
        "root": str(Path(raw_root).resolve()),
        "debug": os.environ.get("APP_FLAG_DEBUG", "0") == "1",
        "mode": _last_mode(argv),
    }
Enter fullscreen mode Exit fullscreen mode

Label _last_mode as a local helper if duplicate mode flags exist. Preserve last-wins or first-wins as the old script did. Measure that from the golden payload, not from memory.

Rerun the replay test and the mutation test. The replay test must pass on the extract. Mutation must still fail the equality check.

Step 6: Gate the diff

Limit the diff to the hook, the new function, and tests. Reject extra formatting outside the touched seam. Reject helper files that the pin does not cover.

Proposed commands:

git diff --stat
git diff -U0 run.py | wc -l
pytest tests/test_pin_cli_config.py -q
Enter fullscreen mode Exit fullscreen mode

If the line count explodes, split the change. The config extract is the whole change.

Where a free model belongs

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

MonkeyCode offers free model access and a free server option. Use both only after the dump pins exist.

The model proposes one build_config function from the golden JSON. The server runs replay and mutation tests on the patch. Discard any patch that misses those tests.

Feed the model the golden JSON and the read sites only. Ask for one function with the current defaults. Reject patches that add argparse, extra files, or new env keys.

If the suite is red, discard the patch. Do not negotiate with the snapshot when tests fail. The snapshot remains the contract for the extract.

Limitations

This pin ignores locale, timezone, and file encoding. It ignores import-time work before the hook. It ignores threads that reread env after startup.

subprocess.check_call will execute module-level side effects in run.py. Scripts that delete data on import are unsafe here. Guard those paths before any dump run.

JSON key order is controlled by sort_keys=True. Nested objects with custom classes will not serialize. Convert those values to plain data before hashing.

Duplicate argv behavior is specific to each messy repo. Last-wins parsing is not a language standard. The golden file is the standard for this extract.

Who should not use this

Do not use this on a greenfield CLI with a stable argparse schema. That codebase already has a config boundary. The dump hook would only add noise.

Do not use this when env keys are secrets. Golden files must not store tokens or passwords. Redact or hash secret values, or skip those keys.

Do not use this as a substitute for integration tests of workers. CLI config pins do not prove job correctness. They only protect the config extract seam.

Skip the method if nobody can run pytest in CI. An unrun pin is only a comment. Comments do not gate merges in review.

Close the loop

Env, cwd, and argv are the real CLI interface. Snapshot them, mutate one field, then extract one builder. Keep the rest of the messy script still messy.

Top comments (0)