Practical projects · about 75 min

Project: a log analyzer from start to finish

Why this matters

Every running service writes logs, and when something breaks, the first question is "what does the log say?" Scrolling through ten thousand lines does not scale; a small tool that parses, filters and summarizes does. This project builds such a tool the way experienced engineers build any program: define the input precisely, split the work into small testable functions, handle bad data deliberately, and wrap it in a proper command-line interface. The same structure works for nearly every data-processing script you will write.

Step 0: specify before coding

Start with a sample of the real input and decide what counts as valid:

2026-10-31T12:00:01 INFO web: server started on :8080
2026-10-31T12:01:12 ERROR auth: invalid password for user bob
2026-10-31T12:01:15 WARNING web: slow response: 2.3s
this line is garbage

The format: TIMESTAMP LEVEL COMPONENT: MESSAGE. Questions to answer before writing code, because real logs will test every one:

Then decide on the output. A good report is compact and stable:

lines: 10 (skipped 2)
DEBUG: 1
INFO: 3
WARNING: 1
ERROR: 2
CRITICAL: 1
errors by component:
  auth: 2
  disk: 1

The architecture

 file ──> read_entries ──> parse_line (per line) ──> [Entry, ...]
                                                         │
                       args (--level, --top) ──> summarize ──> Summary
                                                         │
                                                format_report ──> str ──> print

Only main touches the outside world: arguments, files, stdout, stderr and exit codes. Everything else takes values and returns values. This "functional core, imperative shell" design means each stage can be tested in isolation with a string or a list, and the core could be reused to produce JSON or feed a dashboard without changes.

Step 1: parsing one line

Represent a parsed line as a small immutable record:

from dataclasses import dataclass

LEVELS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]

@dataclass(frozen=True)
class Entry:
    timestamp: str
    level: str
    component: str
    message: str

A dataclass gives readable attribute names (e.level rather than e[1]), a useful repr for debugging, and equality, so tests can compare whole entries. frozen=True makes entries hashable and protects them from accidental modification.

Parsing uses two string tools with a limit:

>>> "2026-10-31T12:01:15 WARNING web: slow response: 2.3s".split(" ", 2)
['2026-10-31T12:01:15', 'WARNING', 'web: slow response: 2.3s']
>>> "web: slow response: 2.3s".partition(": ")
('web', ': ', 'slow response: 2.3s')

split(" ", 2) performs at most two splits, so the remainder stays intact. partition splits on the first occurrence only and always returns three parts; the middle one is empty when the separator is missing, which makes the "malformed" check simple. For the timestamp, datetime.fromisoformat validates for us: it raises ValueError for 2026-13-45T... or yesterday.

Returning None for malformed lines (rather than raising) is a design decision: in this tool a bad line is expected, not exceptional. A function used elsewhere for strict validation might raise a custom ParseError instead. Whichever you choose, make it explicit in the type hint: -> Entry | None.

Step 2: summarizing

The summary needs counts per level, restricted to a minimum level, and the components with the most errors. Severity order comes free from the list: LEVELS.index("WARNING") is 2, so LEVELS[2:] are the levels to report.

from collections import Counter

errors = Counter()
for e in entries:
    if e.level in ("ERROR", "CRITICAL"):
        errors[e.component] += 1
ranked = sorted(errors.items(), key=lambda kv: (-kv[1], kv[0]))

Two details make the output trustworthy. First, every reported level appears even with a count of zero, so a reader can tell "no errors" from "errors not reported". Second, ties are broken by name: Counter.most_common would order ties by first appearance, which changes whenever the log changes slightly and makes test output flaky.

summarize accepts any iterable of entries and loops over it exactly once. That means it works on a list, but also on a generator that parses a 5 GB log lazily, line by line, without holding it all in memory:

entries = (e for line in f if (e := parse_line(line)) is not None)
summary = summarize(entries)

Step 3: the shell around the core

