Skip to content
aviral gupta

// Capstone ยท about 24 hours of work

Personal library manager

You build library, a command-line tool for the books you own: what you have read, what you thought of it, and who has borrowed what. Books live in an SQLite database behind a Repository protocol, every query binds its values with placeholders, and settings come from a TOML file. Reports are template strings: the same kind of template gives plain text in the terminal and an HTML page in which every title is escaped. The tests use temporary databases, and unittest.mock to fix today's date. It draws on all three levels: dataclasses, enums, argparse and logging; then Protocol and generics, sqlite3, tomllib, t-strings and packaging. It is a project you can show.

What the finished program does

  • Book and Loan are frozen dataclasses that check themselves: a title and author, a year from 1 to 9999, a rating from 1 to 5, and a valid ISBN-10 or ISBN-13 check digit. Tags are stored lower case and sorted.
  • Status is a StrEnum: unread, reading, read. A loan is open until it is returned, and overdue by the number of days after its due date.
  • Storage sits behind a runtime-checkable Repository protocol. SqliteStore implements it with sqlite3: books, tags and loans tables, one transaction per change, and a ? placeholder for every value.
  • search filters by part of the title or author, by author, tag and status, in any combination, ordered by author and then title. % and _ in the search text match literally.
  • A second book with the same ISBN raises Duplicate, an unknown id NotFound, and lending a lent book or returning one that is not lent LoanError. All derive from LibraryError.
  • Settings come from an optional TOML file with a [library] table: database (relative to the file, default library.db) and loan_days (default 28). Mistakes raise ConfigError.
  • Reports are template strings: render_text works like an f-string, and render_html escapes every value unless it is Safe. A generic group_by[K, V] groups items for the reports.
  • Commands: add, list, show, edit, remove, lend, return, overdue, stats and export (an HTML page). The options --config, --db and -v come before the command.
  • main returns 0 on success, 1 when a command fails and 2 for a bad configuration; argparse exits with 2 on bad arguments. Errors, and progress with -v, are logged to standard error.
  • pyproject.toml installs a library command and names README.md as the readme. mypy --check-untyped-defs . reports no errors.

Starter layout

pyproject.toml
Package metadata and the build backend. You add readme and the [project.scripts] table.
README.md
The README, with a heading and a TODO for each part. Fill it in as you go (see the README guidance below).
library/__init__.py
Marks the package and holds __version__. Complete.
library/__main__.py
Lets python -m library run the command line. Complete.
library/errors.py
LibraryError and its subclasses NotFound, Duplicate, LoanError and ConfigError. Complete.
library/model.py
Status, Book, Loan, normalise_isbn and normalise_tags. normalise_tags is complete; Book.__post_init__, normalise_isbn and the Loan methods are stubs.
library/config.py
The Config dataclass and load_config, which is a stub.
library/store.py
The Repository protocol, the schema and SqliteStore. Opening and closing the database are written; the queries are stubs.
library/reports.py
Safe, render_text, render_html, group_by and the reports. book_table and the small helpers are complete; the rest are stubs.
library/cli.py
The commands, the argparse parser with its subcommands, and main. The parser, add and list are complete; the other commands and main are stubs.
samples/library.toml
A sample configuration file with both settings.

Milestones

  1. Milestone 1

    The domain model

    Write normalise_isbn with both check-digit rules, Book.__post_init__ (object.__setattr__ stores the cleaned values of a frozen dataclass), and Loan.open and Loan.days_overdue.

    Checks that pass once this milestone is done:

    • normalise_isbn removes hyphens and checks the check digit of ISBN-10 and ISBN-13
    • Book trims its text, normalises tags and ISBN, rejects bad values and is frozen
    • A loan is overdue by the days past its due date, and never once it is returned
  2. Milestone 2

    Configuration

    Write load_config with tomllib: the defaults for None, the [library] table, the database path relative to the file, and ConfigError for every mistake.

    Checks that pass once this milestone is done:

    • load_config gives the defaults, reads [library] from TOML, and rejects bad settings
  3. Milestone 3

    SQLite storage

    Write the SqliteStore book methods: each change inside `with self._db:`, every value as a ? parameter, search built only from fixed SQL pieces, and _books loading all the tags in one query.

    Checks that pass once this milestone is done:

    • SqliteStore is a Repository; add gives the book an id, and get reads it back
    • search filters by part of the title or author, by author, tag and status, in author order
    • update saves changes, remove deletes, and a second book with the same ISBN is a Duplicate
    • Quotes and SQL in a title are stored as text, never run as SQL
    • Books saved to a database file are still there when it is opened again
  4. Milestone 4

    Loans

    Write lend, give_back and loans. Look for an open loan first, so that the error can name the borrower; the partial unique index in the schema is the safety net.

    Checks that pass once this milestone is done:

    • A book can be lent once until it comes back; loans lists open and returned loans
  5. Milestone 5

    Template-string reports

    Write render_text and render_html by iterating over the Template, group_by with type parameters [K, V], then stats_report, overdue_report and html_page.

    Checks that pass once this milestone is done:

    • group_by is generic in the key and the item, and keeps the order items came in
    • render_text works like an f-string; render_html escapes every value except Safe ones
    • stats_report and overdue_report give the expected text
  6. Milestone 6

    Command line, package and README

    Write the other commands and main, add readme and [project.scripts] to pyproject.toml, install it with pip install -e . in a virtual environment, and fill in README.md.

    Checks that pass once this milestone is done:

    • A command works with any Repository, here a mock made from the protocol
    • library add, list and show work on a database file
    • library edit changes only the options given; remove deletes the book
    • library lend, overdue and return use today's date, replaced here with mock.patch
    • library stats prints the statistics; export writes an HTML page with every title escaped
    • --config sets the database and loan length; errors give exit status 1, bad configuration 2
    • pyproject.toml declares the library console script and the README

This project uses parts of Python that do not run in the browser, so you build it on your computer.

Build it on your computer

Make a folder with these starter files and Python 3.14, then work through the milestones. Run the acceptance tests at any point with:

Download the starter as one .zip (starter files, test_main.py and learnrun.py)
python learnrun.py test

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Download learnrun.py

pyproject.toml

[build-system]
requires = ["setuptools >= 77.0.3"]
build-backend = "setuptools.build_meta"

[project]
name = "library-manager"
version = "1.0.0"
description = "Keep track of your books, what you have read, and who has borrowed what."
# TODO: readme = "README.md"
requires-python = ">= 3.14"
dependencies = []

# TODO: a [project.scripts] table, so that pip install -e . creates a
# library command that calls main() in library/cli.py.

[tool.setuptools]
packages = ["library"]

README.md

# library: a personal library manager

TODO: one or two sentences: what the tool does and for whom.

## Install

TODO: the Python version needed, and the commands to create a virtual
environment and install the package with pip.

## Use

TODO: a short session that shows the main commands, copied from a real run.

## Configuration

TODO: the settings file, its [library] table and what each setting means.

## Design

TODO: one line per module, and the decisions worth explaining (the Repository
protocol, parameterised queries, escaping in the HTML export).

## Tests

TODO: how to run them, and what they cover.

## Limitations and next steps

TODO: what it does not do yet.

library/__init__.py

"""A personal library manager: your books, what you have read, and who has borrowed what."""

__version__ = "1.0.0"

library/__main__.py

"""python -m library runs the command line."""

import sys

from .cli import main

sys.exit(main())

library/errors.py

"""The exceptions of the library; the command line turns each into a message and exit status."""


class LibraryError(Exception):
    """Base class: every error this package raises on purpose."""


class NotFound(LibraryError):
    """No book has this id."""


class Duplicate(LibraryError):
    """A book with this ISBN is already in the library."""


class LoanError(LibraryError):
    """The book is already lent, or it is not lent and cannot be returned."""


class ConfigError(LibraryError):
    """The configuration file is missing, is not valid TOML, or has a wrong value."""

library/model.py

"""The domain: books, their reading status, and loans."""

from collections.abc import Iterable
from dataclasses import dataclass
from datetime import date
from enum import StrEnum


class Status(StrEnum):
    """Where you are with a book."""

    UNREAD = "unread"
    READING = "reading"
    READ = "read"


def normalise_isbn(text: str) -> str:
    """An ISBN without hyphens or spaces, with its check digit verified.

    ISBN-13: the digits, weighted 1, 3, 1, 3, ..., add up to a multiple of 10.
    ISBN-10: the digits, weighted 10, 9, ..., 1, add up to a multiple of 11;
    the last one may be X (or x), meaning 10.
    Raise ValueError for anything else, or for a wrong check digit.
    """
    raise NotImplementedError


def normalise_tags(tags: Iterable[str]) -> tuple[str, ...]:
    """Lower case, trimmed, without duplicates, sorted."""
    if isinstance(tags, str):
        raise TypeError("tags must be a collection of strings, not one string")
    return tuple(sorted({tag.strip().lower() for tag in tags} - {""}))


@dataclass(frozen=True, slots=True, kw_only=True)
class Book:
    """A book in your library. id is None until the store has saved it."""

    title: str
    author: str
    year: int | None = None
    isbn: str | None = None
    tags: tuple[str, ...] = ()
    status: Status = Status.UNREAD
    rating: int | None = None
    id: int | None = None

    def __post_init__(self) -> None:
        # TODO: strip title and author and raise ValueError if either is
        # empty; check year (1 to 9999) and rating (1 to 5); normalise the
        # ISBN and the tags. A frozen dataclass cannot assign self.title = ...,
        # so use object.__setattr__(self, "title", value).
        pass

    def __str__(self) -> str:
        year = f" ({self.year})" if self.year else ""
        return f"{self.title} by {self.author}{year}"


