From Lines to Records

The Course
•
September 18, 2026
•
8 min read

Warm-up: from Day 1

Three quick ones before the new material. From memory first — that’s the part that does the work.

1. Refactor or behaviour change? No code. For each change to yesterday’s script, decide which it is.

  1. Renaming c to level_counts everywhere.
  2. Making source_of remove a trailing colon only when there is one.
  3. Moving the counting loop into a function count_levels(lines).
  4. Skipping lines that start with #.
Check your answers
  1. Refactor — the output can’t change.
  2. Behaviour change. It changes the output for a line like db:timeout, which used to be counted as db:timeou. A good change — and still one to make as its own step.
  3. Refactor.
  4. Behaviour change, even if today’s file has no such lines: a line starting with # used to be counted, or crash the script.

If two of yours differ, reread Two kinds of change, never at once on Day 1.

2. Predict. Today leans on dataclasses, so a reminder of what they give you:

Warm-up: predict Python
from dataclasses import dataclass

@dataclass
class Reading:
    sensor: str
    value: float

a = Reading("t1", 20.5)
b = Reading("t1", 20.5)
print(a == b, a)
print([r.sensor for r in [a, Reading("t2", 3.0)] if r.value > 10])
Check your prediction
True Reading(sensor='t1', value=20.5)
['t1']

A dataclass writes __eq__ and __repr__ for you from its fields. From the beginner Dataclasses lesson.

3. Write source_of(line) so it drops the colon only when there is one. This should print db db:timeout:

Warm-up: write Python
# def source_of(line): ...

print(source_of("2026-09-01 10:00:00 ERROR db: timeout"),
      source_of("2026-09-01 10:00:05 ERROR db:timeout"))
Check your answer
def source_of(line):
    return line.split()[3].rstrip(":")

print(source_of("2026-09-01 10:00:00 ERROR db: timeout"),
      source_of("2026-09-01 10:00:05 ERROR db:timeout"))
db db:timeout

rstrip(":") removes colons from the end and nothing else, so db:timeout survives whole — which reveals the real problem: without a space, the source and the message arrive as one word. Today’s parser fixes that properly.

Where Day 1 ended

Yesterday you split a messy script into small, tested functions. Here is where you left off. Run executes the tests, and they all pass:

Where Day 1 ended Python
"""Print a summary of a log file: the Day 1 script, rebuilt from tested parts.""" from analyzer import count_levels, errors_by_source, read_lines def main(path="sample.log"): lines = read_lines(path) print("Lines read:", len(lines)) print("Levels:", count_levels(lines)) print("Errors by source:", errors_by_source(lines)) if __name__ == "__main__": main() """Log analyzer, end of Day 1: small functions, each one tested.""" def read_lines(path): """Return the non-blank lines of a log file.""" with open(path) as file: return [line for line in file.read().splitlines() if line.strip()] def level_of(line): """The level is the third word of a line.""" return line.split()[2] def source_of(line): """The source is the fourth word, minus its colon.""" return line.split()[3].rstrip(":") def count_levels(lines): counts = {} for line in lines: level = level_of(line) counts[level] = counts.get(level, 0) + 1 return counts def errors_by_source(lines): counts = {} for line in lines: if level_of(line) == "ERROR": source = source_of(line) counts[source] = counts.get(source, 0) + 1 return counts import unittest from analyzer import count_levels, errors_by_source, level_of, source_of 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 09:17:45 ERROR search: query=\"python books\"", ] class TestAnalyzer(unittest.TestCase): def test_level_and_source(self): self.assertEqual(level_of(LINES[0]), "INFO") self.assertEqual(source_of(LINES[1]), "payments") def test_source_without_a_colon(self): # fixed on Day 1, after the refactor self.assertEqual(source_of("2026-09-01 10:00:00 ERROR db timeout"), "db") def test_count_levels(self): self.assertEqual(count_levels(LINES), {"INFO": 1, "ERROR": 2}) def test_errors_by_source(self): self.assertEqual(errors_by_source(LINES), {"payments": 1, "search": 1}) import importlib import io import runpy import unittest from contextlib import redirect_stdout import report # What the original script printed. The refactor must not change it. EXPECTED = """\ Lines read: 6 Levels: {'INFO': 3, 'WARNING': 1, 'ERROR': 2} Errors by source: {'payments': 1, 'search': 1} """ def run_script(path): """Run a script as if from the command line, and return what it printed.""" output = io.StringIO() with redirect_stdout(output): runpy.run_path(path, run_name="__main__") return output.getvalue() class TestReport(unittest.TestCase): def test_output_is_unchanged(self): self.assertEqual(run_script("report.py"), EXPECTED) def test_importing_prints_nothing(self): output = io.StringIO() with redirect_stdout(output): importlib.reload(report) self.assertEqual(output.getvalue(), "") import sys import unittest import test_analyzer import test_report loader = unittest.defaultTestLoader suite = unittest.TestSuite([loader.loadTestsFromModule(test_analyzer), loader.loadTestsFromModule(test_report)]) unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite) 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 2026-09-01 09:17:45 ERROR search: query="python books" msg="index offline" 2026-09-01 09:18:02 INFO search: query=dune results=4

