Template 6 of 6 CLI tool with argparse subcommands and tested error paths
Fits CS50P final-project briefs, scripting coursework, and any "build a small CLI tool"
assignment. Two subcommands: summary describes a CSV,
filter filters rows where a named column equals a value. The
CLI exits with status code 2 on missing files or missing columns. 4 pytest cases cover both
happy paths and both error paths.
The build_parser function returns the parser independently
of main, which lets the test suite call
main(["summary", "-i", path]) directly without forking a subprocess.
That is the pattern professional Python CLIs use (Click, Typer, and the stdlib examples all
follow it). Subcommands are added with subs.add_parser
and dispatched with args.func(args), which scales to 4 or
5 subcommands without restructuring. For a coursework brief that asks for a single
command, delete add_subparsers and put the arguments directly
on the top-level parser.
Project tree
csv-cli/
├── cli.py
├── requirements.txt
└── tests/
└── test_cli.py
requirements.txt
# requirements.txt
pandas==2.2.2
pytest==8.2.0
cli.py
# cli.py
# Requires: pip install pandas
from __future__ import annotations
import argparse
import logging
import sys
from pathlib import Path
import pandas as pd
def setup_logging(verbose: bool) -> None:
level = logging.DEBUG if verbose else logging.INFO
logging.basicConfig(level=level, format="%(asctime)s [%(levelname)s] %(message)s")
def cmd_summary(args: argparse.Namespace) -> int:
df = pd.read_csv(args.input)
logging.debug("loaded %d rows, %d columns from %s", len(df), df.shape[1], args.input)
summary = df.describe(include="all").T
if args.output:
summary.to_csv(args.output)
logging.info("wrote summary to %s", args.output)
else:
print(summary)
return 0
def cmd_filter(args: argparse.Namespace) -> int:
df = pd.read_csv(args.input)
if args.column not in df.columns:
logging.error("column %r not in %s; available: %s",
args.column, args.input, list(df.columns))
return 2
out = df[df[args.column] == args.value]
logging.info("kept %d of %d rows where %s == %r",
len(out), len(df), args.column, args.value)
out.to_csv(args.output, index=False)
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="csv-cli", description="Quick CSV operations for coursework.")
parser.add_argument("--verbose", "-v", action="store_true", help="enable debug logging")
subs = parser.add_subparsers(dest="command", required=True)
p_sum = subs.add_parser("summary", help="describe(include='all') for a CSV")
p_sum.add_argument("--input", "-i", type=Path, required=True)
p_sum.add_argument("--output", "-o", type=Path, default=None)
p_sum.set_defaults(func=cmd_summary)
p_flt = subs.add_parser("filter", help="filter rows where column == value")
p_flt.add_argument("--input", "-i", type=Path, required=True)
p_flt.add_argument("--output", "-o", type=Path, required=True)
p_flt.add_argument("--column", "-c", required=True)
p_flt.add_argument("--value", required=True)
p_flt.set_defaults(func=cmd_filter)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
setup_logging(args.verbose)
try:
return args.func(args)
except FileNotFoundError as exc:
logging.error("file not found: %s", exc.filename)
return 2
if __name__ == "__main__":
sys.exit(main())
tests/test_cli.py
# tests/test_cli.py
import pandas as pd
import pytest
from pathlib import Path
from cli import main
@pytest.fixture
def sample_csv(tmp_path: Path) -> Path:
p = tmp_path / "input.csv"
pd.DataFrame({
"id": [1, 2, 3, 4],
"grade": ["A", "B", "A", "C"],
"score": [92, 81, 95, 67],
}).to_csv(p, index=False)
return p
def test_summary_prints_to_stdout(sample_csv, capsys):
rc = main(["summary", "-i", str(sample_csv)])
captured = capsys.readouterr()
assert rc == 0
assert "score" in captured.out
def test_filter_writes_matching_rows(sample_csv, tmp_path):
out = tmp_path / "out.csv"
rc = main(["filter", "-i", str(sample_csv), "-o", str(out), "-c", "grade", "--value", "A"])
assert rc == 0
result = pd.read_csv(out)
assert len(result) == 2
assert set(result["score"]) == {92, 95}
def test_filter_missing_column_returns_2(sample_csv, tmp_path):
out = tmp_path / "out.csv"
rc = main(["filter", "-i", str(sample_csv), "-o", str(out), "-c", "nope", "--value", "x"])
assert rc == 2
def test_missing_input_file_returns_2(tmp_path):
rc = main(["summary", "-i", str(tmp_path / "nope.csv")])
assert rc == 2
Run it
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
python cli.py --help
python cli.py summary -i data/students.csv -o summary.csv
python cli.py filter -i data/students.csv -o passed.csv -c grade --value A
python -m pytest tests/ -v