Skip to main content

Project 02 - Library Management System

Estimated time: 4–6 hours core | Level: Intermediate → Engineering

Before you start, answer this question: if two Book objects have the same ISBN, are they the same book? If yes, what does Python need from your class to treat them as equal - and to allow them to live in a set or act as dictionary keys?

That question defines the entire design of this project.

Learning Objectives

By the time this project is complete, you will have practiced:

  • Implementing __eq__ and __hash__ correctly and understanding why they must be consistent
  • Using ABC and @abstractmethod to define interfaces that concrete classes must fulfil
  • Designing a composition relationship (a Library contains Book and Member objects)
  • Using datetime.date for date tracking and timedelta arithmetic for due date and overdue calculations
  • Making classes that behave correctly in sets and as dictionary keys
  • Implementing search with filtering logic inside domain classes, not in procedural code
  • Writing __repr__ that is actually useful at the Python REPL

System Overview

You are building the back-end for a public library. The system tracks items (books, DVDs), members, checkouts, returns, and overdue fines. It must be possible to store books in a Python set (for catalogue deduplication) and use them as dictionary keys (for checkout tracking). These constraints are non-negotiable - they force correct __eq__ and __hash__ implementation.

Requirements

R1 - LibraryItem Abstract Base Class

  • Inherits from ABC (from abc module).
  • Abstract properties: title (str), item_id (str).
  • Abstract method: get_display_info() -> str - returns a formatted string describing the item for display in search results.
  • Concrete method: __repr__ - implemented here, calls get_display_info() so all subclasses get a consistent repr.
  • Any class that inherits from LibraryItem and does not implement all abstract members must raise TypeError when instantiated.

R2 - Book class

  • Inherits from LibraryItem.
  • Attributes: isbn (str), title (str), author (str), year (int), genre (str, optional).
  • item_id property: returns the ISBN.
  • get_display_info(): returns "[ISBN: 978-0-06-112008-4] To Kill a Mockingbird - Harper Lee (1960)".
  • __eq__: two Book objects are equal if and only if their isbn values are equal. Title, author, and year are irrelevant for equality.
  • __hash__: must be consistent with __eq__. If two books are equal (same ISBN), they must hash to the same value. This is required for set membership and dict key usage.
  • __repr__: delegates to get_display_info() (inherited from LibraryItem).

R3 - DVD class

  • Inherits from LibraryItem.
  • Attributes: dvd_id (str), title (str), director (str), year (int), runtime_minutes (int).
  • item_id property: returns dvd_id.
  • get_display_info(): returns "[DVD: D001] Inception - Christopher Nolan (2010, 148 min)".
  • __eq__ and __hash__: equality and hashing based on dvd_id.

R4 - Member class

  • Attributes: member_id (str), name (str), email (str), join_date (date, auto-set to today on creation).
  • checked_out: a dict mapping LibraryItem to due_date (a date object). Items are keys - this only works if LibraryItem subclasses implement __hash__.
  • checkout_item(item, due_date): adds the item to checked_out. Raises ValueError if the item is already checked out by this member.
  • return_item(item): removes the item from checked_out. Raises ValueError if the item is not currently checked out by this member.
  • get_overdue_items(as_of_date=None): returns a list of (item, due_date) tuples where the due_date is before as_of_date (defaults to today).
  • __repr__: "Member(M001, Alice Chen, 3 items checked out)".

R5 - Library class

  • Attributes: name (str), catalogue (a set of LibraryItem - this is why hash must work), members (dict mapping member_id to Member).
  • add_item(item): adds item to catalogue. If the same item (by __eq__) already exists, do not add a duplicate.
  • remove_item(item): removes item from catalogue. Raises ValueError if not found.
  • register_member(member): adds member to members dict.
  • checkout(member_id, item_id, loan_days=14): finds the member and item, calls member.checkout_item(item, due_date) where due_date = today + timedelta(days=loan_days). Raises ValueError if member or item is not found. Raises ItemUnavailableError if the item is already checked out by someone else.
  • return_item(member_id, item_id): processes a return. Calls member.return_item(item). Calculates fine if overdue.
  • calculate_fine(due_date, return_date, daily_rate=0.50): static method. Returns the fine amount. fine = max(0.0, (return_date - due_date).days * daily_rate). No fine if returned on time.
  • search_by_title(query): returns a list of LibraryItem whose title contains query (case-insensitive).
  • search_by_author(query): returns a list of Book whose author contains query (case-insensitive).
  • search_by_item_id(item_id): returns the LibraryItem with that item_id, or None.
  • get_all_overdue(as_of_date=None): returns a list of (member, item, due_date) tuples for all overdue checkouts across all members.
  • __repr__: "Library('City Library', 42 items, 15 members)".

