Advanced · Lesson 71 of 94

Project: a typed, tested library

Watch this lesson

View full playlist ↗

Read the lesson

What you'll learn

Concept

The Advanced level gave you generators, decorators, context managers, the data model, dataclasses, type hints, mypy, regex, logging, pytest, mocking, HTTP, and patterns. The project puts them into one library that solves a real problem: reading expense records from CSV lines, validating them, and producing reports. It is deliberately a library rather than a script, because the Expert level will wrap it in a command, configuration, packaging, CI, and a container. Getting the library right first is how production code is built.

Project: a typed, tested library: CSV lines → parse: str → Expense (validated dataclass) → ledger: filter, group, total → report: text or JSON → errors: ParseError with line numbers → tests + mypy
Project: a typed, tested library · Figure 1 · Open for full size

The shape is three modules. models.py holds a frozen Expense dataclass and a Category enum, so a record is a typed value that cannot be half-valid. parse.py turns text into those values, raising a ParseError that carries the line number and chaining the underlying cause, and yielding records lazily so a large file streams. ledger.py holds the domain operations on a sequence of expenses: filter by month, group by category, totals. report.py renders a ledger to text or JSON. Each module has a small public API, annotated, and a test file that exercises it without the others where possible.

Module Responsibility Concepts it uses
models.py what an expense is frozen dataclass, Enum, __post_init__ validation
parse.py text in, expenses out, errors with line numbers generators, csv, exception chaining, logging
ledger.py filtering, grouping, totals defaultdict, __len__ and __iter__, collections.abc hints
report.py render to text or JSON json, f-string formatting, strategy dict
tests/ prove each module fixtures, parametrize, tmp_path, raises

Two habits define done. mypy passes with no errors, which means the hints are honest and callers cannot misuse the API without being told. pytest passes, with tests for the happy path, every error path, and the boundaries: empty input, a single record, a malformed line in the middle. Logging is configured nowhere in the library; it emits a WARNING for each skipped line and lets the eventual command decide what to show.

What is deliberately left out matters too. No command-line parsing, no configuration files, no packaging metadata, no network. Those are the Expert level's job, and keeping them out here is what makes the library reusable from a CLI, a web service, or a notebook without change. The public API is small enough to state in one sentence: parse lines into expenses, build a ledger, render a report.

Project: a typed, tested library: Advanced project: expenses/ library; models · parse · ledger · report · tests → Expert: CLI (argparse), config, logging setup → Expert: pyproject, packaging, publish → Expert: ruff, mypy, pre-commit, CI → Expert capstone: Docker, README, release
Project: a typed, tested library · Figure 2 · Open for full size

Code

The models: a frozen, validated Expense and a Category enum. Invalid data cannot become an Expense.

# expenses/__init__.py
"""Expense parsing, ledgers, and reports."""

from expenses.ledger import Ledger
from expenses.models import Category, Expense
from expenses.parse import ParseError, parse_lines
from expenses.report import render

__all__ = ["Category", "Expense", "Ledger", "ParseError", "parse_lines", "render"]
# expenses/models.py
from dataclasses import dataclass
from datetime import date
from enum import StrEnum


class Category(StrEnum):
    FOOD = "food"
    RENT = "rent"
    TRAVEL = "travel"
    OTHER = "other"


@dataclass(frozen=True, order=True)
class Expense:
    day: date
    category: Category
    amount: float
    note: str = ""

    def __post_init__(self) -> None:
        if self.amount <= 0:
            raise ValueError(f"amount must be positive, got {self.amount}")

    @property
    def month(self) -> str:
        return self.day.strftime("%Y-%m")

Parsing: a generator over CSV lines that yields validated expenses and raises a ParseError naming the line, chained from the real cause.

# expenses/parse.py
import csv
import logging
from collections.abc import Iterable, Iterator
from datetime import date

from expenses.models import Category, Expense

log = logging.getLogger(__name__)


class ParseError(ValueError):
    def __init__(self, line_no: int, message: str) -> None:
        super().__init__(f"line {line_no}: {message}")
        self.line_no = line_no


def parse_lines(lines: Iterable[str], skip_bad: bool = False) -> Iterator[Expense]:
    reader = csv.DictReader(lines)
    for line_no, row in enumerate(reader, start=2):
        try:
            yield Expense(
                day=date.fromisoformat(row["day"]),
                category=Category(row["category"]),
                amount=float(row["amount"]),
                note=row.get("note") or "",
            )
        except (KeyError, ValueError) as err:
            if skip_bad:
                log.warning("skipping line %d: %s", line_no, err)
                continue
            raise ParseError(line_no, str(err)) from err

The ledger and the report: domain operations on a sequence of expenses, and two renderers chosen by name.

# expenses/ledger.py
from collections import defaultdict
from collections.abc import Iterable, Iterator

from expenses.models import Category, Expense


class Ledger:
    def __init__(self, expenses: Iterable[Expense]) -> None:
        self._items: list[Expense] = sorted(expenses)

    def __len__(self) -> int:
        return len(self._items)

    def __iter__(self) -> Iterator[Expense]:
        return iter(self._items)

    def for_month(self, month: str) -> "Ledger":
        return Ledger(e for e in self._items if e.month == month)

    def total(self) -> float:
        return round(sum(e.amount for e in self._items), 2)

    def by_category(self) -> dict[Category, float]:
        totals: dict[Category, float] = defaultdict(float)
        for e in self._items:
            totals[e.category] += e.amount
        return {c: round(v, 2) for c, v in sorted(totals.items())}

    def largest(self, n: int = 3) -> list[Expense]:
        return sorted(self._items, key=lambda e: e.amount, reverse=True)[:n]
# expenses/report.py
import json
from collections.abc import Callable

