1. Predict both lines. The second is Day 4’s bug hunt in miniature.
Warm-up: predictPython
def make_multiplier(n): def times(x): return x * n return timesdouble = 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 onei, 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: fixPython
from contextlib import contextmanagerlog = []@contextmanagerdef tracked(name): log.append(f"open {name}") yield name log.append(f"close {name}")try: with tracked("report"): raise ValueError("boom")except ValueError: passprint(log)
Check your fix
from contextlib import contextmanagerlog = []@contextmanagerdef 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: passprint(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. Writecount(items, key), returning a Counter of key(item). This
should print {3: 2, 5: 1}:
from collections import Counterdef 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 endedPython"""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 underlineclass ErrorReport(Report): # + errors by sourceclass HourlyErrorReport(ErrorReport): # + errors by hourclass PaymentsReport(Report): # + payments warningsclass 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 reportPython
from collections import Counterfrom dataclasses import dataclass@dataclass(frozen=True)class LogRecord: hour: int level: str source: str message: strRECORDS = [ 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:
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 reportPython"""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.
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.
Your progress is saved in this browser.
Sign in
to keep it on every device.