R6 - ItemUnavailableError

A custom exception. __str__ should produce: "ItemUnavailableError: 'To Kill a Mockingbird' is currently checked out".

R7 - Demonstration block

Your __main__ block must produce the expected output shown below.

Starter Code Skeleton

from abc import ABC, abstractmethod
from datetime import date, timedelta
from typing import Optional


# ── Custom Exceptions ─────────────────────────────────────────────────────────

class ItemUnavailableError(Exception):
def __init__(self, title: str):
self.title = title

def __str__(self):
# TODO: return "ItemUnavailableError: 'To Kill a Mockingbird' is currently checked out"
pass


# ── LibraryItem ABC ───────────────────────────────────────────────────────────

class LibraryItem(ABC):

@property
@abstractmethod
def title(self) -> str:
pass

@property
@abstractmethod
def item_id(self) -> str:
pass

@abstractmethod
def get_display_info(self) -> str:
pass

def __repr__(self):
# TODO: delegate to get_display_info()
pass


# ── Book ──────────────────────────────────────────────────────────────────────

class Book(LibraryItem):
def __init__(self, isbn: str, title: str, author: str, year: int, genre: str = ""):
# TODO: store all attributes
# Note: _title and _isbn stored as private, exposed via properties
pass

@property
def title(self) -> str:
# TODO
pass

@property
def item_id(self) -> str:
# TODO: return isbn
pass

def get_display_info(self) -> str:
# TODO: return "[ISBN: 978-...] Title - Author (Year)"
pass

def __eq__(self, other) -> bool:
# TODO: equal if same type AND same isbn
# IMPORTANT: check isinstance(other, Book) first
pass

def __hash__(self) -> int:
# TODO: hash based on isbn only
# Must be consistent with __eq__
pass


# ── DVD ───────────────────────────────────────────────────────────────────────

class DVD(LibraryItem):
def __init__(self, dvd_id: str, title: str, director: str, year: int, runtime_minutes: int):
# TODO: store all attributes
pass

@property
def title(self) -> str:
# TODO
pass

@property
def item_id(self) -> str:
# TODO: return dvd_id
pass

def get_display_info(self) -> str:
# TODO: return "[DVD: D001] Title - Director (Year, runtime min)"
pass

def __eq__(self, other) -> bool:
# TODO: equal if same dvd_id
pass

def __hash__(self) -> int:
# TODO: hash based on dvd_id
pass


# ── Member ────────────────────────────────────────────────────────────────────

class Member:
def __init__(self, member_id: str, name: str, email: str):
# TODO: store member_id, name, email
# TODO: set join_date = date.today()
# TODO: initialise checked_out as empty dict
pass

def checkout_item(self, item: LibraryItem, due_date: date) -> None:
# TODO: raise ValueError if item already in checked_out
# TODO: add item -> due_date to checked_out
pass

def return_item(self, item: LibraryItem) -> date:
# TODO: raise ValueError if item not in checked_out
# TODO: remove item from checked_out, return the due_date
pass

def get_overdue_items(self, as_of_date: Optional[date] = None) -> list:
# TODO: default as_of_date to date.today()
# TODO: return list of (item, due_date) where due_date < as_of_date
pass

def __repr__(self):
# TODO: "Member(M001, Alice Chen, 3 items checked out)"
pass


# ── Library ───────────────────────────────────────────────────────────────────

class Library:
def __init__(self, name: str):
# TODO: store name
# TODO: initialise catalogue as empty set
# TODO: initialise members as empty dict
# TODO: initialise _checkouts as dict mapping item_id -> member_id (for availability tracking)
pass

def add_item(self, item: LibraryItem) -> None:
# TODO: add to catalogue set (set handles deduplication via __hash__ and __eq__)
pass

def remove_item(self, item: LibraryItem) -> None:
# TODO: raise ValueError if not in catalogue
# TODO: remove from catalogue
pass

def register_member(self, member: Member) -> None:
# TODO: add to members dict keyed by member_id
pass

