"""tally — summarise one numeric column of a CSV file, or print its first rows. What it does tally stats --col NAME print count, min, max, mean, median of a column tally head [-n N] print the header and the first N data rows (default 5) Inputs A CSV file with a header row (UTF-8). Empty cells in the chosen column are skipped and counted separately as "blank". A non-numeric cell is an error. Exit codes 0 success 2 missing file, missing column, non-numeric cell, or a column with no values How to run python build-a-cli-tool-with-an-agent-from-a-spec.py stats data.csv --col price python build-a-cli-tool-with-an-agent-from-a-spec.py head data.csv -n 3 Tests: python -m unittest build-a-cli-tool-with-an-agent-from-a-spec_test.py Standard library only. Written against Python 3.13. """ from __future__ import annotations import argparse import csv import statistics import sys from typing import Iterable, Sequence, TextIO MISSING_COLUMN = 2 def read_rows(path: str) -> tuple[list[str], list[dict[str, str]]]: """Return (header, rows) for a CSV file. Raises OSError if unreadable.""" with open(path, newline="", encoding="utf-8") as fh: reader = csv.DictReader(fh) header = list(reader.fieldnames or []) rows = list(reader) return header, rows def column_values(rows: Iterable[dict[str, str]], col: str) -> tuple[list[float], int]: """Numeric values of a column plus the number of blank cells skipped. Raises ValueError with a readable message on the first non-numeric cell. """ values: list[float] = [] blanks = 0 for i, row in enumerate(rows, start=2): # line 1 is the header raw = (row.get(col) or "").strip() if raw == "": blanks += 1 continue try: values.append(float(raw)) except ValueError: raise ValueError(f"line {i}: {raw!r} in column {col!r} is not a number") from None return values, blanks def format_stats(col: str, values: Sequence[float], blanks: int) -> str: lines = [ f"column: {col}", f"count: {len(values)}", f"blank: {blanks}", f"min: {min(values):g}", f"max: {max(values):g}", f"mean: {statistics.fmean(values):.4g}", f"median: {statistics.median(values):g}", ] return "\n".join(lines) def cmd_stats(args: argparse.Namespace, out: TextIO, err: TextIO) -> int: try: header, rows = read_rows(args.file) except OSError as exc: print(f"tally: cannot read {args.file}: {exc.strerror}", file=err) return MISSING_COLUMN if args.col not in header: print(f"tally: no column named {args.col!r} (have: {', '.join(header)})", file=err) return MISSING_COLUMN try: values, blanks = column_values(rows, args.col) except ValueError as exc: print(f"tally: {exc}", file=err) return MISSING_COLUMN if not values: print(f"tally: column {args.col!r} has no numeric values", file=err) return MISSING_COLUMN print(format_stats(args.col, values, blanks), file=out) return 0 def cmd_head(args: argparse.Namespace, out: TextIO, err: TextIO) -> int: try: with open(args.file, newline="", encoding="utf-8") as fh: reader = csv.reader(fh) writer = csv.writer(out, lineterminator="\n") for i, row in enumerate(reader): if i > args.n: break writer.writerow(row) except OSError as exc: print(f"tally: cannot read {args.file}: {exc.strerror}", file=err) return MISSING_COLUMN return 0 def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="tally", description="Summarise a CSV column.") sub = parser.add_subparsers(dest="command", required=True) p_stats = sub.add_parser("stats", help="count/min/max/mean/median of one column") p_stats.add_argument("file") p_stats.add_argument("--col", required=True, help="column name from the header row") p_stats.set_defaults(func=cmd_stats) p_head = sub.add_parser("head", help="print the header and the first N rows") p_head.add_argument("file") p_head.add_argument("-n", type=int, default=5, help="rows to print (default 5)") p_head.set_defaults(func=cmd_head) return parser def main(argv: Sequence[str] | None = None, out: TextIO = sys.stdout, err: TextIO = sys.stderr) -> int: args = build_parser().parse_args(argv) return args.func(args, out, err) if __name__ == "__main__": sys.exit(main())