A Command Line with argparse

The Course
•
September 18, 2026
•
7 min read

Everything the analyzer can do is currently reached by editing main.py. A colleague who wants warnings from payments only shouldn’t have to change code. They should type:

python cli.py server.log --level warning --source payments

Turning a list of words into settings is the job of argparse, in the standard library.

A parser in a few lines

argparse Python
import argparse

parser = argparse.ArgumentParser(prog="logan", description="Summarise server logs.")
parser.add_argument("logfile", nargs="+", help="log file(s) to read")
parser.add_argument("--top", type=int, default=5, metavar="N", help="how many sources to list")
parser.add_argument("--source", action="append", default=[], metavar="NAME",
                    help="only this source (repeatable)")
parser.add_argument("-v", "--verbose", action="store_true", help="log progress to stderr")

print(parser.parse_args(["messy.log"]))
print(parser.parse_args(["a.log", "b.log", "--top", "3", "--source", "db", "--source", "auth", "-v"]))

for argv in (["--help"], ["messy.log", "--top", "three"]):
    try:
        parser.parse_args(argv)
    except SystemExit as exit:
        print(f"(argparse exited with code {exit.code})\n")

Each add_argument describes one thing the user can type:

Written asMeans
"logfile"a positional argument, required, identified by its position
nargs="+"one or more of them, collected into a list
"--top", type=intan option, converted with int(). A bad value is reported for you.
action="append"may be given several times; the values are collected into a list
action="store_true"a flag that takes no value: True if present
help=..., metavar=...what --help shows

parse_args returns a Namespace, an object with one attribute per argument: args.logfile, args.top. You get --help for free, generated from the descriptions. On a bad command line, argparse prints the usage and an error to stderr, and exits with code 2.

Your own argument types

type= accepts any function that takes the text and returns a value. Raise argparse.ArgumentTypeError for bad input, and argparse turns it into a proper usage error. The analyzer’s --level uses Level.from_text, so --level warn works, and --level loud says what went wrong:

def level(text: str) -> Level:
    """argparse type for --level: a level's name, in any case, or an alias."""
    try:
        return Level.from_text(text)
    except ValueError:
        names = ", ".join(level.name.lower() for level in Level)
        raise argparse.ArgumentTypeError(f"unknown level {text!r} (choose from {names})") from None
$ python cli.py messy.log --level loud
usage: logan [-h] [--level LEVEL] [--source NAME] [--top N] [--json PATH]
             [--strict] [-v]
             logfile [logfile ...]
logan: error: argument --level: unknown level 'loud' (choose from debug, info, warning, error, critical)

Converting at the edge, again. The rest of the program only ever sees a Level.

main returns an exit code

A command-line program reports success or failure with its exit code: 0 for success, anything else for failure. Scripts and schedulers check it, as in logan server.log && send-report. logan’s codes are:

  • 0: the report was printed, even if some lines were skipped (each one is logged as a warning).
  • 1: a file couldn’t be read, or --strict met a bad line.
  • 2: the command line itself was wrong. argparse uses 2 for this.

The shape of cli.py makes that easy to test:

def main(argv: Sequence[str] | None = None) -> int:
    """Run logan with these arguments (default: the command line). Returns the exit code."""
    args = build_parser().parse_args(argv)
    ...
    return 0


if __name__ == "__main__":
    sys.exit(main())

main returns the exit code rather than calling sys.exit itself. Only the last line, which runs when the file is executed as a program, turns it into a real exit. A test can call main([...]) with any arguments and check the number that comes back, without the test runner being shut down.

Bug hunt: the arguments that went missing

Here’s the analyzer’s cli.py with its test suite. Every test that calls main fails:

cli.py and its tests Python
"""logan: summarise server logs from the command line.""" import argparse import logging import sys from collections.abc import Sequence from analyzer import ParseError, parse_lines, read_lines from filters import all_of, level_at_least, source_is from levels import Level from report import CountSection, ListSection, Report from store import load_records, open_store, save_records, save_summary, summary logger = logging.getLogger("logan") def level(text: str) -> Level: """argparse type for --level: a level's name, in any case, or an alias.""" try: return Level.from_text(text) except ValueError: names = ", ".join(level.name.lower() for level in Level) raise argparse.ArgumentTypeError(f"unknown level {text!r} (choose from {names})") from None def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="logan", description="Summarise server logs: levels, busy sources, first errors.") parser.add_argument("logfile", nargs="+", help="log file(s) to read") parser.add_argument("--level", type=level, default=Level.DEBUG, help="only count records at this level or worse (default: all)") parser.add_argument("--source", action="append", default=[], metavar="NAME", help="only count records from this source (repeatable)") parser.add_argument("--top", type=int, default=5, metavar="N", help="how many sources to list (default: 5)") parser.add_argument("--json", metavar="PATH", help="also write a JSON summary of every record read, ignoring the filters") parser.add_argument("--strict", action="store_true", help="stop at the first bad line instead of skipping it") parser.add_argument("-v", "--verbose", action="store_true", help="log progress to stderr") return parser def main(argv: Sequence[str] | None = None) -> int: """Run logan with these arguments (default: the command line). Returns the exit code.""" args = build_parser().parse_args() logging.basicConfig(level=logging.INFO if args.verbose else logging.WARNING, format="%(levelname)s %(name)s: %(message)s", force=True) skipped: list[ParseError] = [] def skip(error: ParseError) -> None: logger.warning("skipped %s", error) skipped.append(error) keep = level_at_least(args.level) if args.source: keep = all_of(keep, source_is(*args.source)) paths = list(dict.fromkeys(args.logfile)) # the same file named twice counts once with open_store() as conn: for path in paths: try: save_records(conn, path, parse_lines(read_lines(path), None if args.strict else skip)) except OSError as error: # missing, a directory, no permission... logger.error("can't read %s: %s", path, error.strerror or error) return 1 except ParseError as error: logger.error("%s: %s", path, error) return 1 records = [r for path in paths for r in load_records(conn, path) if keep(r)] if args.json: try: save_summary(summary(conn), args.json) except OSError as error: logger.error("can't write %s: %s", args.json, error.strerror or error) return 1 logger.info("wrote %s", args.json) title = f"logan: {', '.join(paths)}" print(Report(title, [ CountSection("Levels", key=lambda r: r.level), CountSection("Busiest sources", key=lambda r: r.source, top=args.top), ListSection("First errors", keep=level_at_least(Level.ERROR), limit=3), ]).render(records)) print(f"\n{len(records)} record(s) counted, {len(skipped)} bad line(s) skipped") return 0 if __name__ == "__main__": sys.exit(main()) import io import json import logging import os import unittest from contextlib import redirect_stderr, redirect_stdout from cli import main def run(*argv): """Run the command line in-process: (exit code, stdout, stderr).""" root = logging.getLogger() saved = root.handlers[:], root.level # main() configures logging: put it back after out, err = io.StringIO(), io.StringIO() try: with redirect_stdout(out), redirect_stderr(err): code = main(list(argv)) except SystemExit as exit: # argparse exits on --help and usage errors code = exit.code finally: root.handlers[:], _ = saved root.setLevel(saved[1]) return code, out.getvalue(), err.getvalue() class TestCommandLine(unittest.TestCase): def test_report_goes_to_stdout_and_warnings_to_stderr(self): code, out, err = run("messy.log") self.assertEqual(code, 0) self.assertIn("6 record(s) counted, 2 bad line(s) skipped", out) self.assertIn("skipped line 5", err) self.assertNotIn("skipped line", out) def test_level_and_source_filters(self): code, out, _ = run("messy.log", "--level", "warn", "--source", "payments") self.assertIn("2 record(s) counted", out) def test_json_summary(self): self.addCleanup(os.remove, "summary.json") run("messy.log", "--json", "summary.json") with open("summary.json") as file: self.assertEqual(json.load(file)["records"], 6) def test_verbose_logs_progress(self): self.assertNotIn("stored 6", run("messy.log")[2]) self.assertIn("stored 6 new record(s)", run("messy.log", "-v")[2]) def test_strict_stops_at_the_first_bad_line(self): code, _, err = run("messy.log", "--strict") self.assertEqual(code, 1) self.assertIn("line 5: bad timestamp", err) def test_a_missing_file_is_an_error(self): code, _, err = run("no-such.log") self.assertEqual(code, 1) self.assertIn("can't read no-such.log: No such file", err) def test_a_directory_is_an_error_not_a_crash(self): os.makedirs("a-directory", exist_ok=True) self.addCleanup(os.rmdir, "a-directory") code, _, err = run("a-directory") self.assertEqual(code, 1) self.assertIn("can't read a-directory", err) def test_the_same_file_twice_counts_once(self): _, out, _ = run("messy.log", "messy.log") self.assertIn("6 record(s) counted, 2 bad line(s) skipped", out) def test_json_summarises_every_record_whatever_the_filters(self): self.addCleanup(os.remove, "summary.json") _, out, _ = run("messy.log", "--level", "error", "--json", "summary.json") self.assertIn("3 record(s) counted", out) with open("summary.json") as file: self.assertEqual(json.load(file)["records"], 6) def test_an_unknown_level_is_a_usage_error(self): code, _, err = run("messy.log", "--level", "loud") self.assertEqual(code, 2) self.assertIn("unknown level 'loud'", err) import sys import unittest import test_cli suite = unittest.defaultTestLoader.loadTestsFromModule(test_cli) unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite) """Log analyzer: typed records, parsed once and counted any way you like.""" import re from collections import Counter from collections.abc import Callable, Hashable, Iterable, Iterator from dataclasses import dataclass, field from datetime import datetime from levels import Level # key=value, where a value is either "quoted, spaces allowed" or a single word. FIELD = re.compile(r'(?P<key>\w+)=(?P<value>"[^"]*"|\S+)') @dataclass(frozen=True) class LogRecord: timestamp: datetime level: Level source: str message: str fields: dict[str, str] = field(default_factory=dict) line_number: int = 0 type Predicate = Callable[[LogRecord], bool] class ParseError(ValueError): """A line that is not a valid log record.""" def __init__(self, line_number: int, reason: str, line: str) -> None: super().__init__(f"line {line_number}: {reason}: {line!r}") self.line_number = line_number self.reason = reason self.line = line def parse_fields(text: str) -> dict[str, str]: """Turn 'a=1 b="two words"' into {'a': '1', 'b': 'two words'}.""" return {m["key"]: m["value"].strip('"') for m in FIELD.finditer(text)} def parse_record(line: str, line_number: int = 0) -> LogRecord: """Parse one line, or raise ParseError saying what is wrong with it.""" parts = line.split(maxsplit=3) if len(parts) < 4: raise ParseError(line_number, "too few parts", line) date, time, level_text, rest = parts try: timestamp = datetime.fromisoformat(f"{date} {time}") except ValueError: raise ParseError(line_number, "bad timestamp", line) from None try: level = Level.from_text(level_text) except ValueError: raise ParseError(line_number, f"unknown level {level_text!r}", line) from None source, colon, message = rest.partition(":") if not colon: raise ParseError(line_number, "missing 'source:'", line) message = message.strip() return LogRecord(timestamp, level, source, message, parse_fields(message), line_number) def read_lines(path: str) -> Iterator[str]: """Yield the lines of a text file one at a time, without line endings. A byte-order mark at the start is skipped, and bytes that aren't valid UTF-8 become U+FFFD instead of stopping the whole file. """ with open(path, encoding="utf-8-sig", errors="replace") as file: for line in file: yield line.rstrip("\n") def parse_lines( lines: Iterable[str], on_error: Callable[[ParseError], object] | None = None ) -> Iterator[LogRecord]: """Yield a record per good line. A bad line's ParseError is passed to on_error. With no on_error it is raised, so by default the first bad line stops everything: nothing is dropped silently. """ for number, line in enumerate(lines, start=1): if not line.strip(): continue try: record = parse_record(line, number) except ParseError as error: if on_error is None: raise on_error(error) else: yield record def count[K: Hashable]( records: Iterable[LogRecord], key: Callable[[LogRecord], K], keep: Predicate | None = None ) -> Counter[K]: """Count records by key(record), counting only those where keep(record) is true.""" return Counter(key(r) for r in records if keep is None or keep(r)) def summarize(records: Iterable[LogRecord]) -> tuple[Counter[Level], Counter[str]]: """Count levels and errors per source in a single pass.""" levels: Counter[Level] = Counter() error_sources: Counter[str] = Counter() for record in records: levels[record.level] += 1 if record.level >= Level.ERROR: error_sources[record.source] += 1 return levels, error_sources """Log levels as an enum: a fixed, ordered set of values, where a typo fails loudly.""" from enum import IntEnum from typing import Self # Other spellings some services use. (Not inside the class: every plain # attribute of an Enum class becomes a member.) ALIASES = {"WARN": "WARNING", "ERR": "ERROR", "FATAL": "CRITICAL"} class Level(IntEnum): """Ordered by severity. The numbers match the standard logging module.""" DEBUG = 10 INFO = 20 WARNING = 30 ERROR = 40 CRITICAL = 50 def __str__(self) -> str: return self.name @classmethod def from_text(cls, text: str) -> Self: """Level.from_text("warn") is Level.WARNING. Anything unknown raises ValueError.""" name = text.strip().upper() try: return cls[ALIASES.get(name, name)] except KeyError: raise ValueError(f"unknown level {text!r}") from None """Ways to choose records: each function builds a Predicate, a yes/no question about one record.""" from datetime import datetime from analyzer import LogRecord, Predicate from levels import Level def level_at_least(minimum: Level | str) -> Predicate: """Keep records at minimum or more severe: level_at_least("WARNING").""" floor = Level.from_text(minimum) if isinstance(minimum, str) else minimum def keep(record: LogRecord) -> bool: return record.level >= floor return keep def source_is(*sources: str) -> Predicate: """Keep records from any of the given sources.""" wanted = set(sources) def keep(record: LogRecord) -> bool: return record.source in wanted return keep def between(start: datetime, end: datetime) -> Predicate: """Keep records from start (included) up to end (not included).""" def keep(record: LogRecord) -> bool: return start <= record.timestamp < end return keep def all_of(*predicates: Predicate) -> Predicate: """Keep records that every predicate keeps.""" def keep(record: LogRecord) -> bool: return all(predicate(record) for predicate in predicates) return keep """Reports made of sections. Each section is a small object that renders itself.""" from collections.abc import Callable, Hashable, Iterable, Sequence from dataclasses import dataclass from typing import Protocol from analyzer import LogRecord, Predicate, count class Section(Protocol): """Anything with a title and a render method can be part of a Report.""" @property def title(self) -> str: ... # read-only, so frozen dataclasses qualify def render(self, records: Sequence[LogRecord]) -> list[str]: ... @dataclass(frozen=True) class CountSection: """Records counted by key, busiest first.""" title: str key: Callable[[LogRecord], Hashable] keep: Predicate | None = None top: int | None = None def render(self, records: Sequence[LogRecord]) -> list[str]: counts = count(records, self.key, self.keep).most_common(self.top) return [f" {str(name):<12}{n:>4}" for name, n in counts] or [" (none)"] @dataclass(frozen=True) class ListSection: """The first few records a predicate keeps, one per line.""" title: str keep: Predicate limit: int = 5 def render(self, records: Sequence[LogRecord]) -> list[str]: chosen = [r for r in records if self.keep(r)][: self.limit] return [f" {r.timestamp:%H:%M:%S} {r.level!s:<8} {r.source}: {r.message}" for r in chosen] or [" (none)"] @dataclass(frozen=True) class Report: title: str sections: Sequence[Section] def render(self, records: Iterable[LogRecord]) -> str: rows = list(records) # every section reads them, so read the stream once lines = [self.title, "=" * len(self.title)] for section in self.sections: lines += ["", section.title, *section.render(rows)] return "\n".join(lines) """Keep parsed records in SQLite, and ask questions in SQL.""" import json import logging import sqlite3 from collections.abc import Iterable, Iterator from contextlib import contextmanager from datetime import datetime from typing import Any from analyzer import LogRecord, parse_fields from levels import Level logger = logging.getLogger(__name__) SCHEMA = """ CREATE TABLE IF NOT EXISTS records ( log TEXT NOT NULL, line_number INTEGER NOT NULL, timestamp TEXT NOT NULL, -- ISO 8601, so text order is time order level TEXT NOT NULL, source TEXT NOT NULL, message TEXT NOT NULL, PRIMARY KEY (log, line_number) ) """ def connect(path: str = ":memory:") -> sqlite3.Connection: """Open (or create) a database with the records table in it.""" conn = sqlite3.connect(path) conn.row_factory = sqlite3.Row conn.execute(SCHEMA) return conn @contextmanager def open_store(path: str = ":memory:") -> Iterator[sqlite3.Connection]: """Connect, and close the connection when the with block ends, however it ends.""" conn = connect(path) try: yield conn finally: conn.close() def save_records(conn: sqlite3.Connection, log: str, records: Iterable[LogRecord]) -> int: """Store records read from one log. Lines already stored are skipped. Returns how many new records were stored. """ rows = ((log, r.line_number, r.timestamp.isoformat(sep=" "), r.level.name, r.source, r.message) for r in records) with conn: # one transaction: all rows, or none cursor = conn.executemany( "INSERT INTO records VALUES (?, ?, ?, ?, ?, ?) " "ON CONFLICT (log, line_number) DO NOTHING", rows) logger.info("%s: stored %d new record(s)", log, cursor.rowcount) return cursor.rowcount def record_from_row(row: sqlite3.Row) -> LogRecord: """Rebuild a LogRecord. Fields are re-parsed from the message, not stored twice.""" return LogRecord( timestamp=datetime.fromisoformat(row["timestamp"]), level=Level[row["level"]], source=row["source"], message=row["message"], fields=parse_fields(row["message"]), line_number=row["line_number"], ) def load_records(conn: sqlite3.Connection, log: str) -> list[LogRecord]: rows = conn.execute( "SELECT * FROM records WHERE log = ? ORDER BY line_number", (log,)) return [record_from_row(row) for row in rows] def search(conn: sqlite3.Connection, text: str) -> list[LogRecord]: """Records whose message contains text, oldest first.""" rows = conn.execute( "SELECT * FROM records WHERE message LIKE ? ORDER BY timestamp", (f"%{text}%",)) return [record_from_row(row) for row in rows] COUNTABLE = {"level", "source"} def count_by( conn: sqlite3.Connection, column: str, level: Level | str | None = None ) -> dict[str, int]: """Count records per level or per source, busiest first, optionally for one level.""" if column not in COUNTABLE: # a column name can't be a ? parameter raise ValueError(f"can't count by {column!r}; choose from {sorted(COUNTABLE)}") # Level.from_text accepts "ERROR" or Level.ERROR, and rejects typos. where, params = ("WHERE level = ?", (Level.from_text(str(level)).name,)) if level else ("", ()) rows = conn.execute( f"SELECT {column} AS key, COUNT(*) AS n FROM records {where} " f"GROUP BY {column} ORDER BY n DESC, key", params) return {row["key"]: row["n"] for row in rows} def summary(conn: sqlite3.Connection) -> dict[str, Any]: """Everything the daily report needs, as plain JSON-ready values.""" total, first, last = conn.execute( "SELECT COUNT(*), MIN(timestamp), MAX(timestamp) FROM records").fetchone() hours = conn.execute( "SELECT CAST(substr(timestamp, 12, 2) AS INTEGER) AS hour, COUNT(*) AS n " "FROM records WHERE level = 'ERROR' GROUP BY hour ORDER BY hour") return { "records": total, "first": first, "last": last, "levels": count_by(conn, "level"), "errors_by_source": count_by(conn, "source", level="ERROR"), "errors_by_hour": [{"hour": row["hour"], "errors": row["n"]} for row in hours], } def save_summary(data: dict[str, Any], path: str) -> None: with open(path, "w") as file: json.dump(data, file, indent=2) def load_summary(path: str) -> Any: with open(path) as file: return json.load(file) 2026-09-01 09:14:02 INFO auth: user=ada action=login 2026-09-01 09:14:07 WARNING payments: order=1042 amount=19.99 retry=1 2026-09-01 09:15:30 ERROR payments: order=1043 msg="card declined" 2026-09-01 09:16:11 INFO auth: user=alan action=logout --- rotated at 09:16 --- 2026-09-01 09:17:45 ERROR search: query="python books" msg="index offline" 2026-09-01 9:18 INFO search: query=dune results=4 2026-09-01 09:19:30 ERROR auth: user=grace msg="token expired"

