What you'll learn
- Build a small library the way a team would: a package, typed models, a clear public API, tests, and logging
- Separate parsing, domain logic, and reporting so each can be tested alone
- Run
mypyandpytestas the definition of done - Leave the code in the shape the Expert level will package, configure, and ship
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.

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.

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)])
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/'
Success: no issues found in 5 source files
.............. [100%]
14 passed in NsTry 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
- Letting the CLI or config leak into the library. Keep the library free of
argparse, environment reads, andbasicConfig; the Expert lessons add those around it. - Half-typed code. A single untyped public function turns off checking for every caller of it; annotate the whole public API and run
mypy --strict. - Tests that need the whole pipeline. Test
modelswithout parsing,parsewithout reports, and use fixtures for the shared pieces.
What's next
- A library is a package with typed models, one module per responsibility, a small public API in
__init__, and a test per module. - Done means
mypyclean andpytestgreen, including error paths and boundaries; logging emits, never configures. - Keep the CLI, config, and packaging out of the library so the Expert level can add them around it.
That completes the Advanced level. Next you enter Expert with concurrency, starting with threads and the truth about the GIL.