def checkout(self, member_id: str, item_id: str, loan_days: int = 14) -> date:
# TODO: find member (ValueError if not found)
# TODO: find item by item_id in catalogue (ValueError if not found)
# TODO: check if item is already checked out (ItemUnavailableError if so)
# TODO: calculate due_date = date.today() + timedelta(days=loan_days)
# TODO: call member.checkout_item(item, due_date)
# TODO: record item as checked out in _checkouts
# TODO: return due_date
pass

def return_item(self, member_id: str, item_id: str) -> float:
# TODO: find member and item
# TODO: call member.return_item(item) to get the due_date
# TODO: calculate fine using calculate_fine(due_date, date.today())
# TODO: remove from _checkouts
# TODO: return fine amount
pass

@staticmethod
def calculate_fine(due_date: date, return_date: date, daily_rate: float = 0.50) -> float:
# TODO: fine = max(0.0, (return_date - due_date).days * daily_rate)
pass

def search_by_title(self, query: str) -> list:
# TODO: case-insensitive search across all items in catalogue
pass

def search_by_author(self, query: str) -> list:
# TODO: filter to Book instances only, match on author
pass

def search_by_item_id(self, item_id: str) -> Optional[LibraryItem]:
# TODO: find item in catalogue where item.item_id == item_id
pass

def get_all_overdue(self, as_of_date: Optional[date] = None) -> list:
# TODO: collect (member, item, due_date) tuples for all overdue items
pass

def __repr__(self):
# TODO: "Library('City Library', 42 items, 15 members)"
pass


# ── Main Demonstration ────────────────────────────────────────────────────────

if __name__ == "__main__":
# Build the library
lib = Library("City Central Library")

# Add books
book1 = Book("978-0-06-112008-4", "To Kill a Mockingbird", "Harper Lee", 1960, "Fiction")
book2 = Book("978-0-7432-7356-5", "The Great Gatsby", "F. Scott Fitzgerald", 1925, "Fiction")
book3 = Book("978-0-14-028329-7", "Of Mice and Men", "John Steinbeck", 1937, "Fiction")
dvd1 = DVD("D001", "Inception", "Christopher Nolan", 2010, 148)

lib.add_item(book1)
lib.add_item(book2)
lib.add_item(book3)
lib.add_item(dvd1)

# Duplicate add - should be silently ignored
lib.add_item(Book("978-0-06-112008-4", "To Kill a Mockingbird", "Harper Lee", 1960))
print(f"Catalogue size after duplicate add: {len(lib.catalogue)}") # must be 4

# Register members
alice = Member("M001", "Alice Chen", "[email protected]")
bob = Member("M002", "Bob Singh", "[email protected]")
lib.register_member(alice)
lib.register_member(bob)

# Checkouts
due1 = lib.checkout("M001", "978-0-06-112008-4", loan_days=14)
due2 = lib.checkout("M002", "978-0-7432-7356-5", loan_days=7)
lib.checkout("M001", "D001", loan_days=3)

print(alice)
print(bob)

# Try to checkout an already-checked-out item
try:
lib.checkout("M002", "978-0-06-112008-4")
except ItemUnavailableError as e:
print(e)

# Search
print("\n--- Search by author: 'Steinbeck' ---")
for item in lib.search_by_author("Steinbeck"):
print(item)

print("\n--- Search by title: 'the' ---")
for item in lib.search_by_title("the"):
print(item)

# Return with simulated overdue (14 days late)
overdue_return_date = date.today() + timedelta(days=14)
fine = Library.calculate_fine(due2, overdue_return_date)
print(f"\nFine for returning 14 days late: ${fine:.2f}")

# Demonstrate set behaviour
print("\n--- Set membership test ---")
same_book = Book("978-0-06-112008-4", "To Kill a Mockingbird", "Harper Lee", 1960)
catalogue_copy = set(lib.catalogue)
print(f"Same ISBN in catalogue set: {same_book in catalogue_copy}") # True
print(f"book1 == same_book: {book1 == same_book}") # True
print(f"book1 is same_book: {book1 is same_book}") # False

print(f"\n{lib}")

Expected Output

Catalogue size after duplicate add: 4
Member(M001, Alice Chen, 2 items checked out)
Member(M002, Bob Singh, 1 items checked out)
ItemUnavailableError: 'To Kill a Mockingbird' is currently checked out

--- Search by author: 'Steinbeck' ---
[ISBN: 978-0-14-028329-7] Of Mice and Men \text{---} John Steinbeck (1937)