@dataclass(frozen=True, slots=True)
class Loan:
    """A book lent to someone: open until it is returned."""

    book_id: int
    borrower: str
    lent_on: date
    due_on: date
    returned_on: date | None = None

    @property
    def open(self) -> bool:
        raise NotImplementedError

    def days_overdue(self, today: date) -> int:
        """How many days past the due date an open loan is; 0 if it is not overdue."""
        raise NotImplementedError

library/config.py

"""Settings, read from an optional TOML file."""

import tomllib
from dataclasses import dataclass
from pathlib import Path

from .errors import ConfigError


@dataclass(frozen=True, slots=True)
class Config:
    """Where the database is, and how long a loan lasts."""

    database: Path = Path("library.db")
    loan_days: int = 28


def load_config(path: Path | None) -> Config:
    """The settings in path's [library] table, or the defaults if path is None.

    A relative database path is taken relative to the folder of the file, so
    the configuration works from any current directory.
    """
    # TODO: open the file in binary mode for tomllib.load. Raise ConfigError
    # for a file that cannot be read, invalid TOML, an unknown setting, or a
    # loan_days that is not a whole number of at least 1.
    raise NotImplementedError

library/store.py

"""Storage: the Repository protocol and its sqlite3 implementation."""

import logging
import sqlite3
from collections.abc import Sequence
from datetime import date
from pathlib import Path
from types import TracebackType
from typing import Protocol, Self, runtime_checkable

from .errors import Duplicate, LoanError, NotFound
from .model import Book, Loan, Status

logger = logging.getLogger(__name__)


@runtime_checkable
class Repository(Protocol):
    """What the rest of the program needs from storage; SqliteStore is one way to provide it."""

    def add(self, book: Book) -> Book: ...
    def get(self, book_id: int) -> Book: ...
    def update(self, book: Book) -> Book: ...
    def remove(self, book_id: int) -> None: ...
    def search(
        self, *, text: str | None = None, author: str | None = None, tag: str | None = None, status: Status | None = None
    ) -> list[Book]: ...
    def lend(self, book_id: int, borrower: str, on: date, due: date) -> Loan: ...
    def give_back(self, book_id: int, on: date) -> Loan: ...
    def loans(self, *, open_only: bool = False) -> list[Loan]: ...


# One transaction, so a new database is created in a single write.
SCHEMA = """
BEGIN;
CREATE TABLE IF NOT EXISTS books (
    id INTEGER PRIMARY KEY,
    title TEXT NOT NULL,
    author TEXT NOT NULL,
    year INTEGER,
    isbn TEXT UNIQUE,
    status TEXT NOT NULL CHECK (status IN ('unread', 'reading', 'read')),
    rating INTEGER CHECK (rating BETWEEN 1 AND 5)
);
CREATE TABLE IF NOT EXISTS tags (
    book_id INTEGER NOT NULL REFERENCES books (id) ON DELETE CASCADE,
    tag TEXT NOT NULL,
    PRIMARY KEY (book_id, tag)
);
CREATE TABLE IF NOT EXISTS loans (
    id INTEGER PRIMARY KEY,
    book_id INTEGER NOT NULL REFERENCES books (id) ON DELETE CASCADE,
    borrower TEXT NOT NULL,
    lent_on TEXT NOT NULL,
    due_on TEXT NOT NULL,
    returned_on TEXT
);
-- At most one open loan per book.
CREATE UNIQUE INDEX IF NOT EXISTS one_open_loan ON loans (book_id) WHERE returned_on IS NULL;
COMMIT;
"""


def _like(text: str) -> str:
    """A LIKE pattern matching text anywhere, with % and _ in text taken literally."""
    escaped = text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
    return f"%{escaped}%"


class SqliteStore:
    """A Repository in an SQLite database. Every query binds its values with ? placeholders."""

    def __init__(self, path: str | Path) -> None:
        self._db = sqlite3.connect(path)
        self._db.row_factory = sqlite3.Row
        self._db.execute("PRAGMA foreign_keys = ON")
        self._db.executescript(SCHEMA)
        logger.info("opened %s", path)

    def close(self) -> None:
        self._db.close()

    def __enter__(self) -> Self:
        return self

    def __exit__(self, kind: type[BaseException] | None, error: BaseException | None, tb: TracebackType | None) -> None:
        self.close()

    # Books

    def add(self, book: Book) -> Book:
        """Insert the book and its tags in one transaction; return it with its id.

        A second book with the same ISBN raises Duplicate.
        """
        # TODO: `with self._db:` commits the transaction, or rolls it back on
        # an error. INSERT with ? placeholders; cursor.lastrowid is the new id.
        # Turn the sqlite3.IntegrityError for books.isbn into Duplicate.
        raise NotImplementedError

    def get(self, book_id: int) -> Book:
        """The book with this id; NotFound if there is none."""
        raise NotImplementedError

    def update(self, book: Book) -> Book:
        """Save every field of a book that has an id, replacing its tags."""
        raise NotImplementedError

    def remove(self, book_id: int) -> None:
        """Delete the book (its tags and loans go too: ON DELETE CASCADE); NotFound if there is none."""
        raise NotImplementedError

    def search(
        self, *, text: str | None = None, author: str | None = None, tag: str | None = None, status: Status | None = None
    ) -> list[Book]:
        """Books matching every filter given, ordered by author and title.

        text matches part of the title or author, author part of the author,
        both ignoring case (for ASCII letters); tag and status match exactly.
        """
        # TODO: collect conditions ("author LIKE ? ESCAPE '\'", ...) and their
        # values in two lists; join only the conditions into the SQL, and pass
        # the values as parameters. _like() builds the LIKE pattern.
        raise NotImplementedError

    def _books(self, rows: Sequence[sqlite3.Row]) -> list[Book]:
        """Books from rows of the books table, with their tags, in two queries."""
        # TODO: one query for the tags of all the rows: WHERE book_id IN (?, ?, ...).
        raise NotImplementedError

    # Loans

    def lend(self, book_id: int, borrower: str, on: date, due: date) -> Loan:
        """Record a loan; LoanError if the book is already lent (say to whom)."""
        raise NotImplementedError

    def give_back(self, book_id: int, on: date) -> Loan:
        """Close the open loan of the book; LoanError if it is not lent."""
        raise NotImplementedError

    def loans(self, *, open_only: bool = False) -> list[Loan]:
        """Every loan, or only the open ones, by due date."""
        raise NotImplementedError

    @staticmethod
    def _loan(row: sqlite3.Row) -> Loan:
        returned = row["returned_on"]
        return Loan(
            row["book_id"],
            row["borrower"],
            date.fromisoformat(row["lent_on"]),
            date.fromisoformat(row["due_on"]),
            date.fromisoformat(returned) if returned else None,
        )

library/reports.py

"""Reports rendered from template strings: plain text, or HTML with every value escaped."""

import html
from collections import Counter
from collections.abc import Callable, Iterable, Mapping, Sequence
from datetime import date
from string.templatelib import Template, convert
from typing import Any, Literal

from .model import Book, Loan, Status


class Safe(str):
    """Text that is already HTML. render_html inserts it as it is."""


def _formatted(value: object, conversion: Literal["a", "r", "s"] | None, format_spec: str) -> str:
    # What an f-string does with {value!conversion:format_spec}.
    return format(convert(value, conversion), format_spec)


def render_text(template: Template) -> str:
    """The template as an f-string would give it."""
    # TODO: iterate over the template: a str part is kept, an Interpolation
    # part becomes _formatted(part.value, part.conversion, part.format_spec).
    raise NotImplementedError


def render_html(template: Template) -> Safe:
    """The template as HTML: the literal parts as written, every value escaped unless it is Safe."""
    # TODO: like render_text, with html.escape for each value that is not Safe.
    raise NotImplementedError


# TODO: generic in the key K and the item V: def group_by[K, V](...) -> dict[K, list[V]]
def group_by(items: Iterable[Any], key: Callable[[Any], Any]) -> dict[Any, list[Any]]:
    """The items in lists by key, keeping their order; the groups in the order first seen."""
    raise NotImplementedError


def rating_text(rating: int | None) -> str:
    return f"{rating}/5" if rating else "-"


def days_text(days: int) -> str:
    return "1 day" if days == 1 else f"{days} days"


def book_table(books: Sequence[Book]) -> str:
    """One line per book: id, title and author, status, rating and tags."""
    lines = []
    for book in books:
        tags = ", ".join(book.tags)
        line = render_text(t"#{book.id:<4} {str(book):<48} {book.status:<8} {rating_text(book.rating):<4} {tags}")
        lines.append(line.rstrip())
    return "\n".join(lines) if lines else "No books found."


def stats_report(books: Sequence[Book], loans: Sequence[Loan], today: date) -> str:
    """Five lines, for example:

    Books: 4 (1 unread, 1 reading, 2 read)
    Authors: 3
    Average rating: 4.5 from 2 rated        (or: Average rating: none yet)
    On loan: 2, 1 overdue
    Top tags: sf (2), classic (1), fantasy (1)   (most used first, then by name; or: none)
    """
    # TODO: t-strings rendered with render_text; group_by for the statuses,
    # collections.Counter for the tags. Authors are counted ignoring case.
    raise NotImplementedError


def overdue_report(loans: Sequence[Loan], books: Mapping[int, Book], today: date) -> str:
    """Overdue loans grouped by borrower (in name order), earliest due first within each:

    sam:
      #2 Dune by Frank Herbert (1965): due 2026-08-29, 5 days overdue

    or "Nothing is overdue."
    """
    raise NotImplementedError


def html_page(books: Sequence[Book], title: str) -> str:
    """The books as a stand-alone HTML page. Titles, authors and tags are escaped.

    A <title> and an <h1> with title, then a <table> with a header row and one
    row per book: title, author, year, status, rating (rating_text) and tags.
    """
    # TODO: render each row with render_html, join them into one Safe string,
    # and put that into the page template, also rendered with render_html.
    raise NotImplementedError

