Reports Made of Parts

The Course
•
September 18, 2026
•
9 min read

Warm-up: from Day 4

From memory first.

1. Predict both lines. The second is Day 4’s bug hunt in miniature.

Warm-up: predict Python
def make_multiplier(n):
    def times(x):
        return x * n
    return times

double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5), triple(5))

adders = [lambda x: x + i for i in range(3)]
print([add(10) for add in adders])
Check your prediction
10 15
[12, 12, 12]

Each call to make_multiplier makes a new n, so each function keeps its own. The lambdas in the loop all share one i, and read it when they’re called — after the loop has left it at 2. From Functions That Build Functions.

2. Fix. The log should show the context closing even when the block fails: ['open report', 'close report'].

Warm-up: fix Python
from contextlib import contextmanager

log = []

@contextmanager
def tracked(name):
    log.append(f"open {name}")
    yield name
    log.append(f"close {name}")

try:
    with tracked("report"):
        raise ValueError("boom")
except ValueError:
    pass

print(log)
Check your fix
from contextlib import contextmanager

log = []

@contextmanager
def tracked(name):
    log.append(f"open {name}")
    try:
        yield name
    finally:
        log.append(f"close {name}")

try:
    with tracked("report"):
        raise ValueError("boom")
except ValueError:
    pass

print(log)
['open report', 'close report']

The exception comes out of the yield, so anything after it is skipped unless it’s in a finally. From Cleanup You Can Count On.

3. Write count(items, key), returning a Counter of key(item). This should print {3: 2, 5: 1}:

Warm-up: write Python
from collections import Counter

# def count(items, key): ...

print(dict(count(["cat", "dog", "horse"], key=len)))
Check your answer
from collections import Counter

def count(items, key):
    return Counter(key(item) for item in items)

print(dict(count(["cat", "dog", "horse"], key=len)))
{3: 2, 5: 1}

The caller decides what to count by; count only counts. From Functions as Arguments.

Where Day 4 ended

Here’s where Day 4 ended: an analyzer configured by functions you pass in.

