Deciding to use Level was the easy part. The analyzer has levels in four
places: the parser makes them, the summary counts them, the filters compare
them, and the store saves and loads them. Change the type in one place and every
other place has to agree.
This is the most common kind of change in a real codebase. It’s not a new feature. It’s one idea, changed everywhere it lives. The way through it is the one from Day 1: small steps, with the tests telling you where you are.
Day 5 checkpoint
Make the analyzer use Level
The playground holds Day 4’s analyzer.py, filters.py and store.py, where
levels are strings, plus levels.py with the Level enum from the last lesson.
The tests have already been updated to expect Level everywhere. Right now
seven fail.
Make them all pass. Work in this order, running the tests after each step:
analyzer.py.parse_recordshould turn the level’s text into aLevelwithLevel.from_text, and report unknown levels with the sameParseErroras before.LEVELScan go. Then look atsummarize: which levels count as errors now?filters.py.level_at_leastshould accept aLevelor a level’s name, and compareLevels.LEVEL_ORDERcan go.store.py. Once steps 1 and 2 pass, run the tests again. Four store tests that were passing now fail. Read what the database stored, and fix the way levels go in and come out.
Hint 1: the idea
This lesson’s rule, applied at both edges. Text becomes a Level once, where a
line is parsed, and everything after that compares Levels, never strings. At
the other edge, the database, a Level has to become something a column can
hold — and has to come back as a Level when it’s read.
Hint 2: a first step
Steps 1 and 2 are small: Level.from_text in parse_record, and comparisons
of Levels in level_at_least. For step 3, print what the database really
stored for one saved record, and compare it with what the failing test expected
to get back. An IntEnum is an int — that’s the clue.
Show one solution
analyzer.py: convert at the edge, and give the error the same reason as
before, so callers see no difference:
from levels import Level
...
date, time, level_text, rest = parts
...
try:
level = Level.from_text(level_text)
except ValueError:
raise ParseError(line_number, f"unknown level {level_text!r}", line) from NoneIn summarize, record.level == "ERROR" compared a Level with a string. An
IntEnum is never equal to a string, so it was quietly False for every record,
another silent miscount. The test that caught it checks something the old code
never did: CRITICAL counts as an error too.
if record.level >= Level.ERROR:
error_sources[record.source] += 1filters.py: one conversion when the filter is built, and a plain
comparison per record:
def level_at_least(minimum):
"""Keep records at minimum or more severe: level_at_least("WARNING")."""
floor = Level.from_text(minimum) if isinstance(minimum, str) else minimum
def keep(record):
return record.level >= floor
return keepstore.py: this is the step the tests had to find for you. After step 1,
save_records passed Level members straight to SQLite. An IntEnum is an
int, so SQLite stored 40, and the TEXT column turned it into the text
'40'. Nothing failed. count_by(level="ERROR") found nothing, and the round
trip came back with level='20'. It’s Day 3’s lesson again: convert at the
storage edge, explicitly, in both directions.
from levels import Level
...
rows = ((log, r.line_number, r.timestamp.isoformat(sep=" "), r.level.name, r.source, r.message)
for r in records)
...
level=Level[row["level"]],Storing the name rather than the number keeps the database readable, and
keeps it working if the numbers ever change. Level[...] on the way out raises
KeyError for anything that isn’t a level, so a corrupted row fails loudly
instead of turning into a string.
The order of that exercise was deliberate. Fix the code the tests point at, run them again, and let the next failure show you the next place. Nobody lists every place a value flows through a program from memory. The tests do it for you, which is the whole reason they were written on Day 1.
The Day 5 reference solution
The analyzer at the end of Day 5, and where Day 7 picks it up. (Day 6 is a standalone exercise.) Compared with the checkpoint, it also has:
report.py, withCountSection,ListSectionandReportfrom lesson 1, typed, and with theSectionprotocol.- Type hints on every public function in
analyzer.py,filters.py,levels.pyandreport.py.mypy --strictfinds no problems in those four files.store.pyisn’t hinted yet, and that’s a fine place to practise. store.count_byaccepts aLevelor a name, and runs it throughLevel.from_text, so passingLevel.ERRORcan’t silently match nothing. Itsat_least=option counts a level and everything worse.- One meaning of “errors”.
summarizecountsERRORandCRITICALas errors, so the SQL summary instore.pydoes too. The stored names it asks for come fromLevel’s order (level_names(Level.ERROR)), never from comparing the text, which would be today’s bug again. A test with one record at every level checks that Python and SQL agree. - Day 3’s guarantees, still tested. A failed save, including a stream that
fails part-way, stores nothing. Search results come back oldest first, and
%and_in a search are plain text.
main.py builds the daily report from sections, then runs all thirty-eight
tests.
Working on your own machine? Download the Day 5 files and carry on there. You need Python 3.12 or later, and nothing else.
What you can now do
- Carry a change of type through a codebase in small, test-guided steps.
- Recognise the places a value crosses a boundary (parsing, storage, comparison) and convert it at each one.
- Spot the silent failures a type change can cause, like an
IntEnumcompared with a string or stored as a number.
That’s the end of the analyzer’s core. Tomorrow is a standalone day about doing several slow things at once. The analyzer comes back on Day 7, to be finished and shipped.