library/cli.py

"""The command line: library [--config FILE] [--db FILE] [-v] COMMAND ..."""

import argparse
import dataclasses
import logging
import sqlite3
import sys
from collections.abc import Callable
from datetime import date, timedelta
from pathlib import Path

from . import __version__
from .config import Config, load_config
from .errors import ConfigError, LibraryError
from .model import Book, Status
from .reports import book_table, days_text, html_page, overdue_report, rating_text, stats_report
from .store import Repository, SqliteStore

logger = logging.getLogger("library")

EXIT_OK = 0
EXIT_ERROR = 1  # the command failed: no such book, a duplicate ISBN, a bad value
EXIT_USAGE = 2  # a bad configuration file; argparse also exits with 2 on bad arguments

type Handler = Callable[[Repository, argparse.Namespace, Config], str]


def today() -> date:
    """Today's date; a function of its own so that tests can replace it."""
    return date.today()


# The commands. Each takes the store, the parsed arguments and the settings,
# and returns the text to print; errors are raised and reported by main.

def cmd_add(store: Repository, args: argparse.Namespace, config: Config) -> str:
    book = store.add(Book(
        title=args.title, author=args.author, year=args.year, isbn=args.isbn,
        tags=tuple(args.tag or ()), status=Status(args.status), rating=args.rating,
    ))
    return f"Added #{book.id}: {book}"


def cmd_list(store: Repository, args: argparse.Namespace, config: Config) -> str:
    status = Status(args.status) if args.status else None
    return book_table(store.search(text=args.search, author=args.author, tag=args.tag, status=status))


def cmd_show(store: Repository, args: argparse.Namespace, config: Config) -> str:
    """Five lines: "#1 Dune by Frank Herbert (1965)", "ISBN: ...", "Status: read, rating 4/5",
    "Tags: classic, sf" and "On the shelf" or "Lent to sam, due 2026-08-29". Use - for a missing value."""
    raise NotImplementedError


def cmd_edit(store: Repository, args: argparse.Namespace, config: Config) -> str:
    """Change only the options given ("Updated #1: ..."); LibraryError("nothing to change ...") if none is."""
    # TODO: dataclasses.replace(book, ...) builds a new Book, so its checks run again.
    raise NotImplementedError


def cmd_remove(store: Repository, args: argparse.Namespace, config: Config) -> str:
    """Remove the book: "Removed #3: Dune by Frank Herbert"."""
    raise NotImplementedError


def cmd_lend(store: Repository, args: argparse.Namespace, config: Config) -> str:
    """Lend from today() for --days or config.loan_days: "Lent #1 Dune to sam until 2026-08-29"."""
    raise NotImplementedError


def cmd_return(store: Repository, args: argparse.Namespace, config: Config) -> str:
    """"Returned #1 Dune from sam", plus " (5 days late)" after the due date (days_text)."""
    raise NotImplementedError


def cmd_overdue(store: Repository, args: argparse.Namespace, config: Config) -> str:
    raise NotImplementedError


def cmd_stats(store: Repository, args: argparse.Namespace, config: Config) -> str:
    raise NotImplementedError


def cmd_export(store: Repository, args: argparse.Namespace, config: Config) -> str:
    """Write html_page to args.file (UTF-8): "Wrote 2 books to FILE" ("1 book" for one)."""
    raise NotImplementedError


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(prog="library", description="Keep track of your books, what you have read, and who has borrowed what.")
    parser.add_argument("--config", type=Path, metavar="FILE", help="a TOML file with a [library] table")
    parser.add_argument("--db", type=Path, metavar="FILE", help="the SQLite database (overrides the configuration)")
    parser.add_argument("-v", "--verbose", action="count", default=0, help="log what happens (-vv for more)")
    parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
    commands = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")

    def command(name: str, handler: Handler, help: str) -> argparse.ArgumentParser:
        sub = commands.add_parser(name, help=help, description=help)
        sub.set_defaults(handler=handler)
        return sub

    def book_options(sub: argparse.ArgumentParser, *, required: bool) -> None:
        if required:
            sub.add_argument("title")
            sub.add_argument("author")
        else:
            sub.add_argument("--title")
            sub.add_argument("--author")
        sub.add_argument("--year", type=int)
        sub.add_argument("--isbn")
        sub.add_argument("--tag", action="append", help="a tag; repeat for more")
        sub.add_argument("--status", choices=[s.value for s in Status], default="unread" if required else None)
        sub.add_argument("--rating", type=int, choices=range(1, 6), metavar="1-5")

    book_options(command("add", cmd_add, "add a book"), required=True)
    sub = command("list", cmd_list, "list books, optionally filtered")
    sub.add_argument("--search", help="part of the title or author")
    sub.add_argument("--author", help="part of the author's name")
    sub.add_argument("--tag")
    sub.add_argument("--status", choices=[s.value for s in Status])
    command("show", cmd_show, "show one book").add_argument("id", type=int)
    sub = command("edit", cmd_edit, "change a book")
    sub.add_argument("id", type=int)
    book_options(sub, required=False)
    command("remove", cmd_remove, "remove a book").add_argument("id", type=int)
    sub = command("lend", cmd_lend, "lend a book to someone")
    sub.add_argument("id", type=int)
    sub.add_argument("borrower")
    sub.add_argument("--days", type=int, help="loan length (default: loan_days from the configuration)")
    command("return", cmd_return, "record that a lent book came back").add_argument("id", type=int)
    command("overdue", cmd_overdue, "list overdue loans by borrower")
    command("stats", cmd_stats, "show statistics")
    sub = command("export", cmd_export, "write the catalogue as an HTML page")
    sub.add_argument("file", type=Path)
    sub.add_argument("--title", default="My library")
    return parser


def configure_logging(verbosity: int) -> None:
    """Errors and, with -v, progress go to standard error, so they never mix with the output."""
    handler = logging.StreamHandler(sys.stderr)
    handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
    logger.handlers[:] = [handler]
    logger.setLevel(logging.WARNING if verbosity == 0 else logging.INFO if verbosity == 1 else logging.DEBUG)
    logger.propagate = False


def main(argv: list[str] | None = None) -> int:
    """Run the tool with argv (default: sys.argv[1:]); return the exit status."""
    # TODO: parse the arguments and configure logging; load the configuration
    # (a ConfigError is logged and returns EXIT_USAGE); open the store at
    # args.db or config.database in a with statement; print what
    # args.handler(store, args, config) returns. Log a LibraryError, ValueError,
    # sqlite3.Error or OSError and return EXIT_ERROR.
    raise NotImplementedError


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

samples/library.toml

# Settings for the library command: library --config samples/library.toml stats
[library]
# Relative to the folder of this file.
database = "library.db"
# How many days a loan lasts unless lend is given --days.
loan_days = 21

Acceptance tests

The project is done when every check in test_main.py passes. Read them before you start: they are the spec, written as code.

test_main.py

import argparse
import contextlib
import dataclasses
import io
import tempfile
import tomllib
from datetime import date
from pathlib import Path
from unittest import mock

from library.cli import cmd_remove, main
from library.config import Config, load_config
from library.errors import ConfigError, Duplicate, LibraryError, LoanError, NotFound
from library.model import Book, Loan, Status, normalise_isbn
from library.reports import Safe, group_by, overdue_report, render_html, render_text, stats_report
from library.store import Repository, SqliteStore

DUNE = "978-0-441-01359-3"


def books():
    return [
        Book(title="The Left Hand of Darkness", author="Ursula K. Le Guin", year=1969, tags=("sf",), status=Status.READ, rating=5),
        Book(title="Dune", author="Frank Herbert", year=1965, isbn=DUNE, tags=("sf", "classic"), status=Status.READING),
        Book(title="A Wizard of Earthsea", author="Ursula K. Le Guin", year=1968, tags=("fantasy",), status=Status.READ, rating=4),
        Book(title="100% Pure", author="Ann Other"),
    ]


def filled_store():
    store = SqliteStore(":memory:")
    for book in books():
        store.add(book)
    return store


def run(argv, on=date(2026, 9, 1)):
    """Call main(argv) with today() fixed to on; return (status, stdout, stderr)."""
    out, err = io.StringIO(), io.StringIO()
    with mock.patch("library.cli.today", return_value=on), contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
        status = main(argv)
    return status, out.getvalue(), err.getvalue()


def test_isbn():
    """normalise_isbn removes hyphens and checks the check digit of ISBN-10 and ISBN-13"""
    got = normalise_isbn(DUNE), normalise_isbn("0-441-01359-7"), normalise_isbn("0 8044 2957 x")
    assert got == ("9780441013593", "0441013597", "080442957X"), f"normalise_isbn gave {got!r}"
    for bad in ["978-0-441-01359-4", "0-441-01359-8", "12345", "97804410135XX"]:
        try:
            normalise_isbn(bad)
        except ValueError:
            continue
        raise AssertionError(f"normalise_isbn({bad!r}) should raise ValueError")


def test_book_validation():
    """Book trims its text, normalises tags and ISBN, rejects bad values and is frozen"""
    book = Book(title="  Dune ", author="Frank Herbert", isbn=DUNE, tags=["SF", " classic", "sf"])
    got = (book.title, book.isbn, book.tags, book.status, book.id)
    assert got == ("Dune", "9780441013593", ("classic", "sf"), Status.UNREAD, None), f"the Book holds {got!r}"
    for bad in [{"title": " "}, {"rating": 6}, {"year": 0}, {"isbn": "123"}]:
        try:
            Book(**({"title": "T", "author": "A"} | bad))
        except ValueError:
            continue
        raise AssertionError(f"Book with {bad!r} should raise ValueError")
    try:
        book.title = "Other"
    except dataclasses.FrozenInstanceError:
        pass
    else:
        raise AssertionError("a Book should be frozen")


