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.
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())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: 1Key points
argparsehandles parsing, type conversion, validation and--helpfor 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.
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())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