It works. But open analyzer.py and look at how it works. level_of knows the level is the third word. source_of knows the source is the fourth, with a colon stuck to it. Every function that needs anything re-slices the raw string itself.

That has three costs, and you will feel all of them today:

  • The layout is known in many places. Change the log format and every function changes with it.
  • Everything is a string. "2026-09-01" is not a date; you cannot ask it what hour it was.
  • Bad lines break things far from where they came in. A junk line crashes level_of deep inside a report, not where it was read.

The fix is an old one: parse each line once, at the edge of the program, into a value with named parts. Everything after that works with the value.

A record for one line

You met dataclasses on Day 6 of the beginner course. They are exactly the tool here: a record is a few named values and nothing else.

A record, not a string Python
from dataclasses import dataclass
from datetime import datetime


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


record = LogRecord(datetime(2026, 9, 1, 9, 14, 2), "INFO", "auth", "user=ada")
print(record)
print(record.level, record.timestamp.hour)
record.level = "ERROR"

frozen=True is a deliberate choice. A record describes something that already happened, so nothing downstream should be able to change it by accident, and the last line proves that nothing can. One limit to know: freezing stops a field being reassigned, not a mutable value inside it being changed. If a record holds a dict, as the analyzer’s will by the end of today, record.fields["x"] = 1 still works. Treat such values as read-only by convention.

Parsing the prefix with string methods

Every line starts with the same four things: date, time, level, and source:. Where the parts are separated by whitespace and no part contains whitespace, string methods are all you need.

split, and when to stop splitting Python
line = "2026-09-01 09:14:02 INFO    auth: user=ada action=login"

print(line.split())              # every word - the message is chopped up
print(line.split(maxsplit=3))    # stop after 3 splits - the rest stays whole

date, time, level, rest = line.split(maxsplit=3)
source, colon, message = rest.partition(":")
print(repr(source), repr(colon), repr(message))

Two small tools doing the work:

  • split(maxsplit=3) splits on runs of whitespace, so the padding after INFO doesn’t matter. It stops after three splits, so the message stays in one piece.
  • partition(":") splits at the first colon and always returns three parts. If there’s no colon at all, the middle part is empty, which will be useful later today when bad lines turn up.

And the timestamp becomes a real datetime with datetime.fromisoformat(f"{date} {time}").

Bug hunt: the invisible character

Same routine as yesterday: reproduce it with a small input, inspect the failure and the relevant state, narrow it down to the cause, pin it with a test, then fix it.

Here is a first attempt at parse_record, with a test that fails:

A test that should pass Python
import sys
import unittest
from dataclasses import dataclass
from datetime import datetime


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


def parse_record(line):
    date, time, level, rest = line.split(maxsplit=3)
    source, _, message = rest.partition(":")
    return LogRecord(datetime.fromisoformat(f"{date} {time}"), level, source, message)


class TestParseRecord(unittest.TestCase):
    def test_message(self):
        record = parse_record("2026-09-01 09:14:02 INFO    auth: user=ada")
        self.assertEqual(record.message, "user=ada")


suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestParseRecord)
unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite)

Reproduce. Already done: one line of input, one assertion. Small inputs are the point. When a failing case is small enough to read in full, the cause often jumps out.

Inspect. Read the failure before the code. ' user=ada' != 'user=ada' and the ? - marker under it point at one character: a leading space. When the difference is whitespace, print(repr(value)) is the tool to reach for, because repr shows spaces, tabs and \r that an ordinary print hides.

Narrow. Where did the space come from? partition(":") splits at the colon, so the message starts with whatever came after it, and in this format that’s a space. It was already visible in the previous playground’s output: ' user=ada action=login'. Easy to miss, which is exactly why a test caught it.

Pin it. The test you just watched fail is the regression test. Keep it, and this bug can never quietly return.

Fix. Add message = message.strip() after the partition line, and run again.

Your turn

Exercise

Parse a line into a record

The analyzer below has a LogRecord and an empty parse_record. Write parse_record so both tests pass. Run executes the tests; the other tabs hold the code and the tests.

"""Log analyzer: Day 2 starts here.""" from dataclasses import dataclass from datetime import datetime @dataclass(frozen=True) class LogRecord: timestamp: datetime level: str source: str message: str def parse_record(line): """Turn one log line into a LogRecord.""" raise NotImplementedError("your code here") def read_lines(path): """Return the non-blank lines of a log file.""" with open(path) as file: return [line for line in file.read().splitlines() if line.strip()] def level_of(line): """The level is the third word of a line.""" return line.split()[2] def source_of(line): """The source is the fourth word, minus its colon.""" return line.split()[3].rstrip(":") def count_levels(lines): counts = {} for line in lines: level = level_of(line) counts[level] = counts.get(level, 0) + 1 return counts def errors_by_source(lines): counts = {} for line in lines: if level_of(line) == "ERROR": source = source_of(line) counts[source] = counts.get(source, 0) + 1 return counts import unittest from datetime import datetime from analyzer import LogRecord, parse_record class TestParseRecord(unittest.TestCase): def test_info_line(self): record = parse_record("2026-09-01 09:14:02 INFO auth: user=ada action=login") self.assertEqual(record, LogRecord( timestamp=datetime(2026, 9, 1, 9, 14, 2), level="INFO", source="auth", message="user=ada action=login", )) def test_error_line(self): record = parse_record('2026-09-01 09:15:30 ERROR payments: msg="card declined"') self.assertEqual((record.level, record.source), ("ERROR", "payments")) self.assertEqual(record.message, 'msg="card declined"') import sys import unittest import test_records suite = unittest.defaultTestLoader.loadTestsFromModule(test_records) unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite)
Hint 1: the idea

A line has four parts you care about, in order: the date, the time, the level, and “the rest” — and the rest is source: message. Parsing the prefix with string methods above shows one tool for each: a split that stops after three cuts, so the message keeps its spaces, and one that cuts at the first colon.

Hint 2: a first step

Print line.split(maxsplit=3) for one of the test lines and check the last piece still holds the whole message. Then split that piece at the first colon. The bug hunt above showed the one detail that’s easy to forget afterwards.

Show one solution
def parse_record(line):
    """Turn one log line into a LogRecord."""
    date, time, level, rest = line.split(maxsplit=3)
    source, _, message = rest.partition(":")
    return LogRecord(
        timestamp=datetime.fromisoformat(f"{date} {time}"),
        level=level,
        source=source,
        message=message.strip(),
    )

maxsplit=3 is what keeps msg="card declined" in one piece. Without it the quoted message would be cut at its space. And the first test compares a whole LogRecord with ==, which works because dataclasses generate __eq__ for you. One comparison checks every field.

What you can now do

  • Parse a line once, at the edge of the program, into a frozen dataclass.
  • Choose split(maxsplit=…) and partition when whitespace and a fixed separator describe the format.
  • Run the day’s bug-hunt routine: reproduce small, inspect with repr, narrow, pin with a test, fix.

Next: the message itself holds key=value pairs, and one of them is about to defeat split().

Last updated on September 18, 2026

Was this article helpful?

Your response is saved on this device.