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:
Turning a list of words into settings is the job of argparse, in the standard
library.
A parser in a few lines
argparsePython
import argparseparser = 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 as
Means
"logfile"
a positional argument, required, identified by its position
nargs="+"
one or more of them, collected into a list
"--top", type=int
an 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
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 0if __name__ == "__main__": sys.exit(main())
mainreturns 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 testsPython"""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 argparseimport sysimport unittestfrom datetime import timedef 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 parserclass 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, timedef 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.
Your progress is saved in this browser.
Sign in
to keep it on every device.