from expenses.ledger import Ledger


def render_text(ledger: Ledger) -> str:
    lines = [f"{c.value:<8}{v:>10.2f}" for c, v in ledger.by_category().items()]
    lines.append(f"{'total':<8}{ledger.total():>10.2f}")
    return "\n".join(lines)


def render_json(ledger: Ledger) -> str:
    return json.dumps({"total": ledger.total(), "by_category": {c.value: v for c, v in ledger.by_category().items()},
                       "count": len(ledger)}, indent=2)


RENDERERS: dict[str, Callable[[Ledger], str]] = {"text": render_text, "json": render_json}


def render(ledger: Ledger, fmt: str = "text") -> str:
    try:
        return RENDERERS[fmt](ledger)
    except KeyError:
        raise ValueError(f"unknown format {fmt!r}; choose from {sorted(RENDERERS)}") from None
# pyproject.toml
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]

[tool.mypy]
strict = true

The library in use, end to end, including a malformed line handled both ways.

# demo.py
import logging

from expenses import Ledger, ParseError, parse_lines, render

logging.basicConfig(level=logging.WARNING, format="%(levelname)s %(name)s: %(message)s")

lines = [
    "day,category,amount,note",
    "2026-09-01,rent,950,September",
    "2026-09-02,food,12.5,lunch",
    "2026-09-03,food,not-a-number,typo",
    "2026-09-15,travel,48,train",
    "2026-08-30,food,9,August coffee",
]

try:
    Ledger(parse_lines(lines))
except ParseError as err:
    print("strict:", err, "| cause:", type(err.__cause__).__name__)

ledger = Ledger(parse_lines(lines, skip_bad=True))
print(len(ledger), "expenses,", len(ledger.for_month("2026-09")), "in September")
print(render(ledger.for_month("2026-09")))
print(render(ledger, "json"))
print([e.note for e in ledger.largest(2)])
Output
strict: line 4: could not convert string to float: 'not-a-number' | cause: ValueError
WARNING expenses.parse: skipping line 4: could not convert string to float: 'not-a-number'
4 expenses, 3 in September
food         12.50
rent        950.00
travel       48.00
total      1010.50
{
  "total": 1019.5,
  "by_category": {
    "food": 21.5,
    "rent": 950.0,
    "travel": 48.0
  },
  "count": 4
}
['September', 'train']

The tests, one file per module, and the definition of done: mypy clean, pytest green.

# tests/conftest.py
import pytest

from expenses import Ledger, parse_lines

HEADER = "day,category,amount,note"


@pytest.fixture
def lines():
    return [HEADER, "2026-09-01,rent,950,", "2026-09-02,food,12.5,lunch", "2026-08-30,food,9,coffee"]


@pytest.fixture
def ledger(lines):
    return Ledger(parse_lines(lines))
# tests/test_models.py
from datetime import date

import pytest

from expenses import Category, Expense


def test_month_property():
    assert Expense(date(2026, 9, 1), Category.FOOD, 1.0).month == "2026-09"


@pytest.mark.parametrize("amount", [0, -1, -0.01])
def test_rejects_non_positive(amount):
    with pytest.raises(ValueError):
        Expense(date(2026, 9, 1), Category.FOOD, amount)


def test_frozen_and_hashable():
    e = Expense(date(2026, 9, 1), Category.FOOD, 1.0)
    assert len({e, Expense(date(2026, 9, 1), Category.FOOD, 1.0)}) == 1
# tests/test_parse_and_ledger.py
import pytest

from expenses import Category, Ledger, ParseError, parse_lines
from tests.conftest import HEADER


def test_parses_all_rows(ledger):
    assert len(ledger) == 3


def test_bad_line_reports_number():
    with pytest.raises(ParseError, match="line 3"):
        list(parse_lines([HEADER, "2026-09-01,rent,950,", "2026-09-02,food,x,"]))


def test_skip_bad_logs_and_continues(caplog):
    got = list(parse_lines([HEADER, "2026-09-02,food,x,", "2026-09-03,food,1,"], skip_bad=True))
    assert len(got) == 1
    assert "skipping line 2" in caplog.text


def test_month_filter_and_totals(ledger):
    september = ledger.for_month("2026-09")
    assert september.total() == 962.5
    assert september.by_category() == {Category.FOOD: 12.5, Category.RENT: 950.0}


def test_largest(ledger):
    assert [e.amount for e in ledger.largest(2)] == [950.0, 12.5]


def test_empty_ledger():
    empty = Ledger([])
    assert empty.total() == 0 and empty.by_category() == {} and len(empty) == 0
# tests/test_report.py
import json

import pytest

from expenses import render


def test_text_has_total_line(ledger):
    assert render(ledger).splitlines()[-1].startswith("total")


def test_json_round_trips(ledger):
    data = json.loads(render(ledger, "json"))
    assert data["total"] == 971.5 and data["count"] == 3


def test_unknown_format(ledger):
    with pytest.raises(ValueError, match="unknown format"):
        render(ledger, "xml")
# done.sh
mypy expenses
pytest -q 2>&1 | sed -E 's/ in [0-9.]+s/ in Ns/'
Output
Success: no issues found in 5 source files
..............                                                           [100%]
14 passed in Ns

Try it yourself

Extend the library without breaking mypy or the tests. Add a Budget dataclass mapping categories to monthly limits and a Ledger.over_budget(budget) method returning the categories that exceed it; add a render_markdown renderer to the registry; and add a parse_file(path) helper that opens a file with the right encoding and delegates to parse_lines. Write the tests first for each, and run done.sh after every step.

Common mistakes

What's next

That completes the Advanced level. Next you enter Expert with concurrency, starting with threads and the truth about the GIL.

Course overview