Where Day 4 ended Python
"""The Day 4 report: what to count, and how, is decided by the functions passed in.""" import sys import unittest import test_analyzer import test_filters import test_store from analyzer import count, parse_lines, read_lines from filters import all_of, level_at_least, source_is from store import load_records, open_store, save_records errors = [] with open_store() as conn: save_records(conn, "messy.log", parse_lines(read_lines("messy.log"), on_error=errors.append)) records = load_records(conn, "messy.log") print(f"{len(records)} records, {len(errors)} bad line(s)") by_source = count(records, key=lambda r: r.source, keep=level_at_least("WARNING")) print("Warnings and worse, by source:", dict(by_source)) payments = all_of(source_is("payments"), level_at_least("WARNING")) by_hour = count(records, key=lambda r: r.timestamp.hour, keep=payments) print("Payment problems, by hour: ", dict(by_hour)) print() loader = unittest.defaultTestLoader suite = unittest.TestSuite(loader.loadTestsFromModule(module) for module in (test_analyzer, test_filters, test_store)) unittest.TextTestRunner(stream=sys.stdout, verbosity=1).run(suite) """Log analyzer, Day 4: parsing and counting, configured by the functions you pass in.""" import re from collections import Counter from dataclasses import dataclass, field from datetime import datetime LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"} # 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: str source: str message: str fields: dict[str, str] = field(default_factory=dict) line_number: int = 0 class ParseError(ValueError): """A line that is not a valid log record.""" def __init__(self, line_number, reason, line): super().__init__(f"line {line_number}: {reason}: {line!r}") self.line_number = line_number self.reason = reason self.line = line def parse_fields(text): """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, line_number=0): """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, rest = parts try: timestamp = datetime.fromisoformat(f"{date} {time}") except ValueError: raise ParseError(line_number, "bad timestamp", line) from None if level not in LEVELS: raise ParseError(line_number, f"unknown level {level!r}", line) 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): """Yield the lines of a file one at a time, without line endings.""" with open(path) as file: for line in file: yield line.rstrip("\n") def parse_lines(lines, on_error=None): """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(records, key, keep=None): """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): """Count levels and errors per source in a single pass.""" levels, error_sources = Counter(), Counter() for record in records: levels[record.level] += 1 if record.level == "ERROR": error_sources[record.source] += 1 return levels, error_sources """Ways to choose records: each function builds a predicate, a yes/no question about one record.""" LEVEL_ORDER = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] def level_at_least(minimum): """Keep records at minimum or more severe: level_at_least("WARNING").""" rank = LEVEL_ORDER.index(minimum) # an unknown level fails now, not later def keep(record): return LEVEL_ORDER.index(record.level) >= rank return keep def source_is(*sources): """Keep records from any of the given sources.""" wanted = set(sources) def keep(record): return record.source in wanted return keep def between(start, end): """Keep records from start (included) up to end (not included).""" def keep(record): return start <= record.timestamp < end return keep def all_of(*predicates): """Keep records that every predicate keeps.""" def keep(record): return all(predicate(record) for predicate in predicates) return keep """Log analyzer, Day 4: keep parsed records in SQLite, and ask questions in SQL.""" import json import sqlite3 from contextlib import contextmanager from datetime import datetime from analyzer import LogRecord, parse_fields 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=":memory:"): """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=":memory:"): """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, log, records): """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, 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) return cursor.rowcount def record_from_row(row): """Rebuild a LogRecord. Fields are re-parsed from the message, not stored twice.""" return LogRecord( timestamp=datetime.fromisoformat(row["timestamp"]), level=row["level"], source=row["source"], message=row["message"], fields=parse_fields(row["message"]), line_number=row["line_number"], ) def load_records(conn, log): rows = conn.execute( "SELECT * FROM records WHERE log = ? ORDER BY line_number", (log,)) return [record_from_row(row) for row in rows] def like_pattern(text): """A LIKE pattern matching text anywhere, with LIKE's own wildcards made literal.""" for special in ("\\", "%", "_"): # the escape character goes first text = text.replace(special, "\\" + special) return f"%{text}%" def search(conn, text): """Records whose message contains text (ignoring ASCII case), oldest first.""" rows = conn.execute( "SELECT * FROM records WHERE message LIKE ? ESCAPE '\\' ORDER BY timestamp", (like_pattern(text),)) return [record_from_row(row) for row in rows] COUNTABLE = {"level", "source"} def count_by(conn, column, level=None): """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)}") where, params = ("WHERE level = ?", (level,)) 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): """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, path): with open(path, "w") as file: json.dump(data, file, indent=2) def load_summary(path): with open(path) as file: return json.load(file) import unittest from datetime import datetime from analyzer import (ParseError, count, parse_fields, parse_lines, parse_record, summarize) GOOD = "2026-09-01 09:00:00 INFO a: x=1" class TestParseRecord(unittest.TestCase): def test_prefix_becomes_fields(self): record = parse_record("2026-09-01 09:14:02 INFO auth: user=ada action=login") self.assertEqual(record.timestamp, datetime(2026, 9, 1, 9, 14, 2)) self.assertEqual((record.level, record.source), ("INFO", "auth")) def test_message_has_no_leading_space(self): record = parse_record("2026-09-01 09:14:02 INFO auth: user=ada") self.assertEqual(record.message, "user=ada") def test_each_kind_of_bad_line_is_reported(self): cases = { "garbage": "too few parts", "2026-13-01 09:00:00 INFO a: x=1": "bad timestamp", "2026-09-01 09:00:00 LOUD a: x=1": "unknown level 'LOUD'", "2026-09-01 09:00:00 INFO no colon here": "missing 'source:'", } for line, reason in cases.items(): with self.subTest(line=line): with self.assertRaises(ParseError) as caught: parse_record(line, 7) self.assertEqual(caught.exception.reason, reason) class TestParseFields(unittest.TestCase): def test_two_quoted_values_stay_separate(self): fields = parse_fields('query="python books" msg="index offline"') self.assertEqual(fields, {"query": "python books", "msg": "index offline"}) class TestParseLines(unittest.TestCase): def test_bad_lines_go_to_on_error(self): errors = [] records = list(parse_lines(["garbage", "", GOOD], on_error=errors.append)) self.assertEqual(len(records), 1) self.assertEqual([e.line_number for e in errors], [1]) def test_without_on_error_the_first_bad_line_stops_parsing(self): with self.assertRaises(ParseError): list(parse_lines([GOOD, "garbage", GOOD])) def test_every_line_is_accounted_for(self): lines = ["garbage", "", GOOD, " "] errors = [] records = list(parse_lines(lines, on_error=errors.append)) self.assertEqual(len(records) + len(errors), 2) def test_errors_are_known_once_the_records_are_read(self): # bug hunt 1 errors = [] records = parse_lines(["garbage", GOOD], on_error=errors.append) self.assertEqual(errors, []) # a generator: nothing has run yet list(records) self.assertEqual(len(errors), 1) class TestCounting(unittest.TestCase): def test_summary_works_on_a_generator(self): lines = ["2026-09-01 09:00:00 ERROR db: x=1", "2026-09-01 09:00:01 INFO db: x=2"] levels, errors = summarize(parse_lines(iter(lines))) self.assertEqual(levels, {"ERROR": 1, "INFO": 1}) self.assertEqual(errors, {"db": 1}) def test_count_with_a_key_and_a_filter(self): records = list(parse_lines([ "2026-09-01 09:00:00 ERROR db: x=1", "2026-09-01 10:00:00 ERROR db: x=2", "2026-09-01 10:30:00 INFO db: x=3", ])) by_hour = count(records, key=lambda r: r.timestamp.hour, keep=lambda r: r.level == "ERROR") self.assertEqual(by_hour, {9: 1, 10: 1}) import unittest from datetime import datetime from analyzer import parse_record from filters import LEVEL_ORDER, all_of, between, level_at_least, source_is def record(level="INFO", source="auth", hour=9): return parse_record(f"2026-09-01 {hour:02}:00:00 {level} {source}: x=1") class TestFilters(unittest.TestCase): def test_level_at_least(self): keep = level_at_least("WARNING") self.assertEqual([keep(record(level)) for level in LEVEL_ORDER], [False, False, True, True, True]) def test_an_unknown_level_fails_when_the_filter_is_made(self): with self.assertRaises(ValueError): level_at_least("WARN") def test_source_is_any_of(self): keep = source_is("auth", "db") self.assertEqual([keep(record(source=s)) for s in ("auth", "db", "search")], [True, True, False]) def test_between_includes_start_excludes_end(self): keep = between(datetime(2026, 9, 1, 9), datetime(2026, 9, 1, 10)) self.assertEqual([keep(record(hour=h)) for h in (8, 9, 10)], [False, True, False]) def test_all_of(self): keep = all_of(level_at_least("ERROR"), source_is("db")) self.assertTrue(keep(record("ERROR", "db"))) self.assertFalse(keep(record("ERROR", "auth"))) self.assertFalse(keep(record("INFO", "db"))) def test_filters_made_in_a_loop_are_all_different(self): # bug hunt 2 by_level = {level: level_at_least(level) for level in LEVEL_ORDER} self.assertTrue(by_level["DEBUG"](record("INFO"))) self.assertFalse(by_level["ERROR"](record("INFO"))) import sqlite3 import unittest from dataclasses import replace from analyzer import ParseError, parse_record from store import (connect, count_by, load_records, load_summary, open_store, save_records, save_summary, search, summary) LINES = [ "2026-09-01 09:14:02 INFO auth: user=ada action=login", "2026-09-01 09:15:30 ERROR payments: order=1043 msg=\"card declined\"", "2026-09-01 10:02:11 ERROR db: msg=\"can't reach replica\"", ] def records(): return [parse_record(line, number) for number, line in enumerate(LINES, start=1)] class TestStore(unittest.TestCase): def setUp(self): self.conn = connect() # a new, empty database for every test self.addCleanup(self.conn.close) def test_records_come_back_equal(self): # bug hunt 1 save_records(self.conn, "a.log", records()) self.assertEqual(load_records(self.conn, "a.log"), records()) def test_search_text_with_a_quote(self): # bug hunt 2 save_records(self.conn, "a.log", records()) found = search(self.conn, "can't") self.assertEqual([r.source for r in found], ["db"]) def test_search_is_not_sql(self): save_records(self.conn, "a.log", records()) self.assertEqual(search(self.conn, "x' OR '1'='1"), []) def test_saving_the_same_log_twice_stores_it_once(self): # bug hunt 3 self.assertEqual(save_records(self.conn, "a.log", records()), 3) self.assertEqual(save_records(self.conn, "a.log", records()), 0) self.assertEqual(count_by(self.conn, "level"), {"ERROR": 2, "INFO": 1}) def test_search_results_are_oldest_first(self): # Day 3 exercise save_records(self.conn, "a.log", reversed(records())) # stored newest first self.assertEqual([r.source for r in search(self.conn, "msg=")], ["payments", "db"]) def test_search_treats_wildcards_as_text(self): save_records(self.conn, "a.log", records()) self.assertEqual(search(self.conn, "%"), []) self.assertEqual(search(self.conn, "_"), []) def test_a_failed_save_stores_nothing(self): # Day 3 exercise good = records() bad = replace(good[1], source=None) # NOT NULL: the database refuses it with self.assertRaises(sqlite3.IntegrityError): save_records(self.conn, "a.log", [good[0], bad, good[2]]) self.assertEqual(count_by(self.conn, "level"), {}) def test_a_stream_that_fails_part_way_stores_nothing(self): def stream(): yield records()[0] raise ParseError(2, "too few parts", "garbage") # fail-fast parsing, mid-save with self.assertRaises(ParseError): save_records(self.conn, "a.log", stream()) self.assertEqual(count_by(self.conn, "level"), {}) def test_count_by_one_level(self): save_records(self.conn, "a.log", records()) self.assertEqual(count_by(self.conn, "source", level="ERROR"), {"db": 1, "payments": 1}) def test_count_by_refuses_other_columns(self): with self.assertRaises(ValueError): count_by(self.conn, "message; DROP TABLE records") class TestSummary(unittest.TestCase): def test_summary_survives_a_round_trip_through_json(self): # bug hunt 4 conn = connect() self.addCleanup(conn.close) save_records(conn, "a.log", records()) data = summary(conn) save_summary(data, "test-summary.json") self.assertEqual(load_summary("test-summary.json"), data) self.assertEqual(data["errors_by_hour"], [{"hour": 9, "errors": 1}, {"hour": 10, "errors": 1}]) class TestOpenStore(unittest.TestCase): def test_closes_the_connection_even_when_the_block_fails(self): # Day 4, bug hunt 3 with self.assertRaises(ZeroDivisionError): with open_store() as conn: 1 / 0 with self.assertRaises(sqlite3.ProgrammingError): # "Cannot operate on a closed database" conn.execute("SELECT 1") 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"

