DEV Community

Dakota Huang
Dakota Huang

Posted on

Pin Extension Totals Before One Report Extract

Mixed inventory CLIs break after a tidy extract. Pin extension totals and sort keys first. Then move one pure classifier function only.

The failure mode

A report script often walks, classifies, and prints together. Most tests check only the process exit code. The printed table still drifts after small refactors. Unknown extensions jump buckets after a helper move.

Empty files also vanish from size totals silently. Locale-aware sorting reorders rows without a comment. You need frozen bytes, not a prettier function.

Four observables to freeze

This workflow pins four observables before any extract. Skip this order and the extract will lie.

  1. Record frozen extension totals as integer maps now.
  2. Count empty, tiny, and large size buckets.
  3. Sort every table row by a stable key.
  4. Keep exit codes for clean and dirty trees.

The original artifact is a tiny Python CLI. Characterization tests compare stdout, stderr, and returncode together. Later structure may change during a small extract. Those four observables must stay byte stable afterward.

Proposed fixture tree

Treat this tree as a proposed local fixture. It is not production telemetry from a fleet.

fixtures/inventory_tree/
  README.md
  app.py
  app.py.bak
  data/empty.csv
  data/notes.TXT
  data/photo.jpeg
  build/out.o
  .hidden.env
Enter fullscreen mode Exit fullscreen mode

Create it with a short shell sequence. Pin file sizes, not only the names.

mkdir -p fixtures/inventory_tree/data fixtures/inventory_tree/build
printf 'hello\n' > fixtures/inventory_tree/README.md
printf 'print(1)\n' > fixtures/inventory_tree/app.py
printf 'print(1)\n' > fixtures/inventory_tree/app.py.bak
: > fixtures/inventory_tree/data/empty.csv
printf 'note\n' > fixtures/inventory_tree/data/notes.TXT
printf 'JPEG' > fixtures/inventory_tree/data/photo.jpeg
printf 'obj\n' > fixtures/inventory_tree/build/out.o
printf 'X=1\n' > fixtures/inventory_tree/.hidden.env
Enter fullscreen mode Exit fullscreen mode

Record sizes with a deterministic command after creation. Do not eyeball the histogram later by hand.

python - <<'PY'
from pathlib import Path
root = Path('fixtures/inventory_tree')
for p in sorted(root.rglob('*')):
    if p.is_file():
        print(f"{p.relative_to(root)} {p.stat().st_size}")
PY
Enter fullscreen mode Exit fullscreen mode

Why file counts are not the spec

Green file counts still hide silent bucket drift. A backup suffix can leave the table entirely. The count stays constant while the totals move.

Sort keys hide inside the printed rows. Two equal counts can swap table lines. Golden stdout catches that row swap immediately. A Counter equality test may not catch it.

Why walk order is not enough here

This script sorts rows after the walk. Walk order can still change hashes elsewhere though. It should not change this report output. If goldens flip, your sort key is incomplete.

Include hidden files in the fixture on purpose. Exclude rules belong in a later extract only. Do not mix ignore policy with size classification.

Fixture sizes must match the constants

The proposed fixture files are small on purpose. Eight bytes is the tiny ceiling in this script. A larger corpus would hide the overlap quickly.

Measure sizes after printf, not before writes. Newline characters can change the size bucket. Windows line endings can move a file across buckets.

Pin the platform or strip newlines in fixtures. Characterization tests inherit that fixture choice fully. Document it in the golden commit message clearly.

Step 1: Capture the messy baseline

The following script is a proposed characterization target. Keep classification, bucketing, and printing in one file.

Resist the urge to extract helpers now. A premature helper often changes unknown-extension policy silently.

# inventory_report.py — proposed messy baseline
from __future__ import annotations

import sys
from collections import defaultdict
from pathlib import Path

TINY_MAX = 8
LARGE_MIN = 8