main wires it together:

def main() -> int:
    args = build_parser().parse_args()
    try:
        with open(args.logfile, encoding="utf-8") as f:
            entries, lines, skipped = read_entries(f)
    except OSError:
        print(f"error: cannot read {args.logfile}", file=sys.stderr)
        return 1
    summary = summarize(entries, args.level, args.top)
    print(format_report(summary, lines, skipped))
    return 0

if __name__ == "__main__":
    sys.exit(main())

Real-world logs sometimes contain invalid UTF-8 bytes. Opening with errors="replace" turns them into � replacement characters instead of crashing with UnicodeDecodeError, a sensible choice for a diagnostic tool.

Testing the whole thing

Because the core is pure, a unittest module for it is short:

import unittest
from analyzer import parse_line, summarize, Entry

class ParseTests(unittest.TestCase):
    def test_message_with_colons(self):
        e = parse_line("2026-10-31T12:00:02 INFO web: GET /a: 200")
        self.assertEqual(e, Entry("2026-10-31T12:00:02", "INFO", "web", "GET /a: 200"))

    def test_garbage_is_none(self):
        self.assertIsNone(parse_line("this line is garbage"))

class SummaryTests(unittest.TestCase):
    def test_zero_counts_present(self):
        s = summarize([], min_level="ERROR")
        self.assertEqual(s.levels, {"ERROR": 0, "CRITICAL": 0})

if __name__ == "__main__":
    unittest.main()

The CLI layer is then tested end to end by running the program on small sample files and comparing output and exit status, exactly what learnprog's checks do for the final exercise.

Common mistakes

Recap

Going further: extend the analyzer with a --since TIMESTAMP option (compare datetime.fromisoformat values), and a --json flag that prints the summary with json.dumps(dataclasses.asdict(summary), indent=2), reusing the same core functions.

Exercises

Exercise 1 · quiz

Where should printing happen?

The analyzer is split into parse_line, summarize and format_report, with only main doing input and output. What is the main benefit of keeping parse_line and summarize free of print and file access?

Answer
  • They can be tested with plain values (a string in, an object out) and reused for other outputs, such as JSON or a web page

A function that only transforms values is easy to test (assert parse_line("...") == Entry(...)), easy to reason about, and reusable. Keeping I/O (reading files, printing, exiting) at the edges, in main, is often called "functional core, imperative shell". The edges are thin and the logic is all testable.

Exercise 2 · write code

Step 1: parse one line

Log lines look like this:

2026-10-31T12:00:01 ERROR auth: invalid password for user bob

that is: an ISO timestamp, one space, a level, one space, a component name, a colon and a space, and the message (which may itself contain colons).

In solution.py, implement parse_line(line) returning an Entry (the frozen dataclass in the starter) or None if the line is malformed. Rules:

File: solution.py

Starting point

from dataclasses import dataclass
from datetime import datetime

LEVELS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]


@dataclass(frozen=True)
class Entry:
    timestamp: str
    level: str
    component: str
    message: str


def parse_line(line: str) -> Entry | None:
    ts, level, rest = line.split(" ", 2)
    component, message = rest.split(": ")
    return Entry(ts, level, component, message)
Hint 1

line.strip().split(" ", 2) splits at most twice, so the rest of the line stays in one piece; check you got 3 parts.

Hint 2

rest.partition(": ") returns (before, sep, after); sep is empty if there was no ": ".

Hint 3

Wrap datetime.fromisoformat(ts) in try/except ValueError and return None on failure.

Reference solution
from dataclasses import dataclass
from datetime import datetime

LEVELS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]


@dataclass(frozen=True)
class Entry:
    timestamp: str
    level: str
    component: str
    message: str