The questions have become reports. The on-call engineer wants errors by source and by hour. The payments team wants their own warnings and a list of the latest failures. A manager wants one line: how many records, how many errors. Each is a few counts and lists under headings, in a different combination.

The inheritance trap

A first instinct is often a class hierarchy: a base Report that prints a title, and subclasses that add parts.

class Report:                        # title and underline
class ErrorReport(Report):           # + errors by source
class HourlyErrorReport(ErrorReport):    # + errors by hour
class PaymentsReport(Report):        # + payments warnings
class PaymentsHourlyReport(...):     # errors by hour, but only payments... which parent?

It works for the first two reports. By the fifth, each new combination needs a new class, parents get changed to suit one child, and “which class prints the hourly part?” takes a search. Inheritance is good at “a kind of” relationships, and a report isn’t a kind of another report. It’s made of parts.

Composition: a report has sections

So model exactly that. A section is a small object with a title and a render(records) method that returns its lines. A report is a title plus a list of sections, and it renders by asking each section in turn:

Sections and a report Python
from collections import Counter
from dataclasses import dataclass


@dataclass(frozen=True)
class LogRecord:
    hour: int
    level: str
    source: str
    message: str


RECORDS = [
    LogRecord(9, "INFO", "auth", "user=ada action=login"),
    LogRecord(9, "ERROR", "payments", "order=1043 card declined"),
    LogRecord(9, "ERROR", "search", "index offline"),
    LogRecord(10, "ERROR", "payments", "order=1050 timeout"),
]