def main(argv: list[str]) -> int:
    if len(argv) != 2:
        sys.stderr.write("usage: inventory_report.py ROOT\n")
        return 2
    root = Path(argv[1])
    if not root.is_dir():
        sys.stderr.write("root missing\n")
        return 2

    ext_totals: dict[str, int] = defaultdict(int)
    buckets = {"empty": 0, "tiny": 0, "large": 0}
    rows: list[tuple[str, str, int]] = []

    for path in root.rglob("*"):
        if not path.is_file():
            continue
        rel = path.relative_to(root).as_posix()
        size = path.stat().st_size
        ext = path.suffix.lower() or "<none>"
        if ext == ".bak":
            ext = "<backup>"
        ext_totals[ext] += size
        if size == 0:
            buckets["empty"] += 1
        elif size <= TINY_MAX:
            buckets["tiny"] += 1
        else:
            buckets["large"] += 1
        rows.append((ext, rel, size))

    rows.sort(key=lambda item: (item[0], item[1]))
    sys.stdout.write("ext\tpath\tsize\n")
    for ext, rel, size in rows:
        sys.stdout.write(f"{ext}\t{rel}\t{size}\n")
    sys.stdout.write("TOTALS\n")
    for ext in sorted(ext_totals):
        sys.stdout.write(f"{ext}\t{ext_totals[ext]}\n")
    sys.stdout.write("BUCKETS\n")
    for name in ("empty", "tiny", "large"):
        sys.stdout.write(f"{name}\t{buckets[name]}\n")
    return 1 if buckets["empty"] else 0


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

Notice bak files map to a synthetic backup bucket. Hidden files still enter the walk on purpose. The notes.TXT name lowercases to a txt suffix. Those three rules are easy to lose.

Step 2: Write characterization tests first

Do not assert pretty object graphs yet here. Capture returncode, stdout, and stderr without parsing. Store golden files beside the test module.

# test_inventory_report_char.py — proposed characterization harness
from __future__ import annotations

import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent
SCRIPT = ROOT / "inventory_report.py"
FIXTURE = ROOT / "fixtures" / "inventory_tree"
GOLDEN = ROOT / "goldens" / "inventory_tree.stdout"
STDERR_GOLDEN = ROOT / "goldens" / "inventory_tree.stderr"


def run(root: Path) -> subprocess.CompletedProcess[str]:
    return subprocess.run(
        [sys.executable, str(SCRIPT), str(root)],
        text=True,
        capture_output=True,
        check=False,
    )


def test_usage_exit_code() -> None:
    proc = subprocess.run(
        [sys.executable, str(SCRIPT)],
        text=True,
        capture_output=True,
        check=False,
    )
    assert proc.returncode == 2
    assert proc.stderr == "usage: inventory_report.py ROOT\n"
    assert proc.stdout == ""


def test_fixture_matches_golden() -> None:
    proc = run(FIXTURE)
    assert proc.returncode == 1
    assert proc.stderr == STDERR_GOLDEN.read_text(encoding="utf-8")
    assert proc.stdout == GOLDEN.read_text(encoding="utf-8")
Enter fullscreen mode Exit fullscreen mode

Generate goldens once from the messy script. Commit those goldens before any function extract.

mkdir -p goldens
python inventory_report.py fixtures/inventory_tree > goldens/inventory_tree.stdout
python inventory_report.py fixtures/inventory_tree 2> goldens/inventory_tree.stderr
python -m pytest test_inventory_report_char.py -q
Enter fullscreen mode Exit fullscreen mode

If pytest is missing, use unittest instead today. The assertion target stays the same either way. Do not regenerate goldens after a failed extract.

Step 3: Freeze sort keys and buckets

Build a decision table before touching any helpers. Each row is a contract, not a vibe.

Input Rule Observable
suffix .TXT lowercase before grouping .txt total includes it
suffix .bak map to <backup> not listed as .bak
suffix missing use <none> README.md groups there
size 0 empty bucket plus exit 1 empty count rises
size 1..8 tiny bucket tiny count rises
size >8 large bucket large count rises
walk order sort (ext, path) table rows stay stable
hidden file include in the walk .hidden.env listed
missing root stderr plus exit 2 stdout stays empty