Reproduce. Every test fails. The ones that check an exit code all got 2, and the others find nothing they expected in the output.

Inspect. Exit code 2 means argparse rejected the command line. Look at what it said. The run helper captures stderr, so print it in a scratch line at the end of test_cli.py:

print(run("messy.log"))

stderr says the following arguments are required: logfile. But the test passed "messy.log".

Before you open this, find the fix, which is one word long. Then explain why the program still works perfectly from a terminal. Compare with ours.

Show the diagnosis and the fix

Narrow. Compare how main receives its arguments with how it uses them. It’s main(argv), and then build_parser().parse_args(), with no argv. With no argument, parse_args reads the real command line, sys.argv[1:], which here belongs to whatever started Python. So main ignored the arguments it was given. You can confirm it with print(sys.argv). It’s not the test’s list.

The program would work perfectly when run from a terminal. Only tests, or any other code calling main with arguments, would ever see this. And tests are exactly what main(argv) was written for.

Pin it. The existing tests are the regression tests. Every one of them calls main with explicit arguments.

Fix. Pass them through: build_parser().parse_args(argv). When argv is None, as it is when the program is run for real, parse_args still falls back to sys.argv. So one small change makes both work.

Your turn

Exercise

A --since type

The next feature is --since 09:30: count only records from that time of day onwards. Start with its argument type. Write time_of_day(text): "09:30" becomes time(9, 30), and anything that isn’t a valid 24-hour HH:MM raises argparse.ArgumentTypeError with the message the tests expect.