@dataclass(frozen=True)
class CountSection:
    """Records counted by key, busiest first."""
    title: str
    key: object              # a function: record -> what to count by
    keep: object = None      # a function: record -> bool, or None for every record

    def render(self, records):
        counts = Counter(self.key(r) for r in records if self.keep is None or self.keep(r))
        return [f"  {name!s:<12}{n:>4}" for name, n in counts.most_common()] or ["  (none)"]


@dataclass(frozen=True)
class ListSection:
    """The first few records a filter keeps."""
    title: str
    keep: object
    limit: int = 5

    def render(self, records):
        chosen = [r for r in records if self.keep(r)][: self.limit]
        return [f"  {r.hour:02}:00 {r.source}: {r.message}" for r in chosen] or ["  (none)"]


@dataclass(frozen=True)
class Report:
    title: str
    sections: list

    def render(self, records):
        lines = [self.title, "=" * len(self.title)]
        for section in self.sections:
            lines += ["", section.title, *section.render(records)]
        return "\n".join(lines)


def is_error(record):
    return record.level == "ERROR"


on_call = Report("On-call summary", [
    CountSection("Errors by source", key=lambda r: r.source, keep=is_error),
    CountSection("Errors by hour", key=lambda r: r.hour, keep=is_error),
    ListSection("First errors", keep=is_error, limit=2),
])
print(on_call.render(RECORDS))