The TINY_MAX and LARGE_MIN constants currently match. That numeric overlap is a silent extract landmine. Characterization tests should lock the present behavior exactly. Do not fix the overlap during this extract.

A second test should pin the overlap on purpose. Label that test as a proposed pin. Wire it only after the function exists.

# Proposed pin after classify_size exists.
# assert classify_size(8) == "tiny"
# assert classify_size(9) == "large"
Enter fullscreen mode Exit fullscreen mode

Step 4: Extract one classifier only

Keep walking and printing inside the main function. Move size classification alone in this diff.

Leave extension mapping in the script for now. Allow only one behavior change per diff.

def classify_size(size: int) -> str:
    if size == 0:
        return "empty"
    if size <= TINY_MAX:
        return "tiny"
    return "large"
Enter fullscreen mode Exit fullscreen mode

Then replace the inline if and elif chain. Re-run the golden tests immediately after that.

If stdout drifts, revert the extract at once. Do not stack an extension-mapping extract in the same diff.

        bucket = classify_size(size)
        buckets[bucket] += 1
Enter fullscreen mode Exit fullscreen mode

That extract is the smallest safe change. Extension policy stays in the loop for now.

Rendering stays in the loop as well. Sort keys stay in the main function.

Step 5: Re-run the same goldens

Use the same commands after the extract. Any extra row means the extract leaked.

python -m pytest test_inventory_report_char.py -q
diff -u goldens/inventory_tree.stdout <(python inventory_report.py fixtures/inventory_tree)
Enter fullscreen mode Exit fullscreen mode

Add a negative fixture in the next test. Point the CLI at a missing path. Confirm stderr still matches the missing root text.

Confirm that stdout stays completely empty here. Missing roots must not print a table.

def test_missing_root() -> None:
    proc = run(ROOT / "fixtures" / "nope")
    assert proc.returncode == 2
    assert proc.stdout == ""
    assert proc.stderr == "root missing\n"
Enter fullscreen mode Exit fullscreen mode

Only then consider extracting a normalize_ext helper. Repeat the same golden command after that extract. Never extract renderer and classifier together now.

Where a free model session fits

A free coding model can draft the first test file. Disclosure: This article was prepared as part of MonkeyCode's product outreach.

MonkeyCode offers free model access and a free server option. Use that session only to draft characterization tests. Keep every golden file in your own repository.

Paste the messy script and the decision table. Ask for tests that lock stdout bytes.

Reject any patch that rewrites bucket policy. The server session does not replace local goldens.

Limitations

This method assumes a deterministic filesystem walk only. Network inventory jobs do not belong in this method.

Clock-stamped reports also break these byte goldens. Symlink cycles need a separate pin first here.

Binary files with unstable metadata need size-only checks. Do not golden entire encoded dumps blindly ever.

The overlap between tiny and large is frozen, not blessed. A later product change needs a new golden on purpose.

Do not hide that change inside a rename. Call it a policy change in the diff. Update goldens in that same commit.

Characterization tests do not prove real design quality. They only prove you did not change behavior. Reviewers still need to read the extract diff.

Who should skip this approach

Skip this if the CLI has no stable output contract. Skip this if stakeholders want a new histogram now. Skip this if the tree mutates during the test run.

Do not use byte goldens for shuffled JSON objects. Do not use them for wall-clock duration fields. Do not use them as a substitute for fuzzing parsers.

Teams shipping a public report schema need schema tests too. This workflow is for messy internal scripts only. It is a brake, not a product vision.

Practical close

Freeze extension totals before you extract a classifier. Freeze sort keys before you touch rendering.

Change only one pure function per diff. Keep goldens committed beside the messy script always.

Let a draft session write tests, not policy. The inventory output is the spec until you replace it.

If you try this on another report CLI, start with returncode. Then lock stdout before any helper extract. Extract only after both locks stay green.

Top comments (0)