def parse_line(line: str) -> Entry | None:
    parts = line.strip().split(" ", 2)
    if len(parts) != 3:
        return None
    ts, level, rest = parts
    if level not in LEVELS:
        return None
    try:
        datetime.fromisoformat(ts)
    except ValueError:
        return None
    component, sep, message = rest.partition(": ")
    if not sep or not component or " " in component:
        return None
    return Entry(ts, level, component, message)

Exercise 3 · write code

Step 2: summarize entries

Now write summarize(entries, min_level="DEBUG", top=3) returning a Summary (dataclass in the starter):

parse_line and Entry are provided already.

File: solution.py

Starting point

from collections import Counter
from dataclasses import dataclass, field

LEVELS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]


@dataclass(frozen=True)
class Entry:
    timestamp: str
    level: str
    component: str
    message: str


@dataclass
class Summary:
    levels: dict[str, int] = field(default_factory=dict)
    top_components: list[tuple[str, int]] = field(default_factory=list)


def summarize(entries, min_level="DEBUG", top=3) -> Summary:
    return Summary()
Hint 1

LEVELS[LEVELS.index(min_level):] is the list of levels to report, already in order.

Hint 2

Start with {level: 0 for level in wanted} so zero counts are present, and skip entries whose level is not a key.

Hint 3

Count error components in a Counter, then sorted(c.items(), key=lambda kv: (-kv[1], kv[0]))[:top].

Reference solution
from collections import Counter
from dataclasses import dataclass, field

LEVELS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]


@dataclass(frozen=True)
class Entry:
    timestamp: str
    level: str
    component: str
    message: str


@dataclass
class Summary:
    levels: dict[str, int] = field(default_factory=dict)
    top_components: list[tuple[str, int]] = field(default_factory=list)


def summarize(entries, min_level="DEBUG", top=3) -> Summary:
    wanted = LEVELS[LEVELS.index(min_level):]
    levels = {level: 0 for level in wanted}
    errors = Counter()
    for e in entries:
        if e.level not in levels:
            continue
        levels[e.level] += 1
        if e.level in ("ERROR", "CRITICAL"):
            errors[e.component] += 1
    ranked = sorted(errors.items(), key=lambda kv: (-kv[1], kv[0]))
    return Summary(levels, ranked[:top])

Exercise 4 · write code

Step 3: the complete tool

Put it all together in main.py (the starter already contains parse_line and summarize from steps 1 and 2). Command line:

usage: main.py [-h] [--level {DEBUG,INFO,WARNING,ERROR,CRITICAL}] [--top N] logfile

--level defaults to DEBUG, --top (int) defaults to 3. Read the file line by line, skip blank lines entirely, count malformed lines as skipped, and print exactly:

lines: 10 (skipped 2)
DEBUG: 1
INFO: 3
WARNING: 1
ERROR: 2
CRITICAL: 1
errors by component:
  auth: 2
  disk: 1

If the log file cannot be opened, print error: cannot read <path> to stderr and exit with status 1. Use a main() -> int function and sys.exit(main()).

File: main.py

Starting point

import argparse
import sys
from collections import Counter
from dataclasses import dataclass, field
from datetime import datetime

LEVELS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]


@dataclass(frozen=True)
class Entry:
    timestamp: str
    level: str
    component: str
    message: str


@dataclass
class Summary:
    levels: dict[str, int] = field(default_factory=dict)
    top_components: list[tuple[str, int]] = field(default_factory=list)


def parse_line(line: str) -> Entry | None:
    parts = line.strip().split(" ", 2)
    if len(parts) != 3:
        return None
    ts, level, rest = parts
    if level not in LEVELS:
        return None
    try:
        datetime.fromisoformat(ts)
    except ValueError:
        return None
    component, sep, message = rest.partition(": ")
    if not sep or not component or " " in component:
        return None
    return Entry(ts, level, component, message)