def test_loan_overdue():
    """A loan is overdue by the days past its due date, and never once it is returned"""
    loan = Loan(1, "sam", date(2026, 8, 1), date(2026, 8, 29))
    got = loan.days_overdue(date(2026, 8, 29)), loan.days_overdue(date(2026, 9, 3)), loan.open
    assert got == (0, 5, True), f"days_overdue on the due date, five days later, and open: {got!r}, expected (0, 5, True)"
    returned = dataclasses.replace(loan, returned_on=date(2026, 9, 10))
    assert returned.days_overdue(date(2026, 9, 20)) == 0 and not returned.open, "a returned loan is not open and never overdue"


def test_config():
    """load_config gives the defaults, reads [library] from TOML, and rejects bad settings"""
    assert load_config(None) == Config(Path("library.db"), 28), f"the defaults are {load_config(None)!r}"
    with tempfile.TemporaryDirectory() as folder:
        path = Path(folder) / "library.toml"
        path.write_text('[library]\ndatabase = "books.db"\nloan_days = 14\n', encoding="utf-8")
        got = load_config(path)
        assert got == Config(Path(folder) / "books.db", 14), f"load_config gave {got!r}; the database should be relative to the file"
        for text in ['[library]\nloan_days = "two weeks"\n', '[library]\ncolour = "red"\n', "[library\n"]:
            path.write_text(text, encoding="utf-8")
            try:
                load_config(path)
            except ConfigError:
                continue
            raise AssertionError(f"load_config accepted {text!r}; expected ConfigError")
        try:
            load_config(Path(folder) / "missing.toml")
        except ConfigError as err:
            assert "missing.toml" in str(err), f"the error {str(err)!r} should name the file"
        else:
            raise AssertionError("a missing configuration file should raise ConfigError")


def test_store_add_and_get():
    """SqliteStore is a Repository; add gives the book an id, and get reads it back"""
    store = SqliteStore(":memory:")
    assert isinstance(store, Repository), "SqliteStore should implement the Repository protocol"
    added = store.add(books()[1])
    assert added.id == 1 and added == dataclasses.replace(books()[1], id=1), f"add returned {added!r}"
    assert store.get(1) == added, f"get(1) returned {store.get(1)!r}, expected {added!r}"
    try:
        store.get(99)
    except NotFound as err:
        assert isinstance(err, LibraryError) and "99" in str(err), f"NotFound should be a LibraryError naming #99, got {err!r}"
    else:
        raise AssertionError("get(99) should raise NotFound")


def test_store_search():
    """search filters by part of the title or author, by author, tag and status, in author order"""
    store = filled_store()

    def titles(**filters):
        return [book.title for book in store.search(**filters)]

    assert titles() == ["100% Pure", "Dune", "A Wizard of Earthsea", "The Left Hand of Darkness"], f"search() gave {titles()!r}, ordered by author then title"
    checks = [
        ({"text": "le guin"}, ["A Wizard of Earthsea", "The Left Hand of Darkness"]),
        ({"text": "DUNE"}, ["Dune"]),
        ({"author": "herbert"}, ["Dune"]),
        ({"tag": "SF"}, ["Dune", "The Left Hand of Darkness"]),
        ({"status": Status.READ, "tag": "sf"}, ["The Left Hand of Darkness"]),
        ({"text": "%"}, ["100% Pure"]),
        ({"text": "_"}, []),
    ]
    for filters, want in checks:
        assert titles(**filters) == want, f"search(**{filters!r}) gave {titles(**filters)!r}, expected {want!r}"


def test_store_update_and_remove():
    """update saves changes, remove deletes, and a second book with the same ISBN is a Duplicate"""
    store = filled_store()
    book = store.get(2)
    updated = store.update(dataclasses.replace(book, status=Status.READ, rating=4, tags=("classic",)))
    assert (updated.status, updated.rating, updated.tags) == (Status.READ, 4, ("classic",)), f"after update the book is {updated!r}"
    assert store.get(2) == updated, "update should be saved"
    try:
        store.add(Book(title="Dune (another copy)", author="Frank Herbert", isbn="9780441013593"))
    except Duplicate:
        pass
    else:
        raise AssertionError("adding a second book with ISBN 9780441013593 should raise Duplicate")
    store.remove(2)
    assert [b.id for b in store.search()] == [4, 3, 1], f"after remove(2) the ids are {[b.id for b in store.search()]!r}"
    try:
        store.remove(2)
    except NotFound:
        pass
    else:
        raise AssertionError("removing #2 twice should raise NotFound")


def test_store_uses_placeholders():
    """Quotes and SQL in a title are stored as text, never run as SQL"""
    store = SqliteStore(":memory:")
    title = "Robert'); DROP TABLE books;--"
    store.add(Book(title=title, author="O'Brien"))
    found = store.search(text="'); DROP")
    assert [b.title for b in found] == [title], f"searching for the quote found {found!r}"
    assert [b.author for b in store.search(author="o'brien")] == ["O'Brien"], "an author with a quote should be found"
    assert len(store.search()) == 1, "the books table should still be there with its one book"


def test_store_persists():
    """Books saved to a database file are still there when it is opened again"""
    with tempfile.TemporaryDirectory() as folder:
        path = Path(folder) / "library.db"
        with SqliteStore(path) as store:
            store.add(books()[0])
        with SqliteStore(path) as store:
            got = [b.title for b in store.search()]
    assert got == ["The Left Hand of Darkness"], f"after reopening, the database holds {got!r}"


def test_loans():
    """A book can be lent once until it comes back; loans lists open and returned loans"""
    store = filled_store()
    loan = store.lend(2, "sam", date(2026, 8, 1), date(2026, 8, 29))
    assert loan == Loan(2, "sam", date(2026, 8, 1), date(2026, 8, 29)), f"lend returned {loan!r}"
    try:
        store.lend(2, "kim", date(2026, 8, 2), date(2026, 8, 30))
    except LoanError as err:
        assert "sam" in str(err), f"the error {str(err)!r} should say who has the book"
    else:
        raise AssertionError("lending a book that is already lent should raise LoanError")
    back = store.give_back(2, date(2026, 9, 3))
    assert back.returned_on == date(2026, 9, 3) and not back.open, f"give_back returned {back!r}"
    try:
        store.give_back(2, date(2026, 9, 4))
    except LoanError:
        pass
    else:
        raise AssertionError("returning a book that is not lent should raise LoanError")
    store.lend(1, "kim", date(2026, 9, 5), date(2026, 10, 3))
    got = [(l.book_id, l.borrower, l.open) for l in store.loans()], [l.borrower for l in store.loans(open_only=True)]
    assert got == ([(2, "sam", False), (1, "kim", True)], ["kim"]), f"loans() and loans(open_only=True) gave {got!r}"


def test_group_by():
    """group_by is generic in the key and the item, and keeps the order items came in"""
    params = getattr(group_by, "__type_params__", ())
    assert len(params) == 2, f"group_by has type parameters {params!r}, expected two, such as [K, V]"
    got = group_by(books(), lambda b: b.author)
    assert list(got) == ["Ursula K. Le Guin", "Frank Herbert", "Ann Other"], f"the groups are {list(got)!r}"
    assert [b.year for b in got["Ursula K. Le Guin"]] == [1969, 1968], "the items in a group should keep their order"


def test_render_templates():
    """render_text works like an f-string; render_html escapes every value except Safe ones"""
    value, name = 3.14159, "Dune"
    assert render_text(t"{value:.2f} {name!r}") == "3.14 'Dune'", f"render_text gave {render_text(t'{value:.2f} {name!r}')!r}"
    title = '<script>alert("hi")</script> & more'
    got = render_html(t"<td>{title}</td>")
    assert got == "<td>&lt;script&gt;alert(&quot;hi&quot;)&lt;/script&gt; &amp; more</td>", f"render_html gave {got!r}"
    inner = Safe("<b>bold</b>")
    assert render_html(t"<p>{inner}</p>") == "<p><b>bold</b></p>", "a Safe value should be inserted as it is"
    assert isinstance(got, Safe), "render_html should return Safe, so its result can go into another template"


def test_reports():
    """stats_report and overdue_report give the expected text"""
    shelf = [dataclasses.replace(b, id=n) for n, b in enumerate(books(), start=1)]
    loans = [
        Loan(2, "sam", date(2026, 8, 1), date(2026, 8, 29)),
        Loan(1, "kim", date(2026, 8, 20), date(2026, 9, 17)),
        Loan(3, "sam", date(2026, 7, 1), date(2026, 7, 29), date(2026, 8, 1)),
    ]
    got = stats_report(shelf, loans, date(2026, 9, 3)).splitlines()
    want = [
        "Books: 4 (1 unread, 1 reading, 2 read)",
        "Authors: 3",
        "Average rating: 4.5 from 2 rated",
        "On loan: 2, 1 overdue",
        "Top tags: sf (2), classic (1), fantasy (1)",
    ]
    assert got == want, f"stats_report gave {got!r}, expected {want!r}"
    got = overdue_report(loans, {b.id: b for b in shelf}, date(2026, 9, 3)).splitlines()
    want = ["sam:", "  #2 Dune by Frank Herbert (1965): due 2026-08-29, 5 days overdue"]
    assert got == want, f"overdue_report gave {got!r}, expected {want!r}"
    assert overdue_report(loans, {b.id: b for b in shelf}, date(2026, 8, 1)) == "Nothing is overdue.", "with nothing overdue the report should say so"


def test_commands_use_the_protocol():
    """A command works with any Repository, here a mock made from the protocol"""
    repo = mock.create_autospec(Repository, instance=True)
    repo.get.return_value = Book(id=3, title="Dune", author="Frank Herbert")
    out = cmd_remove(repo, argparse.Namespace(id=3), Config())
    repo.remove.assert_called_once_with(3)
    assert out == "Removed #3: Dune by Frank Herbert", f"cmd_remove returned {out!r}"