Every report is now just data: a title and a list. The payments team’s report is a different list of the same parts, with no new class. And each part is a frozen dataclass whose fields are exactly its settings, so a section is easy to read, compare and test on its own.

Two small details:

  • [... ] or [" (none)"] uses the fact that an empty list is falsy, so a section with nothing to show still says so, instead of leaving a heading with no lines under it.
  • [..., *section.render(records)] unpacks the section’s lines into the list being built.

Anything that renders is a section

Report.render never checks what kind of section it has. It only calls section.title and section.render(records). Any object with those two works, including a class written long after Report:

@dataclass(frozen=True)
class NoteSection:
    title: str
    text: str

    def render(self, records):
        return [f"  {self.text}"]

Adding it to a report changes nothing in Report. Python’s name for this is duck typing: if it has a title and a render, it’s a section. The next lesson, on type hints, shows how to write that expectation down, so a checker can hold you to it.

Bug hunt: the sections that went quiet

Here’s the analyzer’s first real report.py, used for the daily report. The first section is right. The next two say (none), and messy.log certainly has warnings and errors:

The daily report Python
"""The daily report, now built from sections.""" from analyzer import parse_lines, read_lines from filters import level_at_least from report import CountSection, ListSection, Report errors = [] daily = Report("Daily report: messy.log", [ CountSection("Levels", key=lambda r: r.level), CountSection("Warnings and worse, by source", key=lambda r: r.source, keep=level_at_least("WARNING")), ListSection("First errors", keep=level_at_least("ERROR"), limit=3), ]) print(daily.render(parse_lines(read_lines("messy.log"), on_error=errors.append))) print(f"\n({len(errors)} bad line(s) skipped)") """Reports made of sections. Each section is a small object that renders itself.""" from dataclasses import dataclass from analyzer import count @dataclass(frozen=True) class CountSection: """Records counted by key, busiest first.""" title: str key: object # a function: record -> what to count by keep: object = None # a function: record -> bool, or None for every record top: object = None # how many rows to show, or None for all of them def render(self, records): 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 filter keeps, one per line.""" title: str keep: object limit: int = 5 def render(self, records): chosen = [r for r in records if self.keep(r)][: self.limit] return [f" {r.timestamp:%H:%M:%S} {r.level:<8} {r.source}: {r.message}" for r in chosen] or [" (none)"] @dataclass(frozen=True) class Report: title: str sections: list def render(self, records): lines = [self.title, "=" * len(self.title)] for section in self.sections: lines += ["", section.title, *section.render(records)] return "\n".join(lines) """Log analyzer, Day 4: parsing and counting, configured by the functions you pass in.""" import re from collections import Counter from dataclasses import dataclass, field from datetime import datetime LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"} # 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: str source: str message: str fields: dict[str, str] = field(default_factory=dict) line_number: int = 0 class ParseError(ValueError): """A line that is not a valid log record.""" def __init__(self, line_number, reason, line): super().__init__(f"line {line_number}: {reason}: {line!r}") self.line_number = line_number self.reason = reason self.line = line def parse_fields(text): """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, line_number=0): """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, rest = parts try: timestamp = datetime.fromisoformat(f"{date} {time}") except ValueError: raise ParseError(line_number, "bad timestamp", line) from None if level not in LEVELS: raise ParseError(line_number, f"unknown level {level!r}", line) 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): """Yield the lines of a file one at a time, without line endings.""" with open(path) as file: for line in file: yield line.rstrip("\n") def parse_lines(lines, on_error=None): """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(records, key, keep=None): """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): """Count levels and errors per source in a single pass.""" levels, error_sources = Counter(), Counter() for record in records: levels[record.level] += 1 if record.level == "ERROR": error_sources[record.source] += 1 return levels, error_sources """Ways to choose records: each function builds a predicate, a yes/no question about one record.""" LEVEL_ORDER = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] def level_at_least(minimum): """Keep records at minimum or more severe: level_at_least("WARNING").""" rank = LEVEL_ORDER.index(minimum) # an unknown level fails now, not later def keep(record): return LEVEL_ORDER.index(record.level) >= rank return keep def source_is(*sources): """Keep records from any of the given sources.""" wanted = set(sources) def keep(record): return record.source in wanted return keep def between(start, end): """Keep records from start (included) up to end (not included).""" def keep(record): return start <= record.timestamp < end return keep def all_of(*predicates): """Keep records that every predicate keeps.""" def keep(record): return all(predicate(record) for predicate in predicates) return keep 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 time. The first section always works, and every section after it is empty, whichever order you put them in. Try swapping two.

