Checkpoint: A Configurable Analyzer

The Course
•
September 18, 2026
•
7 min read

Today you took the decisions out of the analyzer and handed them to its caller: what to count by, which records to count, what to do with a bad line. The analyzer does the parsing and counting. The caller supplies the judgement, as functions.

The checkpoint puts all of that together, and adds the one piece of the day the analyzer hasn’t used yet: a context manager of your own.

Day 4 checkpoint

Exercise

analyze, field_equals and error_log

report.py needs three things. analyzer.py and filters.py are today’s versions, ready to import.

  • analyze(lines, *, key, keep=None, on_error=None) parses the lines and counts the records, like count, with bad lines handled the same way as in parse_lines. The * makes everything after it keyword-only, so callers have to write key=..., which keeps call sites readable.
  • field_equals(name, value) is a filter factory. It keeps records whose message has the field name=value.
  • error_log(path) is a context manager. It opens path for writing and gives the with block an on_error callback that writes each error to the file, one per line. The file must be closed however the block ends.

The last two tests prove the cleanup. A small helper in test_report.py, files_opened(), records every file opened inside its block, so the tests can check that error_log’s file is closed afterwards: once when the block ends normally, and once when it fails halfway through. You don’t need to change or understand the helper to pass them. It swaps in a recording version of open for the length of the test.

"""Day 4 checkpoint: a configurable analyzer. The tests are the spec.""" from contextlib import contextmanager from analyzer import parse_lines def analyze(lines, *, key, keep=None, on_error=None): """Parse lines and count the records by key(record), keeping only those keep() accepts. Bad lines go to on_error; with no on_error, the first one is raised. """ raise NotImplementedError("your code here") def field_equals(name, value): """A filter: keep records whose message has the field name=value.""" raise NotImplementedError("your code here") @contextmanager def error_log(path): """Open path for writing and give the with block an on_error callback that writes each error to it, one per line. The file is closed however the block ends.""" raise NotImplementedError("your code here") yield import builtins import unittest from contextlib import contextmanager from unittest import mock from analyzer import ParseError from filters import level_at_least from report import analyze, error_log, field_equals 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\"", "--- rotated at 09:16 ---", "2026-09-01 09:17:45 WARNING auth: user=ada action=retry", "2026-09-01 10:02:11 ERROR auth: user=alan msg=\"token expired\"", ] GOOD_LINES = [line for line in LINES if not line.startswith("---")] class TestAnalyze(unittest.TestCase): def test_counts_by_key(self): self.assertEqual(analyze(GOOD_LINES, key=lambda r: r.level), {"INFO": 1, "ERROR": 2, "WARNING": 1}) def test_keep_filters_before_counting(self): by_source = analyze(GOOD_LINES, key=lambda r: r.source, keep=level_at_least("WARNING")) self.assertEqual(by_source, {"payments": 1, "auth": 2}) def test_bad_lines_go_to_on_error(self): errors = [] analyze(LINES, key=lambda r: r.level, on_error=errors.append) self.assertEqual([e.line_number for e in errors], [3]) def test_without_on_error_a_bad_line_is_raised(self): with self.assertRaises(ParseError): analyze(LINES, key=lambda r: r.level) class TestFieldEquals(unittest.TestCase): def test_keeps_records_with_that_field_value(self): by_hour = analyze(GOOD_LINES, key=lambda r: r.timestamp.hour, keep=field_equals("user", "ada")) self.assertEqual(by_hour, {9: 2}) def test_a_missing_field_is_not_a_match(self): self.assertEqual(analyze(GOOD_LINES, key=lambda r: r.level, keep=field_equals("order", "1")), {}) @contextmanager def files_opened(): """Collect every file opened inside the block, so a test can check it was closed.""" opened, real_open = [], builtins.open def recording_open(*args, **kwargs): file = real_open(*args, **kwargs) opened.append(file) return file with mock.patch("builtins.open", recording_open): yield opened class TestErrorLog(unittest.TestCase): def test_writes_one_line_per_error(self): with error_log("errors.txt") as on_error: analyze(LINES + ["garbage"], key=lambda r: r.level, on_error=on_error) with open("errors.txt") as file: self.assertEqual(file.read().splitlines(), [ "line 3: bad timestamp: '--- rotated at 09:16 ---'", "line 6: too few parts: 'garbage'", ]) def test_the_log_is_complete_even_when_the_block_fails(self): shifts = {9: "morning"} # no entry for 10:00, the last line with self.assertRaises(KeyError): with error_log("errors.txt") as on_error: analyze(LINES, key=lambda r: shifts[r.timestamp.hour], on_error=on_error) with open("errors.txt") as file: self.assertEqual(file.read(), "line 3: bad timestamp: '--- rotated at 09:16 ---'\n") def test_the_file_is_closed_when_the_block_ends(self): with files_opened() as opened: with error_log("errors.txt") as on_error: analyze(LINES, key=lambda r: r.level, on_error=on_error) self.assertEqual([file.closed for file in opened], [True]) def test_the_file_is_closed_even_when_the_block_fails(self): with files_opened() as opened: with self.assertRaises(KeyError): with error_log("errors.txt") as on_error: analyze(LINES, key=lambda r: r.fields["no such field"], on_error=on_error) self.assertEqual([file.closed for file in opened], [True]) """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 import sys import unittest import test_report suite = unittest.defaultTestLoader.loadTestsFromModule(test_report) unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite)
Hint 1: the idea