--- Search by title: 'the' ---
[ISBN: 978-0-7432-7356-5] The Great Gatsby \text{---} F. Scott Fitzgerald (1925)

Fine for returning 14 days late: $7.00

--- Set membership test ---
Same ISBN in catalogue set: True
book1 == same_book: True
book1 is same_book: False

Library('City Central Library', 4 items, 2 members)

Step-by-Step Hints

Hint 1 - Why __hash__ must be consistent with __eq__. Python's contract: if a == b, then hash(a) == hash(b). If you implement __eq__ but not __hash__, Python sets __hash__ = None and your object becomes unhashable - it cannot go in a set or be a dict key. Implement both, based on the same field (isbn for Book, dvd_id for DVD).

def __hash__(self):
return hash(self._isbn)

Hint 2 - The @property @abstractmethod combination requires both decorators. The order matters. @property must come first (outermost), @abstractmethod second (innermost):

@property
@abstractmethod
def title(self) -> str:
pass

If you reverse the order, the property does not behave correctly in subclasses.

Hint 3 - catalogue is a set of LibraryItem objects. This means add_item is literally self.catalogue.add(item). The set deduplication is handled automatically by __eq__ and __hash__. You do not need any manual duplicate-checking code. The demo block proves this: adding a Book with the same ISBN should leave the set at size 4.

Hint 4 - Member.checked_out uses items as dict keys. self.checked_out[item] = due_date. This works only because Book and DVD implement __hash__. When you call member.return_item(item), you call due_date = self.checked_out.pop(item) - which also relies on __eq__ to find the right key even if you pass a different object with the same ISBN.

Hint 5 - Library._checkouts tracks availability separately from Member.checked_out. Member.checked_out answers: "what does this member have?" Library._checkouts answers: "is this item currently out with anyone?" A simple approach: self._checkouts: dict[str, str] mapping item_id -> member_id. When you need to check availability, look up item.item_id in _checkouts.

Hint 6 - Library.search_by_item_id must search the catalogue set. You cannot look up by index in a set. Iterate: next((item for item in self.catalogue if item.item_id == item_id), None).

Hint 7 - LibraryItem.__repr__ delegates to get_display_info(). This is the template method pattern: the base class defines when __repr__ is called, but the subclass defines what it returns. You implement __repr__ once in LibraryItem, not in every subclass.

Hint 8 - Try to instantiate LibraryItem directly to verify the ABC works.

try:
item = LibraryItem() # should raise TypeError
except TypeError as e:
print(e) # Can't instantiate abstract class LibraryItem...

If this does not raise, your abstract methods are not declared correctly.

OOP Concepts Tested

ConceptWhere it appears
Abstract Base ClassesLibraryItem(ABC) with @abstractmethod
__eq__Book, DVD - equality by identifier
__hash__Book, DVD - required for set membership
__repr__All classes, template method via LibraryItem
CompositionLibrary contains Member and LibraryItem objects
@staticmethodLibrary.calculate_fine
Date arithmeticDue dates, overdue detection, fine calculation
Custom exceptionsItemUnavailableError
Type checkingisinstance(item, Book) in search_by_author
Encapsulation_checkouts private to Library, _history not directly accessible

Extension Challenges

Extension 1 - Loan history per item Track a full loan history on each LibraryItem - who checked it out, when, and when it was returned. Add a get_loan_history() method that returns a list of named tuples or dataclasses. This requires deciding where the history lives (on the item? on the library?).

Extension 2 - Waitlist If an item is checked out, allow members to join a waitlist. When the item is returned, the library automatically notifies (prints a message for) the next member on the waitlist. Use collections.deque for the waitlist.

Extension 3 - Magazine subclass Add a Magazine(LibraryItem) class with issue_number and publication_date attributes. Magazines have a shorter default loan period (3 days) and a different fine rate. Verify that magazines work correctly in sets alongside books and DVDs.

Extension 4 - Member tiers Add a MemberTier enum with values STANDARD, PREMIUM, STUDENT. Each tier has a different default loan period, maximum items allowed, and fine rate. Member stores a tier attribute. Library.checkout uses the tier to set the due date and maximum checkout count.

Extension 5 - Persistence Implement Library.save(filepath) and Library.load(filepath) using json. You will need to implement to_dict() on Book, DVD, and Member. Loading back must reconstruct the correct concrete types from the serialised type field. Handle the case where checkout state must be restored correctly.

© 2026 EngineersOfAI. All rights reserved.