LEARN PYTHON | Project: A Library Lending System

Lesson objective

Build an encapsulated library service using frozen dataclass records, clear lending rules, type hints and repeatable tests.

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.

PartResponsibilityImportant boundary
Book, Member, LoanChecked immutable-value recordsValid field types and values
LibraryCatalogue and active-loan rulesCheck all conditions before mutation
app.pyPrompts and feedbackConvert text to proper values
test_library.pyIndependent expectationsFresh 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

    MATCH THE PYTHON TERM TO ITS MEANING

    Terminology

    Terminology

    Physical copy

    One lendable object identified independently of its title.

    Copy ID

    The unique identifier for a registered book copy.

    Member

    A registered borrower record.

    Loan

    A relationship recording copy, member and borrowing dates.

    Active loan

    A loan currently held in the service’s active collection.

    Library service

    The object coordinating catalogue and lending rules.

    Composition

    Holding collaborating records rather than inheriting from them.

    Encapsulation

    Keeping state changes behind supported checked methods.

    Frozen dataclass

    A record blocking ordinary later field assignment.

    Snapshot

    A captured outer view of records at one time.

    Loan limit

    The maximum number of simultaneous active loans for a member.

    Due date

    The agreed calendar day by which a copy is due.

    Overdue

    Active after its due date, using this project’s strict comparison.

    Generator expression

    An expression producing values incrementally for an operation such as sum.

    Lambda

    A short anonymous function expression, here a sorting key.

    State transition

    An accepted change such as creating or removing an active loan.

    Invariant

    A rule that must hold in every accepted service state.

    Session-only data

    Records retained only during the current process run.

    Questions

    Questions

    CHECK YOUR UNDERSTANDING

    Select all correct choices. Each exact set earns one point.

    1. What identifies one lendable physical copy?
    2. What must be checked before a loan is stored?
    3. Borrowed 2026-10-01 for fourteen days. What is due_on?
    4. Which check date is overdue for due_on 2026-10-15?
    5. What does an accepted return do?
    6. Why use frozen records and tuple snapshots?
    7. What happens after restart?
    8. Why pass dates into service methods?

    EXPLAIN AND COMPARE

    Write your answers before revealing each sample.

    1. Explain the record and service responsibilities.

    2. Explain physical-copy identity.

    3. Trace a successful loan.

    4. Explain due versus overdue.

    5. Describe a successful return.

    6. Explain snapshots and record protection.

    7. Evaluate session-only scope.

    8. Design an extension test plan.

    CODE IT | YOUR INDEPENDENT CHALLENGE

    Build and test the lending service. Check duplicate IDs, limits, repeated loans, returns and overdue boundaries.

    Work in your Python editor. Keep a working version, explain one design choice and record at least two tests. Use the Learn tab for worked examples before comparing your result.

    Flashcards

    Flashcards

    Click to flip. Select the ideas you need to revisit.

    0 cards selected for revision.

      Selections are kept while this page is open.

      Workbook

      Workbook

      COMING SOON

      The workbook for Project: A Library Lending System is coming soon. Complete the matching task and written questions in your exercise book.