def test_cli_add_list_show():
    """library add, list and show work on a database file"""
    with tempfile.TemporaryDirectory() as folder:
        db = str(Path(folder) / "books.db")
        status, out, _ = run(["--db", db, "add", "Dune", "Frank Herbert", "--year", "1965", "--isbn", DUNE, "--tag", "sf", "--tag", "classic", "--rating", "5"])
        assert (status, out.strip()) == (0, "Added #1: Dune by Frank Herbert (1965)"), f"add returned {status} and printed {out!r}"
        run(["--db", db, "add", "A Wizard of Earthsea", "Ursula K. Le Guin", "--status", "read"])
        status, out, _ = run(["--db", db, "list", "--tag", "sf"])
        lines = out.splitlines()
        assert status == 0 and len(lines) == 1 and lines[0].startswith("#1") and "Dune" in lines[0] and "5/5" in lines[0], f"list --tag sf printed {out!r}"
        status, out, _ = run(["--db", db, "show", "1"])
    want = ["#1 Dune by Frank Herbert (1965)", "ISBN: 9780441013593", "Status: unread, rating 5/5", "Tags: classic, sf", "On the shelf"]
    assert (status, out.splitlines()) == (0, want), f"show 1 returned {status} and printed {out.splitlines()!r}, expected {want!r}"


def test_cli_edit_and_remove():
    """library edit changes only the options given; remove deletes the book"""
    with tempfile.TemporaryDirectory() as folder:
        db = str(Path(folder) / "books.db")
        run(["--db", db, "add", "Dune", "Frank Herbert", "--tag", "sf"])
        status, out, _ = run(["--db", db, "edit", "1", "--status", "read", "--rating", "4"])
        assert (status, out.strip()) == (0, "Updated #1: Dune by Frank Herbert"), f"edit returned {status} and printed {out!r}"
        _, out, _ = run(["--db", db, "show", "1"])
        assert "Status: read, rating 4/5" in out and "Tags: sf" in out, f"after edit, show printed {out!r}"
        status, _, err = run(["--db", db, "edit", "1"])
        assert status == 1 and "nothing to change" in err, f"edit with no options returned {status} and logged {err!r}"
        status, out, _ = run(["--db", db, "remove", "1"])
        assert (status, out.strip()) == (0, "Removed #1: Dune by Frank Herbert"), f"remove returned {status} and printed {out!r}"
        status, out, _ = run(["--db", db, "list"])
    assert out.strip() == "No books found.", f"after remove, list printed {out!r}"


def test_cli_loans():
    """library lend, overdue and return use today's date, replaced here with mock.patch"""
    with tempfile.TemporaryDirectory() as folder:
        db = str(Path(folder) / "books.db")
        run(["--db", db, "add", "Dune", "Frank Herbert", "--year", "1965"])
        status, out, _ = run(["--db", db, "lend", "1", "sam"], on=date(2026, 8, 1))
        assert (status, out.strip()) == (0, "Lent #1 Dune to sam until 2026-08-29"), f"lend returned {status} and printed {out!r}"
        status, _, err = run(["--db", db, "lend", "1", "kim"])
        assert status == 1 and "sam" in err, f"lending it again returned {status} and logged {err!r}"
        _, out, _ = run(["--db", db, "overdue"], on=date(2026, 9, 3))
        assert out.splitlines() == ["sam:", "  #1 Dune by Frank Herbert (1965): due 2026-08-29, 5 days overdue"], f"overdue printed {out!r}"
        status, out, _ = run(["--db", db, "return", "1"], on=date(2026, 9, 3))
        assert (status, out.strip()) == (0, "Returned #1 Dune from sam (5 days late)"), f"return returned {status} and printed {out!r}"
        _, out, _ = run(["--db", db, "overdue"], on=date(2026, 9, 3))
    assert out.strip() == "Nothing is overdue.", f"after the return, overdue printed {out!r}"


def test_cli_stats_and_export():
    """library stats prints the statistics; export writes an HTML page with every title escaped"""
    with tempfile.TemporaryDirectory() as folder:
        db = str(Path(folder) / "books.db")
        run(["--db", db, "add", "Dune", "Frank Herbert", "--rating", "4", "--tag", "sf"])
        run(["--db", db, "add", "<i>Tricky</i> & Co", "A. Writer", "--status", "read"])
        status, out, _ = run(["--db", db, "stats"])
        assert status == 0 and out.splitlines()[0] == "Books: 2 (1 unread, 0 reading, 1 read)", f"stats printed {out!r}"
        page = Path(folder) / "catalogue.html"
        status, out, _ = run(["--db", db, "export", str(page), "--title", "Sam's books"])
        assert (status, out.strip()) == (0, f"Wrote 2 books to {page}"), f"export returned {status} and printed {out!r}"
        html = page.read_text(encoding="utf-8")
    assert "&lt;i&gt;Tricky&lt;/i&gt; &amp; Co" in html and "<i>Tricky" not in html, "the title should be escaped in the HTML"
    assert "<title>Sam&#x27;s books</title>" in html and "<td>4/5</td>" in html, "the page should have the escaped title and the rating"


def test_cli_config_and_errors():
    """--config sets the database and loan length; errors give exit status 1, bad configuration 2"""
    with tempfile.TemporaryDirectory() as folder:
        config = Path(folder) / "library.toml"
        config.write_text('[library]\ndatabase = "mine.db"\nloan_days = 14\n', encoding="utf-8")
        run(["--config", str(config), "add", "Dune", "Frank Herbert"])
        assert (Path(folder) / "mine.db").exists(), "the database should be created where the configuration says"
        _, out, _ = run(["--config", str(config), "lend", "1", "sam"], on=date(2026, 8, 1))
        assert out.strip().endswith("until 2026-08-15"), f"with loan_days = 14, lend printed {out!r}"
        checks = [
            (["--config", str(config), "show", "7"], 1, "#7"),
            (["--config", str(config), "add", "X", "Y", "--isbn", "978-0-441-01359-4"], 1, "check digit"),
            (["--config", str(Path(folder) / "none.toml"), "list"], 2, "none.toml"),
        ]
        for argv, want, word in checks:
            status, out, err = run(argv)
            assert (status, out) == (want, ""), f"{argv[2:]!r} returned {status} and printed {out!r}, expected {want} and nothing"
            assert word in err, f"for {argv[2:]!r} the error {err!r} should mention {word!r}"
    for argv in [[], ["add", "Only a title"], ["edit", "1", "--rating", "9"], ["list", "--status", "lost"]]:
        try:
            with contextlib.redirect_stderr(io.StringIO()):
                main(argv)
        except SystemExit as exc:
            assert exc.code == 2, f"for {argv!r} the exit status was {exc.code!r}, expected 2"
        else:
            raise AssertionError(f"main({argv!r}) returned instead of exiting with status 2")


def test_pyproject_script():
    """pyproject.toml declares the library console script and the README"""
    with open("pyproject.toml", "rb") as f:
        project = tomllib.load(f).get("project", {})
    scripts = project.get("scripts", {})
    assert scripts.get("library") == "library.cli:main", f"[project.scripts] is {scripts!r}, expected library = \"library.cli:main\""
    assert project.get("readme") == "README.md" and Path("README.md").exists(), "[project] should name README.md as its readme, and the file should exist"

Run the finished program

python -m venv .venv, activate it, then python -m pip install -e . and library --config samples/library.toml add "Dune" "Frank Herbert" --year 1965, then library --config samples/library.toml list (or python -m library ... without installing)
Reference solution

Try the milestones first. This solution passes every acceptance test and the type checker.

pyproject.toml

[build-system]
requires = ["setuptools >= 77.0.3"]
build-backend = "setuptools.build_meta"

[project]
name = "library-manager"
version = "1.0.0"
description = "Keep track of your books, what you have read, and who has borrowed what."
readme = "README.md"
requires-python = ">= 3.14"
dependencies = []

[project.scripts]
library = "library.cli:main"

[tool.setuptools]
packages = ["library"]

README.md

# library: a personal library manager

A command-line tool that keeps track of the books you own, what you have read,
and who has borrowed what. Everything is stored in one SQLite file on your own
computer. It uses only the Python standard library.

## Install

Python 3.14 or later.

```
python -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate
python -m pip install -e .
```

## Use

```
library add "The Left Hand of Darkness" "Ursula K. Le Guin" --year 1969 --tag sf --status read --rating 5
library list --tag sf
library show 1
library edit 1 --status reading
library lend 1 sam
library overdue
library return 1
library stats
library export catalogue.html
```

`library COMMAND --help` explains each command. Add `-v` to see what happens.

## Configuration

By default the database is `library.db` in the current folder and a loan lasts
28 days. `--config FILE` reads a TOML file instead (see `samples/library.toml`):

```toml
[library]
database = "library.db"   # relative to the folder of this file
loan_days = 21
```

`--db FILE` overrides the database for one command.

## Design

- `library/model.py`: the domain, `Book` and `Loan` (frozen dataclasses that
  validate themselves, including ISBN check digits) and the `Status` enum.
- `library/store.py`: the `Repository` protocol, and `SqliteStore`, which
  implements it with parameterised queries only. A partial unique index allows
  one open loan per book.
- `library/reports.py`: text and HTML reports written as template strings;
  `render_html` escapes every value, so a book title cannot inject HTML.
- `library/config.py` and `library/cli.py`: TOML settings and the argparse
  command line. Exit status 0 means success, 1 a failed command, 2 bad
  arguments or configuration.

## Tests

The acceptance tests are in `test_main.py`. They use an SQLite database in a temporary folder, and `unittest.mock`
to fix today's date.

