Part 2 · Intermediate Python · Graded project
Project 2: Library Management System
Build the circulation system for a small-town library: ISBN validation, copies, members with tiered loan limits, due dates and fines, waiting lists, keyword search, notifications, and JSON persistence. This is where classes, dataclasses, enums, Protocols, decorators, type hints, and pytest stop being separate topics and become one working program.
- Model a domain with frozen and mutable dataclasses, enums carrying data, and custom exceptions
- Decouple services from the outside world with Protocols (a clock and a notifier) and dependency injection
- Choose the right data structure for each job: dicts for lookup, a deque for a waiting list, sets for an inverted index
- Serialize a graph of objects to JSON and rebuild it exactly
- Make a code base pass
mypy --strictand a 67-test suite
The scenario
The Millbrook Public Library serves a town of 12,000 people. It owns about 4,000 titles, keeps several copies of the popular ones, and has 900 card holders. Loans are still written on index cards, and the head librarian, Rosa, has three problems she wants solved:
- Typos in ISBNs. Volunteers type ISBNs by hand. A single wrong digit puts the wrong book in the catalog. ISBNs carry a check digit designed to catch exactly this kind of mistake, and the software should use it.
- Fair waiting lists. When the new thriller comes in, twenty people want it. Whoever asked first should get it first, and they should be told when it is waiting for them at the desk.
- Fines that are actually charged. Late fines are 25 cents per day, capped at $10 per loan. A member who owes $5 or more cannot borrow until they pay something down.
Members have one of three tiers. Children may borrow 3 items for 14 days, standard members 5 items for 21 days, and premium (donor) members 10 items for 28 days.
Learning goals
Data modeling
@dataclass(frozen=True, slots=True) for Book,
mutable dataclasses for Copy, Member, and Loan, an Enum whose
members carry a loan limit and loan period, and a StrEnum for copy status (M13).
Interfaces
A Clock Protocol and a Notifier Protocol, so the
Library never calls date.today() or sends real email directly (M12, M16).
Errors
A small exception hierarchy rooted at LibraryError, with
raise ... from to keep the cause when translating low-level errors (M07).
Data structures
Dicts keyed by id, a deque for each waiting list, and an
inverted index of word → set of ISBNs for search (M06, M14).
The data model
__str__, __len__, __contains__,
and properties such as Loan.is_open and Member.is_blocked (M10, M11).
Testing and typing
Injectable time makes due-date logic testable; the solution passes
mypy --strict (M16, M17).
Feature specification
The package is library. Its public API is re-exported from library/__init__.py, so tests
can write from library import Library, Book, Member.
| Module | What it provides |
|---|---|
errors.py | LibraryError and its subclasses: InvalidISBNError
(also a ValueError), NotFoundError (also a LookupError),
DuplicateError, NoCopiesAvailableError, LoanLimitError,
OutstandingFinesError, HoldError, PersistenceError. Provided complete. |
isbn.py | normalize_isbn, is_valid_isbn10,
is_valid_isbn13, isbn10_to_isbn13, and validate_isbn, which returns the
canonical ISBN-13 or raises InvalidISBNError. |
clock.py | The Clock Protocol (today() -> date),
SystemClock, and FixedClock(start) with advance(days). |
models.py | MemberTier (CHILD, STANDARD, PREMIUM with
label, loan_limit, loan_days, and from_label),
CopyStatus, Book, Copy, Member, Loan
(is_open, days_overdue, is_overdue, fine_cents), and
due_date. |
notify.py | Notification, the Notifier Protocol
(send(member, subject, body)), and InMemoryNotifier with sent,
for_member, and __len__. |
search.py | tokenize and SearchIndex with add,
remove, search (every query word must match; sorted by title),
__len__, and __contains__. |
library.py | The Library service: catalog (add_book,
add_copies, lookups), members, circulation (checkout, return_copy,
overdue_loans, send_overdue_notices, pay_fine), holds
(place_hold, cancel_hold, holds_for), search, and
from_records for loading. |
storage.py | library_to_dict, library_from_dict,
save_library(library, path), and load_library(path, clock=..., notifier=...). |
Business rules
- Copy ids are
C00001,C00002, …; loan ids areL00001, … checkoutchecks, in order: unpaid fines (≥ 500 cents blocks), the tier's loan limit, then a copy for this member: one reserved for them on the hold shelf first, otherwise any available copy.- The due date is the checkout date plus the tier's loan period. "Today" always comes from the injected clock.
return_copycloses the loan, adds any fine to the member (and notifies them), then shelves the copy: if someone is waiting, the copy goes onto the hold shelf reserved for the first person in line and they get a "Hold ready" notification; otherwise it becomes available.- A hold can be placed only when no copy is available, not twice by the same member, and not by a member who
already has the title on loan. New copies added with
add_copiesserve the waiting list first.
Architecture
The Library is a service object: it owns the records and enforces the rules. Everything it
needs from the outside world (today's date, a way to send messages) comes in through its constructor as an object
that satisfies a Protocol. That is dependency injection: production code passes
SystemClock(); tests pass FixedClock(date(2026, 3, 1)) and an
InMemoryNotifier() they can inspect.
flowchart LR
subgraph models[models.py]
Book
Copy
Member
Loan
MemberTier
end
Library -->|validates with| ISBN[isbn.py]
Library --> models
Library -->|indexes| SearchIndex[search.py SearchIndex]
Library -->|today| Clock[[Clock Protocol]]
Library -->|send| Notifier[[Notifier Protocol]]
Clock -.-> SystemClock
Clock -.-> FixedClock
Notifier -.-> InMemoryNotifier
Storage[storage.py] -->|to_dict / from_records| Library
Storage --> JSON[(library.json)]
Inside the Library, each kind of record lives in a dict keyed by its id, so every lookup is O(1).
Each title with a waiting list has a deque of member ids: append joins the back of the
line and popleft serves the front, both O(1). A plain list would make pop(0) O(n).
The search index is an inverted index: instead of storing "book → words", it stores "word → set of
ISBNs". A query for tolkien hobbit intersects two small sets instead of scanning every title.
Getting started
pixi run projects # list every project and its status
pixi run start p02 # copy the starter into work/p02_library_system/
pixi run grade p02 # run all 67 tests against your work/ copy
pixi run grade p02 -- -k isbn # only tests whose names contain "isbn"
pixi run mypy --strict work/p02_library_system/library
Open work/p02_library_system/library/. Every function has its final signature, a Google-style
docstring, and a TODO comment telling you what to do. Replace each
raise NotImplementedError. errors.py and __init__.py are complete. The
constructors of Library, SearchIndex, FixedClock, and
InMemoryNotifier are given so you can see the internal data structures.
Milestones
Work in order: each milestone's tests only need the earlier ones. Run
pixi run grade p02 -- -k <keyword> after each step.
Milestone 1: ISBNs, records, and the clock
Files: isbn.py, models.py, clock.py. Tests:
test_library_isbn.py, test_library_models.py (run with -- -k "isbn or models").
An ISBN-10 is valid when 10·d1 + 9·d2 + … + 1·d10 is divisible by 11 (the last character may be
X, meaning 10). An ISBN-13 is valid when the digits weighted 1, 3, 1, 3, … sum to a multiple of 10.
Converting ISBN-10 to ISBN-13 means prefixing 978 to the first nine digits and computing a
new check digit.
normalize_isbn,is_valid_isbn10,is_valid_isbn13isbn10_to_isbn13andvalidate_isbnBook.__post_init__normalizes the ISBN on a frozen dataclass and validates title/authorsMemberTier.from_label,Member.is_blocked,Copy.is_availableLoanoverdue and fine math,due_date,FixedClock.advance
Hint: setting a field on a frozen dataclass
Inside __post_init__ of a frozen dataclass, self.isbn = ... raises
FrozenInstanceError. The standard escape hatch is
object.__setattr__(self, "isbn", validate_isbn(self.isbn)), which bypasses the dataclass's
generated __setattr__. Use it only during construction.
Hint: an Enum whose members carry data
When an Enum member's value is a tuple and the class defines __init__, Python calls
__init__ with the tuple unpacked, so MemberTier.CHILD.loan_limit works. The starter
already does this for you; read it.
Milestone 2: catalog, members, loans, and fines
File: library.py. Tests: test_library_loans.py (-- -k loans).
add_book,add_copies(sequential zero-padded copy ids), lookups raisingNotFoundError__len__and__contains__(accept any valid ISBN form; never raise)register_member,loans_for,pay_finecheckoutwith the fine block, loan limit, and copy selection in the right orderreturn_copycharging fines;overdue_loansandsend_overdue_notices
Hint: translating KeyError
try:
return self._members[member_id]
except KeyError:
raise NotFoundError(f"no member {member_id}") from None
from None hides the internal KeyError from the traceback: callers see one clear
domain error.
Hint: testing three weeks in one second
The tests construct FixedClock(date(2026, 3, 1)), check out a book, then call
clock.advance(25) and return it. Because checkout and return_copy ask
self.clock.today(), the loan is four days late without anyone waiting. If you call
date.today() anywhere in library.py, these tests fail.
Milestone 3: holds and notifications
Files: library.py, notify.py. Tests: test_library_holds.py (-- -k holds).
InMemoryNotifier.send,for_member,__len__place_holdwith all three refusal rules, returning the 1-based position- The private
_shelvehelper used by bothreturn_copyandadd_copies _copy_forprefers a copy reserved for the borrowercancel_holdfor a queued hold and for a copy already on the hold shelf
Hint: one place that decides where a copy goes
Three events put a copy "back": a return, a new copy, and a cancelled shelf hold. Write the decision once in
_shelve(copy): if the title's deque is non-empty, popleft() a member, mark the copy
ON_HOLD_SHELF with reserved_for set, and notify them; otherwise mark it
AVAILABLE. Duplicated rules drift apart; a single helper cannot.
Milestone 4: keyword search
File: search.py. Tests: test_library_search.py (-- -k search).
tokenizewith the provided regex oncasefold()ed textadd(re-adding an ISBN replaces its old words) andremove(drop empty sets)searchintersects posting sets and sorts by title, then year
Hint: intersecting many sets
set.intersection(*list_of_sets) intersects any number of sets. Use
self._postings.get(word, set()) so an unknown word contributes an empty set (and therefore an empty
result) without inserting a key into the defaultdict.
Milestone 5: JSON persistence
File: storage.py plus Library.from_records. Tests:
test_library_storage.py (-- -k storage).
library_to_dictwith aversionkey, ISO date strings, enum labels/valuesLibrary.from_recordsrebuilds the dicts, the deques, the search index, and the id counterslibrary_from_dictwrapsKeyError/TypeError/ValueErrorinPersistenceErrorsave_librarywrites atomically (temporary file, thenreplace);load_librarytranslates file and JSON errors
Hint: why a format version and an atomic write?
A "version": 1 key lets a future release recognize and migrate old files instead of
misreading them. Writing to library.json.tmp and then calling Path.replace means a
crash mid-write leaves the old file intact: replace is atomic on the same filesystem.
Rubric
| Criterion | Weight | What "excellent" looks like |
|---|---|---|
| Correctness | 50% | pixi run grade p02 passes all 67 tests. |
| Typing | 15% | pixi run mypy --strict work/p02_library_system/library reports no errors; no Any outside JSON boundaries. |
| Design | 15% | Rules live in one place (_shelve, checkout); the library never calls date.today() or prints; internal collections are not leaked (properties return copies). |
| Errors | 10% | Every failure raises a specific LibraryError subclass with a helpful message; low-level errors are translated with from. |
| Style | 10% | pixi run ruff check and pixi run ruff format --check are clean; docstrings are accurate. |
Stretch goals
- CLI. A
main()withargparsesubcommands (add,lend,return,search,overdue) that loads and saveslibrary.json; register it as a Pixi task. - Outbox notifier. An
OutboxNotifierthat writes each message to a file. Because it satisfies theNotifierProtocol, theLibraryneeds no changes. - Ranked search. Score by the number of matching words and allow prefix matches
(
tolkfinds Tolkien). A trie (M27) makes prefix lookup fast. - Renewals.
renew(loan_id)extends a loan once, unless someone is waiting. - Property-based tests. With Hypothesis (
pixi add hypothesis), generate random nine-digit bodies, compute the ISBN-10 check digit, and assert the ISBN-13 conversion always validates.