def summarize(entries, min_level="DEBUG", top=3) -> Summary:
    wanted = LEVELS[LEVELS.index(min_level):]
    levels = {level: 0 for level in wanted}
    errors = Counter()
    for e in entries:
        if e.level not in levels:
            continue
        levels[e.level] += 1
        if e.level in ("ERROR", "CRITICAL"):
            errors[e.component] += 1
    ranked = sorted(errors.items(), key=lambda kv: (-kv[1], kv[0]))
    return Summary(levels, ranked[:top])


def main() -> int:
    # parse the arguments, read the file, summarize, print the report
    return 0


if __name__ == "__main__":
    sys.exit(main())

Example with arguments app.log

expected output

lines: 10 (skipped 2)
DEBUG: 1
INFO: 3
WARNING: 1
ERROR: 2
CRITICAL: 1
errors by component:
  auth: 2
  disk: 1

Example with arguments --level ERROR --top 1 app.log

expected output

lines: 10 (skipped 2)
ERROR: 2
CRITICAL: 1
errors by component:
  auth: 2
Hint 1

Write a read_entries(f) helper that loops over the file, skips blank lines, and counts lines and skipped lines while collecting entries.

Hint 2

Build the report as a list of strings and join with newlines; that keeps formatting in one place.

Hint 3

except OSError: covers missing files, directories and permission problems alike.

Reference solution
import argparse
import sys
from collections import Counter
from dataclasses import dataclass, field
from datetime import datetime

LEVELS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]


@dataclass(frozen=True)
class Entry:
    timestamp: str
    level: str
    component: str
    message: str


@dataclass
class Summary:
    levels: dict[str, int] = field(default_factory=dict)
    top_components: list[tuple[str, int]] = field(default_factory=list)


def parse_line(line: str) -> Entry | None:
    parts = line.strip().split(" ", 2)
    if len(parts) != 3:
        return None
    ts, level, rest = parts
    if level not in LEVELS:
        return None
    try:
        datetime.fromisoformat(ts)
    except ValueError:
        return None
    component, sep, message = rest.partition(": ")
    if not sep or not component or " " in component:
        return None
    return Entry(ts, level, component, message)


def summarize(entries, min_level="DEBUG", top=3) -> Summary:
    wanted = LEVELS[LEVELS.index(min_level):]
    levels = {level: 0 for level in wanted}
    errors = Counter()
    for e in entries:
        if e.level not in levels:
            continue
        levels[e.level] += 1
        if e.level in ("ERROR", "CRITICAL"):
            errors[e.component] += 1
    ranked = sorted(errors.items(), key=lambda kv: (-kv[1], kv[0]))
    return Summary(levels, ranked[:top])


def read_entries(f) -> tuple[list[Entry], int, int]:
    entries, lines, skipped = [], 0, 0
    for line in f:
        if not line.strip():
            continue
        lines += 1
        entry = parse_line(line)
        if entry is None:
            skipped += 1
        else:
            entries.append(entry)
    return entries, lines, skipped


def format_report(summary: Summary, lines: int, skipped: int) -> str:
    out = [f"lines: {lines} (skipped {skipped})"]
    out += [f"{level}: {count}" for level, count in summary.levels.items()]
    out.append("errors by component:")
    if summary.top_components:
        out += [f"  {comp}: {count}" for comp, count in summary.top_components]
    else:
        out.append("  (none)")
    return "\n".join(out)


def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(description="Summarize an application log.")
    p.add_argument("logfile")
    p.add_argument("--level", choices=LEVELS, default="DEBUG", help="lowest level to count")
    p.add_argument("--top", type=int, default=3, metavar="N", help="components to list")
    return p


def main() -> int:
    args = build_parser().parse_args()
    try:
        with open(args.logfile, encoding="utf-8") as f:
            entries, lines, skipped = read_entries(f)
    except OSError:
        print(f"error: cannot read {args.logfile}", file=sys.stderr)
        return 1
    summary = summarize(entries, args.level, args.top)
    print(format_report(summary, lines, skipped))
    return 0


if __name__ == "__main__":
    sys.exit(main())