AdvancedPython · Lesson 7 of 9

Command-Line Tools with argparse

Turn scripts into proper commands with arguments, options, subcommands, help text and exit codes.

Scripts become far more useful as commands: python results.py report results.csv --min-score 30. The standard argparse module parses arguments, converts types, checks choices, and generates --help text automatically.

Positional arguments are required (path); options start with dashes (--min-score) and can have defaults and types. Subcommands (report, top) group related actions, each with its own arguments, using add_subparsers.

Write main(argv=None) that returns an exit code: 0 for success, non-zero for errors, and print errors to sys.stderr. Accepting argv makes the tool easy to test, because tests can call main(["report", "file.csv"]) directly.

results.pyPython
import argparse
import csv
import sys
from pathlib import Path


def load(path: Path) -> list[dict[str, str]]:
    with path.open(encoding="utf-8", newline="") as f:
        return list(csv.DictReader(f))


def cmd_report(args: argparse.Namespace) -> int:
    rows = [r for r in load(args.path) if int(r["score"]) >= args.min_score]
    for r in rows:
        print(f"{r['name']:<8} {r['subject']:<8} {r['score']:>3}")
    print(f"{len(rows)} result(s) with score >= {args.min_score}")
    return 0


def cmd_top(args: argparse.Namespace) -> int:
    rows = sorted(load(args.path), key=lambda r: int(r["score"]), reverse=True)
    for r in rows[: args.count]:
        print(r["name"], r["score"])
    return 0


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(prog="results", description="Work with exam results CSV files.")
    sub = parser.add_subparsers(dest="command", required=True)

    report = sub.add_parser("report", help="list results above a minimum score")
    report.add_argument("path", type=Path, help="CSV file with name,subject,score columns")
    report.add_argument("--min-score", type=int, default=0, help="only show scores at or above this")
    report.set_defaults(func=cmd_report)

    top = sub.add_parser("top", help="show the highest scores")
    top.add_argument("path", type=Path)
    top.add_argument("-n", "--count", type=int, default=3)
    top.set_defaults(func=cmd_top)
    return parser


def main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    if not args.path.exists():
        print(f"error: {args.path} not found", file=sys.stderr)
        return 1
    return args.func(args)


if __name__ == "__main__":
    sys.exit(main())
TerminalShell
printf 'name,subject,score\nAmina,Maths,88\nJuma,Maths,29\nNeema,Biology,84\n' > results.csv
python results.py --help
python results.py report results.csv --min-score 30
python results.py top results.csv -n 2
python results.py report missing.csv; echo "exit code: $?"     # exit code: 1

Key points

  • argparse handles parsing, type conversion, validation and --help for you.
  • Use subcommands for related actions; give options sensible defaults.
  • Write main(argv=None) returning an exit code — it keeps the tool testable.

Exercise

Add a stats subcommand that prints the average, highest and lowest score, with an optional --subject filter. Make it return exit code 2 with a clear message if the filter matches no rows, and write two pytest tests that call main([...]) directly.

Show solution

Try the exercise yourself first — then compare your approach with this one.

This is the whole results.py with a cmd_stats function and a stats subparser added. stats filters by --subject if given, returns exit code 2 when nothing matches, and prints the summary otherwise. The tests call main([...]) with a temporary CSV and check the exit code and output with pytest's capsys fixture.

results.pyPython
import argparse
import csv
import statistics
import sys
from pathlib import Path


def load(path: Path) -> list[dict[str, str]]:
    with path.open(encoding="utf-8", newline="") as f:
        return list(csv.DictReader(f))


def cmd_report(args: argparse.Namespace) -> int:
    rows = [r for r in load(args.path) if int(r["score"]) >= args.min_score]
    for r in rows:
        print(f"{r['name']:<8} {r['subject']:<8} {r['score']:>3}")
    print(f"{len(rows)} result(s) with score >= {args.min_score}")
    return 0


def cmd_top(args: argparse.Namespace) -> int:
    rows = sorted(load(args.path), key=lambda r: int(r["score"]), reverse=True)
    for r in rows[: args.count]:
        print(r["name"], r["score"])
    return 0


def cmd_stats(args: argparse.Namespace) -> int:
    rows = load(args.path)
    if args.subject:
        rows = [r for r in rows if r["subject"].lower() == args.subject.lower()]
    if not rows:
        print(f"error: no results for subject {args.subject!r}", file=sys.stderr)
        return 2
    scores = [int(r["score"]) for r in rows]
    print(f"average {statistics.mean(scores):.1f}, highest {max(scores)}, lowest {min(scores)}")
    return 0


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(prog="results", description="Work with exam results CSV files.")
    sub = parser.add_subparsers(dest="command", required=True)

    report = sub.add_parser("report", help="list results above a minimum score")
    report.add_argument("path", type=Path, help="CSV file with name,subject,score columns")
    report.add_argument("--min-score", type=int, default=0, help="only show scores at or above this")
    report.set_defaults(func=cmd_report)

    top = sub.add_parser("top", help="show the highest scores")
    top.add_argument("path", type=Path)
    top.add_argument("-n", "--count", type=int, default=3)
    top.set_defaults(func=cmd_top)

    stats = sub.add_parser("stats", help="average, highest and lowest score")
    stats.add_argument("path", type=Path)
    stats.add_argument("--subject", help="only include this subject")
    stats.set_defaults(func=cmd_stats)
    return parser


def main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    if not args.path.exists():
        print(f"error: {args.path} not found", file=sys.stderr)
        return 1
    return args.func(args)


if __name__ == "__main__":
    sys.exit(main())
test_results_cli.pyPython
from pathlib import Path

import pytest

from results import main


@pytest.fixture
def csv_file(tmp_path: Path) -> Path:
    path = tmp_path / "results.csv"
    path.write_text("name,subject,score\nAmina,Maths,88\nJuma,Maths,30\nNeema,Biology,84\n", encoding="utf-8")
    return path


def test_stats_for_one_subject(csv_file: Path, capsys: pytest.CaptureFixture[str]) -> None:
    assert main(["stats", str(csv_file), "--subject", "maths"]) == 0
    assert "average 59.0, highest 88, lowest 30" in capsys.readouterr().out


def test_unknown_subject_returns_2(csv_file: Path, capsys: pytest.CaptureFixture[str]) -> None:
    assert main(["stats", str(csv_file), "--subject", "Chemistry"]) == 2
    assert "no results" in capsys.readouterr().err

Check your understanding

  1. What does argparse give you for free?

  2. What should a command return when it fails?

  3. Why write main(argv=None) instead of reading sys.argv directly?

  4. Where should error messages go?

Ask AI