import argparse
import sys
import unittest
from datetime import time


def time_of_day(text):
    """argparse type for --since: "09:30" becomes time(9, 30)."""
    raise NotImplementedError("your code here")


def build_parser():
    parser = argparse.ArgumentParser(prog="logan")
    parser.add_argument("logfile", nargs="+")
    parser.add_argument("--since", type=time_of_day, metavar="HH:MM")
    return parser


class TestTimeOfDay(unittest.TestCase):
    def test_good_times(self):
        self.assertEqual(time_of_day("09:30"), time(9, 30))
        self.assertEqual(time_of_day("9:05"), time(9, 5))
        self.assertEqual(time_of_day("23:59"), time(23, 59))

    def test_bad_times_are_argument_errors(self):
        for text in ["24:00", "noon", "09:30:15", ""]:
            with self.subTest(text=text):
                with self.assertRaises(argparse.ArgumentTypeError) as caught:
                    time_of_day(text)
                self.assertEqual(str(caught.exception),
                                 f"expected a time like 09:30, not {text!r}")

    def test_the_parser_uses_it(self):
        args = build_parser().parse_args(["messy.log", "--since", "09:16"])
        self.assertEqual(args.since, time(9, 16))


suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestTimeOfDay)
unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite)
Hint 1: the idea

