// Abschlussprojekt · etwa 24 Stunden Arbeit
Persönliche Bibliotheksverwaltung
Sie bauen library, ein Kommandozeilenwerkzeug für Ihre eigenen Bücher: was Sie gelesen haben, wie es Ihnen gefiel und wer was ausgeliehen hat. Die Bücher liegen in einer SQLite-Datenbank hinter einem Repository-Protokoll, jede Abfrage bindet ihre Werte über Platzhalter, und die Einstellungen stehen in einer TOML-Datei. Berichte sind Template-Strings: dieselbe Art Vorlage ergibt Text im Terminal und eine HTML-Seite, in der jeder Titel maskiert ist. Die Tests nutzen temporäre Datenbanken und unittest.mock für ein festes Datum. Das Projekt verbindet alle drei Stufen: Dataclasses, Enums, argparse und Logging, dann Protocol und Generics, sqlite3, tomllib, t-Strings und Packaging. Es ist ein Projekt, das Sie zeigen können.
Was das fertige Programm kann
- Book und Loan sind eingefrorene Dataclasses, die sich selbst prüfen: Titel und Autor, ein Jahr von 1 bis 9999, eine Bewertung von 1 bis 5 und eine gültige Prüfziffer für ISBN-10 oder ISBN-13. Tags werden kleingeschrieben und sortiert gespeichert.
- Status ist ein StrEnum: unread, reading, read. Eine Ausleihe ist offen, bis das Buch zurückkommt, und um die Tage nach dem Fälligkeitsdatum überfällig.
- Die Speicherung liegt hinter einem zur Laufzeit prüfbaren Repository-Protokoll. SqliteStore setzt es mit sqlite3 um: Tabellen books, tags und loans, eine Transaktion je Änderung und ein ?-Platzhalter für jeden Wert.
- search filtert nach einem Teil von Titel oder Autor, nach Autor, Tag und Status, in jeder Kombination, sortiert nach Autor und dann Titel. % und _ im Suchtext gelten wörtlich.
- Ein zweites Buch mit derselben ISBN löst Duplicate aus, eine unbekannte id NotFound, und ein verliehenes Buch zu verleihen oder ein nicht verliehenes zurückzugeben LoanError. Alle erben von LibraryError.
- Einstellungen kommen aus einer optionalen TOML-Datei mit einer Tabelle [library]: database (relativ zur Datei, Standard library.db) und loan_days (Standard 28). Fehler lösen ConfigError aus.
- Berichte sind Template-Strings: render_text arbeitet wie ein f-String, render_html maskiert jeden Wert, der nicht Safe ist. Ein generisches group_by[K, V] gruppiert Elemente für die Berichte.
- Befehle: add, list, show, edit, remove, lend, return, overdue, stats und export (eine HTML-Seite). Die Optionen --config, --db und -v stehen vor dem Befehl.
- main gibt 0 bei Erfolg zurück, 1, wenn ein Befehl scheitert, und 2 bei fehlerhafter Konfiguration; argparse endet bei falschen Argumenten mit 2. Fehler und mit -v der Fortschritt gehen auf stderr.
- pyproject.toml installiert einen Befehl library und nennt README.md als readme. mypy --check-untyped-defs . meldet keine Fehler.
Aufbau der Startdateien
- pyproject.toml
- Paket-Metadaten und das Build-Backend. Sie ergänzen readme und die Tabelle [project.scripts].
- README.md
- Die README, mit einer Überschrift und einem TODO je Teil. Füllen Sie sie nach und nach aus (siehe die Hinweise zur README unten).
- library/__init__.py
- Kennzeichnet das Paket und enthält __version__. Vollständig.
- library/__main__.py
- Damit startet python -m library die Kommandozeile. Vollständig.
- library/errors.py
- LibraryError und die Unterklassen NotFound, Duplicate, LoanError und ConfigError. Vollständig.
- library/model.py
- Status, Book, Loan, normalise_isbn und normalise_tags. normalise_tags ist vollständig; Book.__post_init__, normalise_isbn und die Methoden von Loan sind Stubs.
- library/config.py
- Die Dataclass Config und load_config, das ein Stub ist.
- library/store.py
- Das Protokoll Repository, das Schema und SqliteStore. Öffnen und Schließen der Datenbank sind geschrieben; die Abfragen sind Stubs.
- library/reports.py
- Safe, render_text, render_html, group_by und die Berichte. book_table und die kleinen Hilfsfunktionen sind vollständig; der Rest sind Stubs.
- library/cli.py
- Die Befehle, der argparse-Parser mit seinen Unterbefehlen und main. Parser, add und list sind vollständig; die übrigen Befehle und main sind Stubs.
- samples/library.toml
- Eine Beispiel-Konfigurationsdatei mit beiden Einstellungen.
Etappen
Etappe 1
Das Domänenmodell
Schreiben Sie normalise_isbn mit beiden Prüfziffer-Regeln, Book.__post_init__ (object.__setattr__ speichert die bereinigten Werte einer eingefrorenen Dataclass) sowie Loan.open und Loan.days_overdue.
Prüfungen, die nach dieser Etappe bestehen:
- normalise_isbn entfernt Bindestriche und prüft die Prüfziffer von ISBN-10 und ISBN-13
- Book kürzt seine Texte, normalisiert Tags und ISBN, lehnt falsche Werte ab und ist eingefroren
- Eine Ausleihe ist um die Tage nach dem Fälligkeitsdatum überfällig, nach der Rückgabe nie
Etappe 2
Konfiguration
Schreiben Sie load_config mit tomllib: die Standardwerte für None, die Tabelle [library], den Datenbankpfad relativ zur Datei und ConfigError für jeden Fehler.
Prüfungen, die nach dieser Etappe bestehen:
- load_config liefert die Standardwerte, liest [library] aus TOML und lehnt falsche Einstellungen ab
Etappe 3
SQLite-Speicher
Schreiben Sie die Buch-Methoden von SqliteStore: jede Änderung in `with self._db:`, jeder Wert als ?-Parameter, search nur aus festen SQL-Teilen gebaut, und _books lädt alle Tags mit einer Abfrage.
Prüfungen, die nach dieser Etappe bestehen:
- SqliteStore ist ein Repository; add gibt dem Buch eine id, und get liest es wieder
- search filtert nach einem Teil von Titel oder Autor, nach Autor, Tag und Status, sortiert nach Autor
- update speichert Änderungen, remove löscht, und ein zweites Buch mit derselben ISBN ist ein Duplicate
- Anführungszeichen und SQL in einem Titel werden als Text gespeichert, nie als SQL ausgeführt
- In einer Datenbankdatei gespeicherte Bücher sind nach dem erneuten Öffnen noch da
Etappe 4
Ausleihen
Schreiben Sie lend, give_back und loans. Suchen Sie zuerst nach einer offenen Ausleihe, damit die Meldung die ausleihende Person nennen kann; der partielle eindeutige Index im Schema ist das Sicherheitsnetz.
Prüfungen, die nach dieser Etappe bestehen:
- Ein Buch kann einmal verliehen werden, bis es zurückkommt; loans listet offene und zurückgegebene Ausleihen
Etappe 5
Berichte aus Template-Strings
Schreiben Sie render_text und render_html, indem Sie über das Template iterieren, group_by mit den Typparametern [K, V], dann stats_report, overdue_report und html_page.
Prüfungen, die nach dieser Etappe bestehen:
- group_by ist generisch in Schlüssel und Element und behält die Reihenfolge der Elemente
- render_text arbeitet wie ein f-String; render_html maskiert jeden Wert außer Safe-Werten
- stats_report und overdue_report liefern den erwarteten Text
Etappe 6
Kommandozeile, Paket und README
Schreiben Sie die übrigen Befehle und main, ergänzen Sie readme und [project.scripts] in pyproject.toml, installieren Sie mit pip install -e . in einer virtuellen Umgebung und füllen Sie README.md aus.
Prüfungen, die nach dieser Etappe bestehen:
- Ein Befehl arbeitet mit jedem Repository, hier einem aus dem Protokoll erzeugten Mock
- library add, list und show arbeiten mit einer Datenbankdatei
- library edit ändert nur die angegebenen Optionen; remove löscht das Buch
- library lend, overdue und return nutzen das heutige Datum, hier mit mock.patch ersetzt
- library stats gibt die Statistik aus; export schreibt eine HTML-Seite mit maskierten Titeln
- --config legt Datenbank und Leihdauer fest; Fehler ergeben Exit-Status 1, eine falsche Konfiguration 2
- pyproject.toml deklariert den Konsolenbefehl library und die README
Dieses Projekt nutzt Teile von Python, die im Browser nicht laufen. Sie bauen es deshalb auf Ihrem Rechner.
Auf dem eigenen Rechner bauen
Legen Sie einen Ordner mit diesen Startdateien an, installieren Sie Python 3.14 und arbeiten Sie die Etappen ab. Die Abnahmetests starten Sie jederzeit mit:
Starter als eine .zip-Datei herunterladen (Starter-Dateien, test_main.py und learnrun.py)python learnrun.py testUnter macOS und Linux tippen Sie python3, wo in diesen Befehlen python steht, wie in der ersten Lektion.
learnrun.py herunterladenpyproject.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
Abnahmetests
Das Projekt ist fertig, wenn jede Prüfung in test_main.py besteht. Lesen Sie sie vor dem Start: Sie sind die Spezifikation, als Code geschrieben.
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)):
"""Ruft main(argv) auf, mit today() fest auf on; gibt (Status, stdout, stderr) zurück."""
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 entfernt Bindestriche und prüft die Prüfziffer von ISBN-10 und 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 ergab {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}) sollte ValueError auslösen")
def test_book_validation():
"""Book kürzt seine Texte, normalisiert Tags und ISBN, lehnt falsche Werte ab und ist eingefroren"""
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"das Book enthält {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 mit {bad!r} sollte ValueError auslösen")
try:
book.title = "Other"
except dataclasses.FrozenInstanceError:
pass
else:
raise AssertionError("ein Book sollte eingefroren sein")
def test_loan_overdue():
"""Eine Ausleihe ist um die Tage nach dem Fälligkeitsdatum überfällig, nach der Rückgabe nie"""
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 am Fälligkeitstag, fünf Tage später, und open: {got!r}, erwartet (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, "eine zurückgegebene Ausleihe ist nicht offen und nie überfällig"
def test_config():
"""load_config liefert die Standardwerte, liest [library] aus TOML und lehnt falsche Einstellungen ab"""
assert load_config(None) == Config(Path("library.db"), 28), f"die Standardwerte sind {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 ergab {got!r}; die Datenbank sollte relativ zur Datei liegen"
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 akzeptierte {text!r}; erwartet ConfigError")
try:
load_config(Path(folder) / "missing.toml")
except ConfigError as err:
assert "missing.toml" in str(err), f"die Meldung {str(err)!r} sollte die Datei nennen"
else:
raise AssertionError("eine fehlende Konfigurationsdatei sollte ConfigError auslösen")
def test_store_add_and_get():
"""SqliteStore ist ein Repository; add gibt dem Buch eine id, und get liest es wieder"""
store = SqliteStore(":memory:")
assert isinstance(store, Repository), "SqliteStore sollte das Protokoll Repository erfüllen"
added = store.add(books()[1])
assert added.id == 1 and added == dataclasses.replace(books()[1], id=1), f"add gab {added!r} zurück"
assert store.get(1) == added, f"get(1) gab {store.get(1)!r} zurück, erwartet {added!r}"
try:
store.get(99)
except NotFound as err:
assert isinstance(err, LibraryError) and "99" in str(err), f"NotFound sollte ein LibraryError sein und #99 nennen, erhalten {err!r}"
else:
raise AssertionError("get(99) sollte NotFound auslösen")
def test_store_search():
"""search filtert nach einem Teil von Titel oder Autor, nach Autor, Tag und Status, sortiert nach Autor"""
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() ergab {titles()!r}, sortiert nach Autor, dann Titel"
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}) ergab {titles(**filters)!r}, erwartet {want!r}"
def test_store_update_and_remove():
"""update speichert Änderungen, remove löscht, und ein zweites Buch mit derselben ISBN ist ein 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"nach update ist das Buch {updated!r}"
assert store.get(2) == updated, "update sollte gespeichert sein"
try:
store.add(Book(title="Dune (another copy)", author="Frank Herbert", isbn="9780441013593"))
except Duplicate:
pass
else:
raise AssertionError("ein zweites Buch mit der ISBN 9780441013593 sollte Duplicate auslösen")
store.remove(2)
assert [b.id for b in store.search()] == [4, 3, 1], f"nach remove(2) sind die ids {[b.id for b in store.search()]!r}"
try:
store.remove(2)
except NotFound:
pass
else:
raise AssertionError("#2 zweimal zu entfernen sollte NotFound auslösen")
def test_store_uses_placeholders():
"""Anführungszeichen und SQL in einem Titel werden als Text gespeichert, nie als SQL ausgeführt"""
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"die Suche nach dem Anführungszeichen fand {found!r}"
assert [b.author for b in store.search(author="o'brien")] == ["O'Brien"], "ein Autor mit Apostroph sollte gefunden werden"
assert len(store.search()) == 1, "die Tabelle books sollte mit ihrem einen Buch noch da sein"
def test_store_persists():
"""In einer Datenbankdatei gespeicherte Bücher sind nach dem erneuten Öffnen noch da"""
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"nach dem erneuten Öffnen enthält die Datenbank {got!r}"
def test_loans():
"""Ein Buch kann einmal verliehen werden, bis es zurückkommt; loans listet offene und zurückgegebene Ausleihen"""
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 gab {loan!r} zurück"
try:
store.lend(2, "kim", date(2026, 8, 2), date(2026, 8, 30))
except LoanError as err:
assert "sam" in str(err), f"die Meldung {str(err)!r} sollte sagen, wer das Buch hat"
else:
raise AssertionError("ein bereits verliehenes Buch zu verleihen sollte LoanError auslösen")
back = store.give_back(2, date(2026, 9, 3))
assert back.returned_on == date(2026, 9, 3) and not back.open, f"give_back gab {back!r} zurück"
try:
store.give_back(2, date(2026, 9, 4))
except LoanError:
pass
else:
raise AssertionError("ein nicht verliehenes Buch zurückzugeben sollte LoanError auslösen")
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() und loans(open_only=True) ergaben {got!r}"
def test_group_by():
"""group_by ist generisch in Schlüssel und Element und behält die Reihenfolge der Elemente"""
params = getattr(group_by, "__type_params__", ())
assert len(params) == 2, f"group_by hat die Typparameter {params!r}, erwartet zwei, etwa [K, V]"
got = group_by(books(), lambda b: b.author)
assert list(got) == ["Ursula K. Le Guin", "Frank Herbert", "Ann Other"], f"die Gruppen sind {list(got)!r}"
assert [b.year for b in got["Ursula K. Le Guin"]] == [1969, 1968], "die Elemente einer Gruppe sollten ihre Reihenfolge behalten"
def test_render_templates():
"""render_text arbeitet wie ein f-String; render_html maskiert jeden Wert außer Safe-Werten"""
value, name = 3.14159, "Dune"
assert render_text(t"{value:.2f} {name!r}") == "3.14 'Dune'", f"render_text ergab {render_text(t'{value:.2f} {name!r}')!r}"
title = '<script>alert("hi")</script> & more'
got = render_html(t"<td>{title}</td>")
assert got == "<td><script>alert("hi")</script> & more</td>", f"render_html ergab {got!r}"
inner = Safe("<b>bold</b>")
assert render_html(t"<p>{inner}</p>") == "<p><b>bold</b></p>", "ein Safe-Wert sollte unverändert eingefügt werden"
assert isinstance(got, Safe), "render_html sollte Safe zurückgeben, damit das Ergebnis in eine andere Vorlage passt"
def test_reports():
"""stats_report und overdue_report liefern den erwarteten 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 ergab {got!r}, erwartet {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 ergab {got!r}, erwartet {want!r}"
assert overdue_report(loans, {b.id: b for b in shelf}, date(2026, 8, 1)) == "Nothing is overdue.", "wenn nichts überfällig ist, sollte der Bericht das sagen"
def test_commands_use_the_protocol():
"""Ein Befehl arbeitet mit jedem Repository, hier einem aus dem Protokoll erzeugten Mock"""
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 gab {out!r} zurück"
def test_cli_add_list_show():
"""library add, list und show arbeiten mit einer Datenbankdatei"""
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 gab {status} zurück und gab {out!r} aus"
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 gab {out!r} aus"
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 gab {status} zurück und gab {out.splitlines()!r} aus, erwartet {want!r}"
def test_cli_edit_and_remove():
"""library edit ändert nur die angegebenen Optionen; remove löscht das Buch"""
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 gab {status} zurück und gab {out!r} aus"
_, out, _ = run(["--db", db, "show", "1"])
assert "Status: read, rating 4/5" in out and "Tags: sf" in out, f"nach edit gab show {out!r} aus"
status, _, err = run(["--db", db, "edit", "1"])
assert status == 1 and "nothing to change" in err, f"edit ohne Optionen gab {status} zurück und protokollierte {err!r}"
status, out, _ = run(["--db", db, "remove", "1"])
assert (status, out.strip()) == (0, "Removed #1: Dune by Frank Herbert"), f"remove gab {status} zurück und gab {out!r} aus"
status, out, _ = run(["--db", db, "list"])
assert out.strip() == "No books found.", f"nach remove gab list {out!r} aus"
def test_cli_loans():
"""library lend, overdue und return nutzen das heutige Datum, hier mit mock.patch ersetzt"""
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 gab {status} zurück und gab {out!r} aus"
status, _, err = run(["--db", db, "lend", "1", "kim"])
assert status == 1 and "sam" in err, f"erneutes Verleihen gab {status} zurück und protokollierte {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 gab {out!r} aus"
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 gab {status} zurück und gab {out!r} aus"
_, out, _ = run(["--db", db, "overdue"], on=date(2026, 9, 3))
assert out.strip() == "Nothing is overdue.", f"nach der Rückgabe gab overdue {out!r} aus"
def test_cli_stats_and_export():
"""library stats gibt die Statistik aus; export schreibt eine HTML-Seite mit maskierten Titeln"""
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 gab {out!r} aus"
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 gab {status} zurück und gab {out!r} aus"
html = page.read_text(encoding="utf-8")
assert "<i>Tricky</i> & Co" in html and "<i>Tricky" not in html, "der Titel sollte im HTML maskiert sein"
assert "<title>Sam's books</title>" in html and "<td>4/5</td>" in html, "die Seite sollte den maskierten Titel und die Bewertung enthalten"
def test_cli_config_and_errors():
"""--config legt Datenbank und Leihdauer fest; Fehler ergeben Exit-Status 1, eine falsche Konfiguration 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(), "die Datenbank sollte dort entstehen, wo die Konfiguration es sagt"
_, out, _ = run(["--config", str(config), "lend", "1", "sam"], on=date(2026, 8, 1))
assert out.strip().endswith("until 2026-08-15"), f"mit loan_days = 14 gab lend {out!r} aus"
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} gab {status} zurück und gab {out!r} aus, erwartet {want} und nichts"
assert word in err, f"für {argv[2:]!r} sollte die Meldung {err!r} {word!r} enthalten"
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"für {argv!r} war der Exit-Status {exc.code!r}, erwartet 2"
else:
raise AssertionError(f"main({argv!r}) kehrte zurück, statt mit Status 2 zu enden")
def test_pyproject_script():
"""pyproject.toml deklariert den Konsolenbefehl library und die 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] ist {scripts!r}, erwartet library = \"library.cli:main\""
assert project.get("readme") == "README.md" and Path("README.md").exists(), "[project] sollte README.md als readme nennen, und die Datei sollte existieren"
Das fertige Programm starten
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)Referenzlösung
Versuchen Sie zuerst die Etappen. Diese Lösung besteht alle Abnahmetests und die Typprüfung.
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
Weiterentwickeln
- Importieren und exportieren Sie CSV mit dem Modul csv, damit ein Katalog aus einer Tabellenkalkulation umziehen kann, und melden Sie jede übersprungene Zeile mit Grund.
- Schreiben Sie ein zweites Repository, das die Bibliothek in einer JSON-Datei hält, und führen Sie dieselben Speicher-Tests mit beiden Umsetzungen aus.
- Versionieren Sie das Schema mit PRAGMA user_version und migrieren Sie eine ältere Datenbank beim Öffnen, etwa um eine Spalte notes zu ergänzen.
- Ergänzen Sie eine Option --json für list und stats, typisieren Sie jeden Datensatz als TypedDict, und halten Sie mypy sauber.
- Profilieren Sie mit cProfile das Hinzufügen und Suchen von 10.000 Büchern, legen Sie dann die Indizes an, die die Abfragen brauchen, und messen Sie erneut.
Die README schreiben
Das Abschlussprojekt können Sie herzeigen. Eine gute README behandelt:
- Was das Werkzeug tut und für wen, in zwei Sätzen, mit einem Beispielbefehl und seiner Ausgabe weit oben.
- Die Installation: die Python-Version, eine virtuelle Umgebung und python -m pip install -e .
- Eine kurze Sitzung aus einem echten Lauf: add, list, lend, overdue, stats und export.
- Die Konfigurationsdatei: die Tabelle [library], jede Einstellung mit ihrem Standardwert und dass der Datenbankpfad relativ zur Datei gilt.
- Der Aufbau: eine Zeile je Modul und warum die Speicherung hinter dem Protokoll Repository liegt.
- Die Sicherheitsentscheidungen, jede mit dem Test, der sie belegt: kein SQL aus Eingaben gebaut und jeder Wert im HTML-Export maskiert.
- Wie man die Tests ausführt und was sie abdecken, einschließlich des mit unittest.mock festgelegten Datums.
- Grenzen und nächste Schritte, ehrlich benannt. Stellen Sie es als Portfolio-Arbeit aus Ihrem Lernen dar, nie als Zertifizierung.
Projekte sind Übung: Ihre Prüfungen laufen im Browser oder auf Ihrem Rechner und zählen nie für eine Bescheinigung.
Quellen
- sqlite3: DB-API 2.0 interface for SQLite databases (Python 3.14; englisch)
- string.templatelib: Support for template string literals (Python 3.14; englisch)
- html: HyperText Markup Language support (Python 3.14; englisch)
- typing: Support for type hints (Python 3.14; englisch)
- Compound statements (Python 3.14 language reference; englisch)
- dataclasses: Data Classes (Python 3.14; englisch)
- tomllib: Parse TOML files (Python 3.14; englisch)
- unittest.mock: mock object library (Python 3.14; englisch)
- Writing your pyproject.toml (Python Packaging User Guide; englisch)
Zuletzt geprüft am 29. September 2026
Zurück zum Kurs Python