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 contextmanagerfrom analyzer import count, parse_linesdef 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@contextmanagerdef 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 functionsPython"""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 decoratorPython
import functoolsimport timedef 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@timeddef total(n): """Add up the numbers below n.""" return sum(range(n))@timeddef squares(n): """Yield the squares below n, one at a time.""" for i in range(n): yield i * iprint(total(2_000_000))print(total.__name__, "-", total.__doc__)numbers = squares(2_000_000) # "took 0.0 ms": nothing has run yetprint(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.
Your progress is saved in this browser.
Sign in
to keep it on every device.