Checking a time by hand — splitting on : and testing ranges — is easy to get subtly wrong. You met a parser on Day 2 that turns text into a date or time according to a format, and refuses anything that doesn’t fit it exactly. Let it do the checking; your job is to turn its refusal into the error argparse knows how to show.

Hint 2: a first step

datetime.strptime("09:30", "%H:%M") returns a datetime, and .time() on that is time(9, 30). Try it on "24:00" and "9:30:15" to see it refuse. Then catch its ValueError and raise argparse.ArgumentTypeError with the message the tests expect.

Show one solution
from datetime import datetime, time


def time_of_day(text):
    """argparse type for --since: "09:30" becomes time(9, 30)."""
    try:
        return datetime.strptime(text, "%H:%M").time()
    except ValueError:
        raise argparse.ArgumentTypeError(f"expected a time like 09:30, not {text!r}") from None

strptime with "%H:%M" does all the checking. %H accepts 9 or 09, but only 0 to 23, so 24:00 fails. And the whole string must match the format, so 09:30:15 fails on the leftover :15.

time.fromisoformat looks like a shortcut, but it’s more forgiving than you want here. It accepts "09" as nine o’clock and "24:00" as midnight. When you’re validating what a person typed, choose the parser whose rules match the ones you’d write in the --help text.

from None hides strptime’s own error. argparse shows the user only your message, so make it say what a good value looks like.

What you can now do

  • Describe a command line with argparse: positionals, options, flags, repeatable options and custom types.
  • Report outcomes with exit codes: 0, 1, and argparse’s 2.
  • Write main(argv=None) -> int, so a program can be run from the terminal and called from tests alike.
  • Recognise parse_args() silently reading the wrong command line.

Next: the last feature, the README, and shipping it.

Last updated on September 18, 2026

Was this article helpful?

Your response is saved on this device.