Each of the three is one of today’s lessons, and each is small because its pieces already exist in analyzer.py. analyze joins parse_lines to count. field_equals is a filter factory, shaped like message_contains. error_log is a @contextmanager whose body is a with open(...) that yields a function.

Hint 2: a first step

Write analyze first. Look at what parse_lines takes and returns, and what count takes — the whole function turns out to be one line. Then field_equals: a parsed record keeps its message’s fields in record.fields.

Show one solution
from contextlib import contextmanager

from analyzer import count, parse_lines


def analyze(lines, *, key, keep=None, on_error=None):
    """Parse lines and count the records by key(record), keeping only those keep() accepts.

    Bad lines go to on_error; with no on_error, the first one is raised.
    """
    return count(parse_lines(lines, on_error), key, keep)


def field_equals(name, value):
    """A filter: keep records whose message has the field name=value."""
    def keep(record):
        return record.fields.get(name) == value

    return keep


@contextmanager
def error_log(path):
    """Open path for writing and give the with block an on_error callback that
    writes each error to it, one per line. The file is closed however the block ends."""
    with open(path, "w") as file:
        def write(error):
            file.write(f"{error}\n")

        yield write

analyze is one line, because the pieces were built to fit together. It passes its arguments straight through to functions that already have the right defaults. Composing existing functions, instead of writing a new loop, is what today’s work bought.

field_equals uses .get(name), which returns None for a missing field. So a record without the field doesn’t match, and nothing raises. record.fields[name] would crash on the first record that doesn’t mention that field.

error_log needs no try/finally of its own, because with open(...) around the yield already provides one: if the block raises, the exception arrives at the yield, the with closes the file, and the exception carries on. The callback it yields is a closure over file. Context managers and closures compose too.

The Day 4 reference solution

The analyzer at the end of Day 4, and where Day 5 starts. It has a new module, filters.py, which holds today’s filter factories. parse_lines takes on_error, analyzer.py gained count, and store.py gained open_store. Twenty-eight tests, including one for each of today’s three bug hunts, and Day 3’s tests that a failed save stores nothing.

Day 4 reference: configured by functions 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"

Working on your own machine? Download the Day 4 files and carry on there. You need Python 3.12 or later, and nothing else.

Going further: decorators

Optional

Nothing later in the course depends on this section.

Today’s course plan said: a decorator only if something genuinely repeats. The analyzer doesn’t need one yet, and that’s a fine outcome. But here’s what it would look like if it did.

Suppose you want to know how long each stage takes. You’d write start = time.perf_counter() before a call and print(...) after it, around the parse, around the save, and around every count. When the same wrapping code surrounds many different functions, a decorator can hold it in one place:

A timing decorator Python
import functools
import time


def timed(function):
    """Print how long each call of function takes."""
    @functools.wraps(function)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        try:
            return function(*args, **kwargs)
        finally:
            elapsed = time.perf_counter() - start
            print(f"{function.__name__} took {elapsed * 1000:.1f} ms")

    return wrapper


@timed
def total(n):
    """Add up the numbers below n."""
    return sum(range(n))


@timed
def squares(n):
    """Yield the squares below n, one at a time."""
    for i in range(n):
        yield i * i


print(total(2_000_000))
print(total.__name__, "-", total.__doc__)

numbers = squares(2_000_000)          # "took 0.0 ms": nothing has run yet
print(sum(numbers))

A decorator is a function factory like the ones from this morning: it takes a function and returns a new one. @timed above def total is shorthand for total = timed(total).

Three details matter:

  • *args, **kwargs let the wrapper accept whatever the original accepts, and pass it all on unchanged.
  • functools.wraps copies the original’s name and docstring onto the wrapper. Remove that line and total.__name__ becomes wrapper, which makes every traceback, log line and help() harder to read.
  • try/finally means the timing is printed even when the function raises. It’s the same rule as context managers.

Now look at squares: “took 0.0 ms” for two million squares. Calling a generator function only creates the generator, and the work happens later, when sum reads it. It’s the same surprise as this morning’s callback, and a reminder that wrapping code assumes something about when the work happens. For code like this, a context manager around the whole stage often measures what you actually meant.

A decorator earns its place when the same concern, like timing, retrying or logging, wraps many functions that have nothing else in common. For one or two call sites, the plain code is clearer.

functools.partial

When you keep passing the same argument, functools.partial fixes it once: by_source = partial(count, key=lambda r: r.source) gives a new function, and by_source(records, keep=is_error) fills in the rest. It’s a function factory you don’t have to write.

What you can now do

  • Combine callbacks, filter factories and existing functions into small, configurable APIs.
  • Write a context manager whose cleanup is proven by a test on the failure path.
  • Recognise when a decorator would remove real repetition, and write one with functools.wraps, or decide not to.

Tomorrow: the analyzer gets a report made of parts, type hints on its public functions, and a bug that no test caught because every test agreed with it.

Last updated on September 18, 2026

Was this article helpful?

Your response is saved on this device.