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.

⏱ ~8–12 hoursPrerequisites: M10–M1867 grading tests

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:

  1. 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.
  2. 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.
  3. 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.

ModuleWhat it provides
errors.pyLibraryError and its subclasses: InvalidISBNError (also a ValueError), NotFoundError (also a LookupError), DuplicateError, NoCopiesAvailableError, LoanLimitError, OutstandingFinesError, HoldError, PersistenceError. Provided complete.
isbn.pynormalize_isbn, is_valid_isbn10, is_valid_isbn13, isbn10_to_isbn13, and validate_isbn, which returns the canonical ISBN-13 or raises InvalidISBNError.
clock.pyThe Clock Protocol (today() -> date), SystemClock, and FixedClock(start) with advance(days).
models.pyMemberTier (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.pyNotification, the Notifier Protocol (send(member, subject, body)), and InMemoryNotifier with sent, for_member, and __len__.
search.pytokenize and SearchIndex with add, remove, search (every query word must match; sorted by title), __len__, and __contains__.
library.pyThe 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.pylibrary_to_dict, library_from_dict, save_library(library, path), and load_library(path, clock=..., notifier=...).

Business rules

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_isbn13
  • isbn10_to_isbn13 and validate_isbn
  • Book.__post_init__ normalizes the ISBN on a frozen dataclass and validates title/authors
  • MemberTier.from_label, Member.is_blocked, Copy.is_available
  • Loan overdue 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 raising NotFoundError
  • __len__ and __contains__ (accept any valid ISBN form; never raise)
  • register_member, loans_for, pay_fine
  • checkout with the fine block, loan limit, and copy selection in the right order
  • return_copy charging fines; overdue_loans and send_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_hold with all three refusal rules, returning the 1-based position
  • The private _shelve helper used by both return_copy and add_copies
  • _copy_for prefers a copy reserved for the borrower
  • cancel_hold for 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).

  • tokenize with the provided regex on casefold()ed text
  • add (re-adding an ISBN replaces its old words) and remove (drop empty sets)
  • search intersects 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_dict with a version key, ISO date strings, enum labels/values
  • Library.from_records rebuilds the dicts, the deques, the search index, and the id counters
  • library_from_dict wraps KeyError/TypeError/ValueError in PersistenceError
  • save_library writes atomically (temporary file, then replace); load_library translates 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

CriterionWeightWhat "excellent" looks like
Correctness50%pixi run grade p02 passes all 67 tests.
Typing15%pixi run mypy --strict work/p02_library_system/library reports no errors; no Any outside JSON boundaries.
Design15%Rules live in one place (_shelve, checkout); the library never calls date.today() or prints; internal collections are not leaked (properties return copies).
Errors10%Every failure raises a specific LibraryError subclass with a helpful message; low-level errors are translated with from.
Style10%pixi run ruff check and pixi run ruff format --check are clean; docstrings are accurate.

Stretch goals

Reflection quiz