## Limitations and next steps

- Searching ignores case only for ASCII letters (SQLite's `LIKE`).
- There is no import from other catalogues yet.

library/__init__.py

"""A personal library manager: your books, what you have read, and who has borrowed what."""

__version__ = "1.0.0"

library/__main__.py

"""python -m library runs the command line."""

import sys

from .cli import main

sys.exit(main())

library/errors.py

"""The exceptions of the library; the command line turns each into a message and exit status."""


class LibraryError(Exception):
    """Base class: every error this package raises on purpose."""


class NotFound(LibraryError):
    """No book has this id."""


class Duplicate(LibraryError):
    """A book with this ISBN is already in the library."""


class LoanError(LibraryError):
    """The book is already lent, or it is not lent and cannot be returned."""


class ConfigError(LibraryError):
    """The configuration file is missing, is not valid TOML, or has a wrong value."""

library/model.py

"""The domain: books, their reading status, and loans."""

from collections.abc import Iterable
from dataclasses import dataclass
from datetime import date
from enum import StrEnum


class Status(StrEnum):
    """Where you are with a book."""

    UNREAD = "unread"
    READING = "reading"
    READ = "read"


def normalise_isbn(text: str) -> str:
    """An ISBN without hyphens or spaces, with its check digit verified.

    ISBN-13: the digits, weighted 1, 3, 1, 3, ..., add up to a multiple of 10.
    ISBN-10: the digits, weighted 10, 9, ..., 1, add up to a multiple of 11;
    the last one may be X, meaning 10.
    """
    isbn = text.replace("-", "").replace(" ", "").upper()
    if len(isbn) == 13 and isbn.isdigit():
        total = sum(int(d) * (3 if i % 2 else 1) for i, d in enumerate(isbn))
        valid = total % 10 == 0
    elif len(isbn) == 10 and isbn[:9].isdigit() and (isbn[9].isdigit() or isbn[9] == "X"):
        digits = [int(d) for d in isbn[:9]] + [10 if isbn[9] == "X" else int(isbn[9])]
        valid = sum(d * w for d, w in zip(digits, range(10, 0, -1))) % 11 == 0
    else:
        raise ValueError(f"not an ISBN-10 or ISBN-13: {text!r}")
    if not valid:
        raise ValueError(f"the check digit of ISBN {text!r} is wrong")
    return isbn


def normalise_tags(tags: Iterable[str]) -> tuple[str, ...]:
    """Lower case, trimmed, without duplicates, sorted."""
    if isinstance(tags, str):
        raise TypeError("tags must be a collection of strings, not one string")
    return tuple(sorted({tag.strip().lower() for tag in tags} - {""}))


@dataclass(frozen=True, slots=True, kw_only=True)
class Book:
    """A book in your library. id is None until the store has saved it."""

    title: str
    author: str
    year: int | None = None
    isbn: str | None = None
    tags: tuple[str, ...] = ()
    status: Status = Status.UNREAD
    rating: int | None = None
    id: int | None = None

    def __post_init__(self) -> None:
        # A frozen dataclass sets its fields with object.__setattr__, and so can we.
        for name in ("title", "author"):
            value = getattr(self, name).strip()
            if not value:
                raise ValueError(f"a book needs a {name}")
            object.__setattr__(self, name, value)
        if self.year is not None and not 0 < self.year <= 9999:
            raise ValueError(f"year {self.year} is out of range")
        if self.rating is not None and not 1 <= self.rating <= 5:
            raise ValueError(f"a rating is from 1 to 5, not {self.rating}")
        if self.isbn is not None:
            object.__setattr__(self, "isbn", normalise_isbn(self.isbn))
        object.__setattr__(self, "tags", normalise_tags(self.tags))
        object.__setattr__(self, "status", Status(self.status))

    def __str__(self) -> str:
        year = f" ({self.year})" if self.year else ""
        return f"{self.title} by {self.author}{year}"


@dataclass(frozen=True, slots=True)
class Loan:
    """A book lent to someone: open until it is returned."""

    book_id: int
    borrower: str
    lent_on: date
    due_on: date
    returned_on: date | None = None

    @property
    def open(self) -> bool:
        return self.returned_on is None

    def days_overdue(self, today: date) -> int:
        """How many days past the due date an open loan is; 0 if it is not overdue."""
        if not self.open:
            return 0
        return max(0, (today - self.due_on).days)

library/config.py

"""Settings, read from an optional TOML file."""

import tomllib
from dataclasses import dataclass
from pathlib import Path

from .errors import ConfigError


@dataclass(frozen=True, slots=True)
class Config:
    """Where the database is, and how long a loan lasts."""

    database: Path = Path("library.db")
    loan_days: int = 28


def load_config(path: Path | None) -> Config:
    """The settings in path's [library] table, or the defaults if path is None.

    A relative database path is taken relative to the folder of the file, so
    the configuration works from any current directory.
    """
    if path is None:
        return Config()
    try:
        with path.open("rb") as f:
            data = tomllib.load(f)
    except OSError as err:
        raise ConfigError(f"{path}: cannot read it ({err.strerror})") from err
    except tomllib.TOMLDecodeError as err:
        raise ConfigError(f"{path}: not valid TOML ({err})") from err
    table = data.get("library", {})
    if not isinstance(table, dict):
        raise ConfigError(f"{path}: [library] must be a table")
    unknown = set(table) - {"database", "loan_days"}
    if unknown:
        raise ConfigError(f"{path}: unknown setting(s) {', '.join(sorted(unknown))}")
    config = Config()
    database = table.get("database", str(config.database))
    loan_days = table.get("loan_days", config.loan_days)
    if not isinstance(database, str) or not database:
        raise ConfigError(f"{path}: database must be a file name")
    if isinstance(loan_days, bool) or not isinstance(loan_days, int) or loan_days < 1:
        raise ConfigError(f"{path}: loan_days must be a whole number of at least 1, not {loan_days!r}")
    return Config(path.parent / database, loan_days)

library/store.py

"""Storage: the Repository protocol and its sqlite3 implementation."""

import logging
import sqlite3
from collections.abc import Sequence
from datetime import date
from pathlib import Path
from types import TracebackType
from typing import Protocol, Self, runtime_checkable

from .errors import Duplicate, LoanError, NotFound
from .model import Book, Loan, Status

logger = logging.getLogger(__name__)


@runtime_checkable
class Repository(Protocol):
    """What the rest of the program needs from storage; SqliteStore is one way to provide it."""

    def add(self, book: Book) -> Book: ...
    def get(self, book_id: int) -> Book: ...
    def update(self, book: Book) -> Book: ...
    def remove(self, book_id: int) -> None: ...
    def search(
        self, *, text: str | None = None, author: str | None = None, tag: str | None = None, status: Status | None = None
    ) -> list[Book]: ...
    def lend(self, book_id: int, borrower: str, on: date, due: date) -> Loan: ...
    def give_back(self, book_id: int, on: date) -> Loan: ...
    def loans(self, *, open_only: bool = False) -> list[Loan]: ...


# One transaction, so a new database is created in a single write.
SCHEMA = """
BEGIN;
CREATE TABLE IF NOT EXISTS books (
    id INTEGER PRIMARY KEY,
    title TEXT NOT NULL,
    author TEXT NOT NULL,
    year INTEGER,
    isbn TEXT UNIQUE,
    status TEXT NOT NULL CHECK (status IN ('unread', 'reading', 'read')),
    rating INTEGER CHECK (rating BETWEEN 1 AND 5)
);
CREATE TABLE IF NOT EXISTS tags (
    book_id INTEGER NOT NULL REFERENCES books (id) ON DELETE CASCADE,
    tag TEXT NOT NULL,
    PRIMARY KEY (book_id, tag)
);
CREATE TABLE IF NOT EXISTS loans (
    id INTEGER PRIMARY KEY,
    book_id INTEGER NOT NULL REFERENCES books (id) ON DELETE CASCADE,
    borrower TEXT NOT NULL,
    lent_on TEXT NOT NULL,
    due_on TEXT NOT NULL,
    returned_on TEXT
);
-- At most one open loan per book.
CREATE UNIQUE INDEX IF NOT EXISTS one_open_loan ON loans (book_id) WHERE returned_on IS NULL;
COMMIT;
"""


def _like(text: str) -> str:
    """A LIKE pattern matching text anywhere, with % and _ in text taken literally."""
    escaped = text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
    return f"%{escaped}%"


class SqliteStore:
    """A Repository in an SQLite database. Every query binds its values with ? placeholders."""

    def __init__(self, path: str | Path) -> None:
        self._db = sqlite3.connect(path)
        self._db.row_factory = sqlite3.Row
        self._db.execute("PRAGMA foreign_keys = ON")
        self._db.executescript(SCHEMA)
        logger.info("opened %s", path)

    def close(self) -> None:
        self._db.close()

    def __enter__(self) -> Self:
        return self

    def __exit__(self, kind: type[BaseException] | None, error: BaseException | None, tb: TracebackType | None) -> None:
        self.close()

    # Books

    def add(self, book: Book) -> Book:
        try:
            with self._db:  # one transaction: committed, or rolled back on an error
                cursor = self._db.execute(
                    "INSERT INTO books (title, author, year, isbn, status, rating) VALUES (?, ?, ?, ?, ?, ?)",
                    (book.title, book.author, book.year, book.isbn, book.status.value, book.rating),
                )
                book_id = cursor.lastrowid
                assert book_id is not None
                self._db.executemany("INSERT INTO tags (book_id, tag) VALUES (?, ?)", [(book_id, tag) for tag in book.tags])
        except sqlite3.IntegrityError as err:
            if "books.isbn" not in str(err):
                raise
            raise Duplicate(f"a book with ISBN {book.isbn} is already in the library") from err
        logger.info("added #%d: %s", book_id, book)
        return self.get(book_id)

    def get(self, book_id: int) -> Book:
        row = self._db.execute("SELECT * FROM books WHERE id = ?", (book_id,)).fetchone()
        if row is None:
            raise NotFound(f"no book #{book_id}")
        return self._books([row])[0]

    def update(self, book: Book) -> Book:
        if book.id is None:
            raise NotFound("the book has not been saved yet")
        self.get(book.id)
        try:
            with self._db:
                self._db.execute(
                    "UPDATE books SET title = ?, author = ?, year = ?, isbn = ?, status = ?, rating = ? WHERE id = ?",
                    (book.title, book.author, book.year, book.isbn, book.status.value, book.rating, book.id),
                )
                self._db.execute("DELETE FROM tags WHERE book_id = ?", (book.id,))
                self._db.executemany("INSERT INTO tags (book_id, tag) VALUES (?, ?)", [(book.id, tag) for tag in book.tags])
        except sqlite3.IntegrityError as err:
            if "books.isbn" not in str(err):
                raise
            raise Duplicate(f"a book with ISBN {book.isbn} is already in the library") from err
        logger.info("updated #%d", book.id)
        return self.get(book.id)

    def remove(self, book_id: int) -> None:
        with self._db:
            cursor = self._db.execute("DELETE FROM books WHERE id = ?", (book_id,))
        if cursor.rowcount == 0:
            raise NotFound(f"no book #{book_id}")
        logger.info("removed #%d", book_id)

    def search(
        self, *, text: str | None = None, author: str | None = None, tag: str | None = None, status: Status | None = None
    ) -> list[Book]:
        """Books matching every filter given, ordered by author and title.

        text matches part of the title or author, author part of the author,
        both ignoring case (for ASCII letters); tag and status match exactly.
        """
        conditions: list[str] = []
        params: list[object] = []
        if text:
            conditions.append("(title LIKE ? ESCAPE '\\' OR author LIKE ? ESCAPE '\\')")
            params += [_like(text), _like(text)]
        if author:
            conditions.append("author LIKE ? ESCAPE '\\'")
            params.append(_like(author))
        if tag:
            conditions.append("EXISTS (SELECT 1 FROM tags WHERE tags.book_id = books.id AND tags.tag = ?)")
            params.append(tag.strip().lower())
        if status:
            conditions.append("status = ?")
            params.append(Status(status).value)
        # Only fixed SQL text is joined here; every value goes in params.
        where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
        rows = self._db.execute(f"SELECT * FROM books {where} ORDER BY author, title, id", params).fetchall()
        return self._books(rows)

    def _books(self, rows: Sequence[sqlite3.Row]) -> list[Book]:
        """Books from rows of the books table, with their tags, in two queries."""
        if not rows:
            return []
        ids = [row["id"] for row in rows]
        placeholders = ", ".join("?" * len(ids))
        tags: dict[int, list[str]] = {}
        for book_id, tag in self._db.execute(f"SELECT book_id, tag FROM tags WHERE book_id IN ({placeholders})", ids):
            tags.setdefault(book_id, []).append(tag)
        return [
            Book(
                id=row["id"],
                title=row["title"],
                author=row["author"],
                year=row["year"],
                isbn=row["isbn"],
                status=Status(row["status"]),
                rating=row["rating"],
                tags=tuple(tags.get(row["id"], [])),
            )
            for row in rows
        ]

    # Loans

    def lend(self, book_id: int, borrower: str, on: date, due: date) -> Loan:
        book = self.get(book_id)
        borrower = borrower.strip()
        if not borrower:
            raise LoanError("a loan needs a borrower")
        current = self._open_loan(book_id)
        if current is not None:
            raise LoanError(f"{book.title} is already lent to {current.borrower}")
        with self._db:
            self._db.execute(
                "INSERT INTO loans (book_id, borrower, lent_on, due_on) VALUES (?, ?, ?, ?)",
                (book_id, borrower, on.isoformat(), due.isoformat()),
            )
        logger.info("lent #%d to %s until %s", book_id, borrower, due)
        return Loan(book_id, borrower, on, due)

    def give_back(self, book_id: int, on: date) -> Loan:
        book = self.get(book_id)
        current = self._open_loan(book_id)
        if current is None:
            raise LoanError(f"{book.title} is not lent to anyone")
        with self._db:
            self._db.execute(
                "UPDATE loans SET returned_on = ? WHERE book_id = ? AND returned_on IS NULL", (on.isoformat(), book_id)
            )
        logger.info("#%d returned by %s", book_id, current.borrower)
        return Loan(current.book_id, current.borrower, current.lent_on, current.due_on, on)

    def loans(self, *, open_only: bool = False) -> list[Loan]:
        sql = "SELECT * FROM loans" + (" WHERE returned_on IS NULL" if open_only else "") + " ORDER BY due_on, id"
        return [self._loan(row) for row in self._db.execute(sql)]

    def _open_loan(self, book_id: int) -> Loan | None:
        row = self._db.execute("SELECT * FROM loans WHERE book_id = ? AND returned_on IS NULL", (book_id,)).fetchone()
        return None if row is None else self._loan(row)

    @staticmethod
    def _loan(row: sqlite3.Row) -> Loan:
        returned = row["returned_on"]
        return Loan(
            row["book_id"],
            row["borrower"],
            date.fromisoformat(row["lent_on"]),
            date.fromisoformat(row["due_on"]),
            date.fromisoformat(returned) if returned else None,
        )

library/reports.py

"""Reports rendered from template strings: plain text, or HTML with every value escaped."""

import html
from collections import Counter
from collections.abc import Callable, Iterable, Mapping, Sequence
from datetime import date
from string.templatelib import Template, convert
from typing import Literal

from .model import Book, Loan, Status


class Safe(str):
    """Text that is already HTML. render_html inserts it as it is."""


def _formatted(value: object, conversion: Literal["a", "r", "s"] | None, format_spec: str) -> str:
    # What an f-string does with {value!conversion:format_spec}.
    return format(convert(value, conversion), format_spec)


def render_text(template: Template) -> str:
    """The template as an f-string would give it."""
    return "".join(
        part if isinstance(part, str) else _formatted(part.value, part.conversion, part.format_spec) for part in template
    )


def render_html(template: Template) -> Safe:
    """The template as HTML: the literal parts as written, every value escaped unless it is Safe."""
    parts: list[str] = []
    for part in template:
        if isinstance(part, str):
            parts.append(part)  # written by the programmer, so trusted
        else:
            text = _formatted(part.value, part.conversion, part.format_spec)
            parts.append(text if isinstance(part.value, Safe) else html.escape(text))
    return Safe("".join(parts))


def group_by[K, V](items: Iterable[V], key: Callable[[V], K]) -> dict[K, list[V]]:
    """The items in lists by key, keeping their order; the groups in the order first seen."""
    groups: dict[K, list[V]] = {}
    for item in items:
        groups.setdefault(key(item), []).append(item)
    return groups


def rating_text(rating: int | None) -> str:
    return f"{rating}/5" if rating else "-"


def days_text(days: int) -> str:
    return "1 day" if days == 1 else f"{days} days"


def book_table(books: Sequence[Book]) -> str:
    """One line per book: id, title and author, status, rating and tags."""
    lines = []
    for book in books:
        tags = ", ".join(book.tags)
        line = render_text(t"#{book.id:<4} {str(book):<48} {book.status:<8} {rating_text(book.rating):<4} {tags}")
        lines.append(line.rstrip())
    return "\n".join(lines) if lines else "No books found."


def stats_report(books: Sequence[Book], loans: Sequence[Loan], today: date) -> str:
    """Counts per status, authors, the average rating, loans and the most used tags."""
    by_status = group_by(books, lambda book: book.status)
    counts = ", ".join(f"{len(by_status.get(status, []))} {status}" for status in Status)
    authors = len({book.author.casefold() for book in books})
    ratings = [book.rating for book in books if book.rating]
    open_loans = [loan for loan in loans if loan.open]
    overdue = sum(1 for loan in open_loans if loan.days_overdue(today))
    tag_counts = sorted(Counter(tag for book in books for tag in book.tags).items(), key=lambda item: (-item[1], item[0]))
    top = ", ".join(f"{tag} ({n})" for tag, n in tag_counts[:3]) or "none"
    lines = [
        t"Books: {len(books)} ({counts})",
        t"Authors: {authors}",
        t"Average rating: {sum(ratings) / len(ratings):.1f} from {len(ratings)} rated" if ratings else t"Average rating: none yet",
        t"On loan: {len(open_loans)}, {overdue} overdue",
        t"Top tags: {top}",
    ]
    return "\n".join(render_text(line) for line in lines)


def overdue_report(loans: Sequence[Loan], books: Mapping[int, Book], today: date) -> str:
    """Overdue loans grouped by borrower, most overdue first within each."""
    overdue = sorted((loan for loan in loans if loan.days_overdue(today)), key=lambda loan: loan.due_on)
    if not overdue:
        return "Nothing is overdue."
    lines = []
    for borrower, group in sorted(group_by(overdue, lambda loan: loan.borrower).items()):
        lines.append(render_text(t"{borrower}:"))
        for loan in group:
            book = books[loan.book_id]
            lines.append(render_text(t"  #{book.id} {book}: due {loan.due_on}, {days_text(loan.days_overdue(today))} overdue"))
    return "\n".join(lines)


def html_page(books: Sequence[Book], title: str) -> str:
    """The books as a stand-alone HTML page. Titles, authors and tags are escaped."""
    rows = Safe("\n".join(
        render_html(
            t"      <tr><td>{book.title}</td><td>{book.author}</td><td>{book.year or ''}</td>"
            t"<td>{book.status}</td><td>{rating_text(book.rating)}</td><td>{', '.join(book.tags)}</td></tr>"
        )
        for book in books
    ))
    return render_html(t"""<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>{title}</title>
  </head>
  <body>
    <h1>{title}</h1>
    <table>
      <tr><th>Title</th><th>Author</th><th>Year</th><th>Status</th><th>Rating</th><th>Tags</th></tr>
{rows}
    </table>
  </body>
</html>
""")

library/cli.py

"""The command line: library [--config FILE] [--db FILE] [-v] COMMAND ..."""

import argparse
import dataclasses
import logging
import sqlite3
import sys
from collections.abc import Callable
from datetime import date, timedelta
from pathlib import Path

from . import __version__
from .config import Config, load_config
from .errors import ConfigError, LibraryError
from .model import Book, Status
from .reports import book_table, days_text, html_page, overdue_report, rating_text, stats_report
from .store import Repository, SqliteStore

logger = logging.getLogger("library")

EXIT_OK = 0
EXIT_ERROR = 1  # the command failed: no such book, a duplicate ISBN, a bad value
EXIT_USAGE = 2  # a bad configuration file; argparse also exits with 2 on bad arguments

type Handler = Callable[[Repository, argparse.Namespace, Config], str]


def today() -> date:
    """Today's date; a function of its own so that tests can replace it."""
    return date.today()


# The commands. Each takes the store, the parsed arguments and the settings,
# and returns the text to print; errors are raised and reported by main.

def cmd_add(store: Repository, args: argparse.Namespace, config: Config) -> str:
    book = store.add(Book(
        title=args.title, author=args.author, year=args.year, isbn=args.isbn,
        tags=tuple(args.tag or ()), status=Status(args.status), rating=args.rating,
    ))
    return f"Added #{book.id}: {book}"


def cmd_list(store: Repository, args: argparse.Namespace, config: Config) -> str:
    status = Status(args.status) if args.status else None
    return book_table(store.search(text=args.search, author=args.author, tag=args.tag, status=status))


def cmd_show(store: Repository, args: argparse.Namespace, config: Config) -> str:
    book = store.get(args.id)
    loan = next((loan for loan in store.loans(open_only=True) if loan.book_id == book.id), None)
    lines = [
        f"#{book.id} {book}",
        f"ISBN: {book.isbn or '-'}",
        f"Status: {book.status}, rating {rating_text(book.rating)}",
        f"Tags: {', '.join(book.tags) or '-'}",
        f"Lent to {loan.borrower}, due {loan.due_on}" if loan else "On the shelf",
    ]
    return "\n".join(lines)


def cmd_edit(store: Repository, args: argparse.Namespace, config: Config) -> str:
    if all(getattr(args, name) is None for name in ("title", "author", "year", "isbn", "rating", "status", "tag")):
        raise LibraryError("nothing to change: give at least one option, such as --status read")
    book = store.get(args.id)
    # replace builds a new Book, so __post_init__ checks the new values too.
    book = store.update(dataclasses.replace(
        book,
        title=book.title if args.title is None else args.title,
        author=book.author if args.author is None else args.author,
        year=book.year if args.year is None else args.year,
        isbn=book.isbn if args.isbn is None else args.isbn,
        rating=book.rating if args.rating is None else args.rating,
        status=book.status if args.status is None else Status(args.status),
        tags=book.tags if args.tag is None else tuple(args.tag),
    ))
    return f"Updated #{book.id}: {book}"


def cmd_remove(store: Repository, args: argparse.Namespace, config: Config) -> str:
    book = store.get(args.id)
    store.remove(args.id)
    return f"Removed #{args.id}: {book}"


def cmd_lend(store: Repository, args: argparse.Namespace, config: Config) -> str:
    on = today()
    loan = store.lend(args.id, args.borrower, on, on + timedelta(days=args.days or config.loan_days))
    return f"Lent #{args.id} {store.get(args.id).title} to {loan.borrower} until {loan.due_on}"


def cmd_return(store: Repository, args: argparse.Namespace, config: Config) -> str:
    on = today()
    loan = store.give_back(args.id, on)
    late = (on - loan.due_on).days
    return f"Returned #{args.id} {store.get(args.id).title} from {loan.borrower}" + (f" ({days_text(late)} late)" if late > 0 else "")


def cmd_overdue(store: Repository, args: argparse.Namespace, config: Config) -> str:
    books = {book.id: book for book in store.search() if book.id is not None}
    return overdue_report(store.loans(open_only=True), books, today())


def cmd_stats(store: Repository, args: argparse.Namespace, config: Config) -> str:
    return stats_report(store.search(), store.loans(), today())


def cmd_export(store: Repository, args: argparse.Namespace, config: Config) -> str:
    books = store.search()
    args.file.write_text(html_page(books, args.title), encoding="utf-8")
    return f"Wrote {len(books)} book{'' if len(books) == 1 else 's'} to {args.file}"


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(prog="library", description="Keep track of your books, what you have read, and who has borrowed what.")
    parser.add_argument("--config", type=Path, metavar="FILE", help="a TOML file with a [library] table")
    parser.add_argument("--db", type=Path, metavar="FILE", help="the SQLite database (overrides the configuration)")
    parser.add_argument("-v", "--verbose", action="count", default=0, help="log what happens (-vv for more)")
    parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
    commands = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")

    def command(name: str, handler: Handler, help: str) -> argparse.ArgumentParser:
        sub = commands.add_parser(name, help=help, description=help)
        sub.set_defaults(handler=handler)
        return sub

    def book_options(sub: argparse.ArgumentParser, *, required: bool) -> None:
        if required:
            sub.add_argument("title")
            sub.add_argument("author")
        else:
            sub.add_argument("--title")
            sub.add_argument("--author")
        sub.add_argument("--year", type=int)
        sub.add_argument("--isbn")
        sub.add_argument("--tag", action="append", help="a tag; repeat for more")
        sub.add_argument("--status", choices=[s.value for s in Status], default="unread" if required else None)
        sub.add_argument("--rating", type=int, choices=range(1, 6), metavar="1-5")

    book_options(command("add", cmd_add, "add a book"), required=True)
    sub = command("list", cmd_list, "list books, optionally filtered")
    sub.add_argument("--search", help="part of the title or author")
    sub.add_argument("--author", help="part of the author's name")
    sub.add_argument("--tag")
    sub.add_argument("--status", choices=[s.value for s in Status])
    command("show", cmd_show, "show one book").add_argument("id", type=int)
    sub = command("edit", cmd_edit, "change a book")
    sub.add_argument("id", type=int)
    book_options(sub, required=False)
    command("remove", cmd_remove, "remove a book").add_argument("id", type=int)
    sub = command("lend", cmd_lend, "lend a book to someone")
    sub.add_argument("id", type=int)
    sub.add_argument("borrower")
    sub.add_argument("--days", type=int, help="loan length (default: loan_days from the configuration)")
    command("return", cmd_return, "record that a lent book came back").add_argument("id", type=int)
    command("overdue", cmd_overdue, "list overdue loans by borrower")
    command("stats", cmd_stats, "show statistics")
    sub = command("export", cmd_export, "write the catalogue as an HTML page")
    sub.add_argument("file", type=Path)
    sub.add_argument("--title", default="My library")
    return parser


def configure_logging(verbosity: int) -> None:
    """Errors and, with -v, progress go to standard error, so they never mix with the output."""
    handler = logging.StreamHandler(sys.stderr)
    handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
    logger.handlers[:] = [handler]
    logger.setLevel(logging.WARNING if verbosity == 0 else logging.INFO if verbosity == 1 else logging.DEBUG)
    logger.propagate = False


def main(argv: list[str] | None = None) -> int:
    """Run the tool with argv (default: sys.argv[1:]); return the exit status."""
    args = build_parser().parse_args(argv)
    configure_logging(args.verbose)
    try:
        config = load_config(args.config)
    except ConfigError as err:
        logger.error("%s", err)
        return EXIT_USAGE
    database = args.db or config.database
    try:
        with SqliteStore(database) as store:
            print(args.handler(store, args, config))
    except (LibraryError, ValueError) as err:
        logger.error("%s", err)
        return EXIT_ERROR
    except (sqlite3.Error, OSError) as err:
        logger.error("%s: %s", database, err)
        return EXIT_ERROR
    return EXIT_OK


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

samples/library.toml

# Settings for the library command: library --config samples/library.toml stats
[library]
# Relative to the folder of this file.
database = "library.db"
# How many days a loan lasts unless lend is given --days.
loan_days = 21

Take it further

  • Import and export CSV with the csv module, so that a catalogue can move in from a spreadsheet, and report each row that was skipped and why.
  • Write a second Repository that keeps the library in a JSON file, and run the same store tests against both implementations.
  • Version the schema with PRAGMA user_version, and migrate an older database when it is opened, for example to add a notes column.
  • Add a --json option to list and stats, typing each record as a TypedDict, and keep mypy clean.
  • Profile adding and searching 10,000 books with cProfile, then add the indexes the queries need and measure again.

Write its README

The capstone is work you can show. A good README covers:

  • What the tool does and for whom, in two sentences, with one example command and its output near the top.
  • How to install it: the Python version, a virtual environment, and python -m pip install -e .
  • A short session copied from a real run: add, list, lend, overdue, stats and export.
  • The configuration file: the [library] table, each setting and its default, and that the database path is relative to the file.
  • The design: one line per module, and why storage sits behind the Repository protocol.
  • The safety choices, each with the test that proves it: no SQL built from input, and every value escaped in the HTML export.
  • How to run the tests and what they cover, including the date fixed with unittest.mock.
  • Limitations and next steps, stated honestly. Present it as portfolio work you built while learning, never as a certification.

Projects are practice: your checks run in your browser or on your computer and never count toward a certificate.