Learn
06.6 | PROJECT: A LIBRARY LENDING SYSTEM
01 | DEFINE WHAT THE LIBRARY MUST DO
Download the library project and tests
The demo library can register book copies and members, lend an available copy, accept a return and report active or overdue loans. It starts with three copies and two fictional members. Two copies deliberately share the same title so students can see why a physical copy needs its own ID.
A member may have two active loans in the demo, and each loan lasts fourteen calendar days. The due day itself is not overdue; the next day is. The service supports configurable positive limits. A rejected operation must leave accepted state unchanged.
This project is session-only. Restarting restores the seeded demo, so previous loans are not saved. Persistent restoration is a later extension using Section 5’s file and validation ideas. Keeping that limit explicit lets this project focus on object responsibilities.
02 | MAP THE OBJECTS AND THEIR OWNERS
Book describes one physical copy. Member describes one registered borrower. Loan records a relationship between their IDs with borrowing and due dates. Library owns dictionaries of these records and the rules deciding whether operations may change them.
Library has books; it is not a subclass of Book. This is a composition design. The records are frozen dataclasses whose fields are strings and date values. The service is mutable because its collection of active loans changes.
The four Python files are entities.py, lending.py, app.py and test_library.py. The command-line application imports the service; the service does not ask for input or secretly read the application’s current date.
| Part | Responsibility | Important boundary |
|---|---|---|
| Book, Member, Loan | Checked immutable-value records | Valid field types and values |
| Library | Catalogue and active-loan rules | Check all conditions before mutation |
| app.py | Prompts and feedback | Convert text to proper values |
| test_library.py | Independent expectations | Fresh library fixture per test |
03 | CREATE VALID RECORD OBJECTS
IDs use B followed by three ASCII digits for a copy and M followed by three digits for a member. Titles, authors and names must be trimmed non-blank text of at most eighty characters. These small demo rules are explicit rather than inferred from a field name.
__post_init__ validates after the generated initializer assigns fields. check_day deliberately requires an exact date object, excluding both text and datetime values. Annotations document expectations; these checks enforce actual values.
Frozen records discourage ordinary callers from changing a registered ID behind the service’s dictionary key. They do not enforce uniqueness across separate records; Library checks that when adding them.
"""Small immutable records used by the lending service."""
from dataclasses import dataclass
from datetime import date
import re
def check_id(value: str, prefix: str) -> None:
if type(value) is not str or not re.fullmatch(prefix + r"[0-9]{3}", value):
raise ValueError(f"Use {prefix} followed by three digits.")
def check_text(value: str, label: str) -> None:
if type(value) is not str or not 1 <= len(value.strip()) <= 80:
raise ValueError(f"{label} must contain 1 to 80 characters after trimming.")
if value != value.strip():
raise ValueError(f"Trim outer spaces from {label} first.")
def check_day(value: date) -> None:
if type(value) is not date:
raise ValueError("Supply a calendar date, not text or a datetime.")
@dataclass(frozen=True)
class Book:
"""One physical copy, identified independently of its title."""
copy_id: str
title: str
author: str
def __post_init__(self) -> None:
check_id(self.copy_id, "B")
check_text(self.title, "Title")
check_text(self.author, "Author")
@dataclass(frozen=True)
class Member:
member_id: str
name: str
def __post_init__(self) -> None:
check_id(self.member_id, "M")
check_text(self.name, "Name")
@dataclass(frozen=True)
class Loan:
copy_id: str
member_id: str
borrowed_on: date
due_on: date
def __post_init__(self) -> None:
check_id(self.copy_id, "B")
check_id(self.member_id, "M")
check_day(self.borrowed_on)
check_day(self.due_on)
if self.due_on < self.borrowed_on:
raise ValueError("Due date cannot precede borrowing.")
04 | OWN STATE WITHOUT EXPOSING THE DICTIONARIES
Library.__init__ creates fresh dictionaries for each instance. Putting them as mutable class attributes would risk sharing one catalogue or loan collection across separate libraries. Positive policy checks reject zero, negatives and Booleans.
books(), members() and active_loans() return tuple snapshots. Those new outer views do not let an ordinary caller add directly to the internal dictionary. The frozen records contain immutable field values, so their ordinary fields cannot be altered through that view either.
The underscore remains a convention, not a security barrier. A caller deliberately reaching into _loans can bypass the supported design. The application should use the documented methods.
"""Encapsulated lending rules; no prompts or hidden current-date dependency."""
from datetime import date, timedelta
from entities import Book, Member, Loan, check_day
class Library:
def __init__(self, max_loans: int = 3, loan_days: int = 14) -> None:
if type(max_loans) is not int or max_loans < 1:
raise ValueError("max_loans must be a positive integer.")
if type(loan_days) is not int or loan_days < 1:
raise ValueError("loan_days must be a positive integer.")
self._max_loans = max_loans
self._loan_days = loan_days
self._books: dict[str, Book] = {}
self._members: dict[str, Member] = {}
self._loans: dict[str, Loan] = {}
def add_book(self, book: Book) -> None:
if not isinstance(book, Book):
raise ValueError("Supply a Book object.")
if book.copy_id in self._books:
raise ValueError("Copy ID already exists.")
self._books[book.copy_id] = book
def add_member(self, member: Member) -> None:
if not isinstance(member, Member):
raise ValueError("Supply a Member object.")
if member.member_id in self._members:
raise ValueError("Member ID already exists.")
self._members[member.member_id] = member
def books(self) -> tuple[Book, ...]:
"""Return an immutable snapshot of catalogue references."""
return tuple(self._books.values())
def members(self) -> tuple[Member, ...]:
return tuple(self._members.values())
def active_loans(self) -> tuple[Loan, ...]:
return tuple(self._loans.values())
05 | LEND ONLY AFTER ALL CHECKS
lend receives a copy ID, member ID and explicit borrowing date. It validates that date, checks both IDs, rejects a copy already on loan and counts that member’s active loans against the limit. Only then does it create and store a Loan.
The count uses a generator expression: it yields True or False for each active loan, and sum counts the True matches. This counts active records, not all historical borrowing. The due date uses timedelta, so month boundaries work naturally.
If due-date arithmetic overflows the supported calendar range, it raises before the dictionary assignment. The command-line boundary reports that failure. The service does not leave a partially accepted loan.
"""Encapsulated lending rules; no prompts or hidden current-date dependency."""
from datetime import date, timedelta
from entities import Book, Member, Loan, check_day
class Library:
def __init__(self, max_loans: int = 3, loan_days: int = 14) -> None:
if type(max_loans) is not int or max_loans < 1:
raise ValueError("max_loans must be a positive integer.")
if type(loan_days) is not int or loan_days < 1:
raise ValueError("loan_days must be a positive integer.")
self._max_loans = max_loans
self._loan_days = loan_days
self._books: dict[str, Book] = {}
self._members: dict[str, Member] = {}
self._loans: dict[str, Loan] = {}
def add_book(self, book: Book) -> None:
if not isinstance(book, Book):
raise ValueError("Supply a Book object.")
if book.copy_id in self._books:
raise ValueError("Copy ID already exists.")
self._books[book.copy_id] = book
def add_member(self, member: Member) -> None:
if not isinstance(member, Member):
raise ValueError("Supply a Member object.")
if member.member_id in self._members:
raise ValueError("Member ID already exists.")
self._members[member.member_id] = member
def books(self) -> tuple[Book, ...]:
"""Return an immutable snapshot of catalogue references."""
return tuple(self._books.values())
def members(self) -> tuple[Member, ...]:
return tuple(self._members.values())
def active_loans(self) -> tuple[Loan, ...]:
return tuple(self._loans.values())
def _require_book(self, copy_id: str) -> None:
if type(copy_id) is not str or copy_id not in self._books:
raise ValueError("Unknown copy ID.")
def available(self, copy_id: str) -> bool:
self._require_book(copy_id)
return copy_id not in self._loans
def lend(self, copy_id: str, member_id: str, on_day: date) -> Loan:
"""Create an active loan after all checks; failures preserve state."""
check_day(on_day)
self._require_book(copy_id)
if type(member_id) is not str or member_id not in self._members:
raise ValueError("Unknown member ID.")
if copy_id in self._loans:
raise ValueError("That copy is already on loan.")
count = sum(loan.member_id == member_id for loan in self._loans.values())
if count >= self._max_loans:
raise ValueError("Member has reached the active-loan limit.")
due_on = on_day + timedelta(days=self._loan_days)
loan = Loan(copy_id, member_id, on_day, due_on)
self._loans[copy_id] = loan
return loan
def return_book(self, copy_id: str, on_day: date) -> Loan:
"""Remove and return the active Loan, rejecting an earlier return date."""
check_day(on_day)
self._require_book(copy_id)
if copy_id not in self._loans:
raise ValueError("That copy is not on loan.")
loan = self._loans[copy_id]
if on_day < loan.borrowed_on:
raise ValueError("Return date cannot precede borrowing.")
del self._loans[copy_id]
return loan
def overdue(self, on_day: date) -> tuple[Loan, ...]:
"""Due day itself is not overdue; report active loans only."""
check_day(on_day)
loans = [loan for loan in self._loans.values() if on_day > loan.due_on]
return tuple(sorted(loans, key=lambda loan: (loan.due_on, loan.copy_id)))
06 | RETURN AND REPORT OVERDUE LOANS
return_book rejects an unloaned copy and a return date before its borrowing date. It removes the active Loan and returns that record. The copy is then available and the member has capacity for another loan.
overdue compares the supplied check date with due_on using a strict greater-than condition. It reports active loans only. Returned loans are no longer retained as history in this small model.
The sorting key uses lambda, a short anonymous function, to return (due_on, copy_id). It is equivalent in purpose to a named key helper: order by due date and then copy ID. The snapshot provides stable report ordering without changing the service’s dictionaries.
07 | KEEP PROMPTS IN THE APPLICATION
The application converts text dates using strict format and calendar checks, then calls service methods. It normalises IDs to uppercase and trims names before construction. The service itself still checks its boundary because another caller could bypass these prompts.
Commands are catalogue, members, book, member, lend, return, loans, overdue and quit. The app reports expected ValueError and calendar OverflowError failures rather than mutating service state to hide them.
Every lend and return has an explicit date. That keeps testing reproducible. A user can deliberately supply a future date, and the demo does not enforce a global chronological event log or factual truth. Such policies would be additional requirements.
"""Session-only console library. Run this file, not the helper modules."""
from datetime import date
import re
from entities import Book, Member
from lending import Library
def parse_day(text: str) -> date:
text = text.strip()
if not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", text):
raise ValueError("Use YYYY-MM-DD.")
return date.fromisoformat(text)
def demo_library() -> Library:
library = Library(max_loans=2, loan_days=14)
library.add_book(Book("B001", "Python Adventures", "A. Coder"))
library.add_book(Book("B002", "Python Adventures", "A. Coder"))
library.add_book(Book("B003", "Robots in Action", "R. Maker"))
library.add_member(Member("M001", "Maya"))
library.add_member(Member("M002", "Noor"))
return library
def run_library(library: Library) -> None:
print("Library demo | data lasts only for this run")
print("IDs: B plus three digits for copies; M plus three digits for members.")
while True:
command = input("catalogue / members / book / member / lend / return / loans / overdue / quit: ").strip().lower()
if command == "quit":
print("Finished. Session records are not saved to a file.")
return
try:
if command == "catalogue":
for book in library.books():
status = "available" if library.available(book.copy_id) else "on loan"
print(f"{book.copy_id} | {book.title} | {book.author} | {status}")
elif command == "members":
for member in library.members():
print(member.member_id, member.name)
elif command == "book":
copy_id = input("Copy ID: ").strip().upper()
title = input("Title: ").strip()
author = input("Author: ").strip()
library.add_book(Book(copy_id, title, author))
print("Book copy added.")
elif command == "member":
member_id = input("Member ID: ").strip().upper()
name = input("Fictional member name: ").strip()
library.add_member(Member(member_id, name))
print("Member added.")
elif command == "lend":
copy_id = input("Copy ID: ").strip().upper()
member_id = input("Member ID: ").strip().upper()
day = parse_day(input("Borrow date YYYY-MM-DD: "))
loan = library.lend(copy_id, member_id, day)
print("Lent. Due:", loan.due_on.isoformat())
elif command == "return":
copy_id = input("Copy ID: ").strip().upper()
day = parse_day(input("Return date YYYY-MM-DD: "))
library.return_book(copy_id, day)
print("Copy returned.")
elif command == "loans":
loans = library.active_loans()
if not loans:
print("No active loans.")
for loan in loans:
print(loan.copy_id, loan.member_id, "due", loan.due_on.isoformat())
elif command == "overdue":
day = parse_day(input("Check date YYYY-MM-DD: "))
loans = library.overdue(day)
if not loans:
print("No overdue active loans.")
for loan in loans:
print(loan.copy_id, loan.member_id, "due", loan.due_on.isoformat())
else:
print("Unknown command. Choose a displayed word.")
except (ValueError, OverflowError) as error:
print("Not completed:", error)
def main() -> None:
try:
run_library(demo_library())
except (EOFError, KeyboardInterrupt):
print("\nStopped. Session records were not saved.")
if __name__ == "__main__":
main()
08 | TRY A FIXED LENDING MODEL
The browser model uses the same seeded copy and member IDs, a two-loan limit and a fourteen-day period. All its data is temporary. Lend, return or report overdue for a real calendar date, then inspect the active loan list. Reset restores a fresh session.
09 | TEST INVARIANTS AND BOUNDARIES
Run python -m unittest discover -v in the project folder. The sixteen test methods use a fresh Library before each test and fixed dates. They check duplicate IDs, copy independence, loan limits, double lending, return rules, overdue boundaries and ordinary frozen-field protection.
A success check should inspect the returned Loan, its due date and changed availability. A rejection check should compare active_loans before and after. A snapshot test confirms that adding a later book does not grow an earlier tuple.
The tests also reject text and datetime where date is required, and test calendar overflow before loan acceptance. That shows runtime validation matters even when method parameters have annotations.
10 | RUN A COMPLETE USER WORKFLOW
Start app.py with your editor or python app.py. List the catalogue and find B001 and B002 with the same title. Lend B001 to M001 on 2026-10-01; its due date is 2026-10-15. B002 should remain available.
Attempt to lend B001 again to M002. Then check overdue on 2026-10-15 and 2026-10-16. The first report is empty; the second includes B001. Return it on the sixteenth and verify that it is available again.
Quit and restart to confirm the documented session-only behaviour. Do not claim that loan records were saved simply because the previous session displayed them. Use fictional names rather than actual student borrowing data.
11 | EXTEND THE MODEL DELIBERATELY
A loan-history feature needs a place for returned records and an agreed return-date field. A renewal feature needs a policy for how due dates change and whether reserved copies can be renewed. Add those requirements and tests before changing the service.
For persistence, serialize IDs and ISO dates into one defined schema, validate restored records and reconstruct the Library without bypassing its consistency rules. Do not merely JSON-dump the internal object dictionary and assume every reference restores correctly.
Inheritance is not needed to earn an object-oriented design. This project uses dataclass records, composition, encapsulated service methods, annotations and tests because they match its responsibilities. A simple clear design is a strong outcome.
PUT YOUR SKILLS TO WORK
Use the Section 6 challenges to practise these ideas independently. Work in your Python editor, predict results and keep a short test record.
Section 6 challenges Python course navigation