Inspect. What does each section receive? Add a line at the top of CountSection.render in report.py:

        print("render got", type(records).__name__)

It prints render got generator.

Your turn, before you open this. The input is already small, so start with the test: write one that renders a two-section report from a generator and fails now. Then fix Report, not the sections, and compare with ours.

Show the diagnosis and the fix

Narrow. main.py passes parse_lines(...), a generator, straight to Report.render, which hands that same generator to every section. The first section reads it to the end. The others get an empty stream, and each renders (none). It’s Day 2’s bug again, in a new place. That’s worth noticing: a design where several parts read the same input invites it.

Pin it. A test that renders a two-section report from a generator:

def test_every_section_sees_every_record(self):
    report = Report("Test", [CountSection("A", key=lambda r: r.level),
                             CountSection("B", key=lambda r: r.level)])
    text = report.render(parse_lines(LINES))            # a generator, read once
    self.assertEqual(text.count("INFO           1"), 2)

Fix. The report is the one place that knows its sections will each read the records, so it reads the stream once and gives every section the list:

    def render(self, records):
        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)

Memory is the cost. A report needs all its records at once. That’s a fair trade for a daily report, and exactly the kind of decision that should be made once, visibly, in one place.

Your turn

Exercise

A section of your own

Write ShareSection(title, keep), a section that shows what share of the records keep accepts, like 2 of 4 (50.0%). With no records at all it should say 0 of 0, not divide by zero.

import sys
import unittest
from dataclasses import dataclass


@dataclass(frozen=True)
class LogRecord:
    level: str
    source: str


# Write ShareSection here.


RECORDS = [LogRecord("INFO", "auth"), LogRecord("ERROR", "db"),
           LogRecord("ERROR", "db"), LogRecord("WARNING", "auth")]


class TestShareSection(unittest.TestCase):
    def test_share(self):
        section = ShareSection("Errors", keep=lambda r: r.level == "ERROR")
        self.assertEqual(section.render(RECORDS), ["  2 of 4 (50.0%)"])

    def test_one_decimal_place(self):
        section = ShareSection("From auth, not INFO",
                               keep=lambda r: r.source == "auth" and r.level != "INFO")
        self.assertEqual(section.render(RECORDS[:3]), ["  0 of 3 (0.0%)"])

    def test_no_records(self):
        section = ShareSection("Errors", keep=lambda r: r.level == "ERROR")
        self.assertEqual(section.render([]), ["  0 of 0"])

    def test_it_has_a_title_like_every_section(self):
        self.assertEqual(ShareSection("Errors", keep=bool).title, "Errors")


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

A section is a small frozen dataclass with a title and a render(records) method that returns a list of lines — like CountSection above. This one also carries keep. render needs two numbers, how many records and how many were kept, and one special case: no records at all.

Hint 2: a first step

Start with the fields, title and keep, and a render that returns [f" {kept} of {total}"]. Then handle total == 0 before any division, and add the percentage: the format spec :.1% turns a fraction into a percentage with one decimal place.

Show one solution
@dataclass(frozen=True)
class ShareSection:
    """What share of the records keep() accepts."""
    title: str
    keep: object

    def render(self, records):
        total = len(records)
        kept = sum(1 for r in records if self.keep(r))
        if total == 0:
            return ["  0 of 0"]
        return [f"  {kept} of {total} ({kept / total:.1%})"]

len(records) and a second pass with sum both need a list, not a stream. That’s fine, because the fixed Report always hands its sections a list. It’s also exactly the kind of assumption the next lesson’s type hints let you write down: this method needs a sequence, not just anything iterable.

{kept / total:.1%} multiplies by 100 and adds the % sign itself, with one decimal place.

What you can now do

  • Recognise when a class hierarchy is standing in for “made of”, and use composition instead.
  • Build a report from small frozen-dataclass parts that are configured by data.
  • Rely on duck typing: anything with the right methods can take part.
  • Decide, in one place, when a stream must become a list.

Next: writing down what each function expects and returns, with type hints.

Last updated on September 18, 2026

Was this article helpful?

Your response is saved on this device.