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
ABCand@abstractmethodto define interfaces that concrete classes must fulfil - Designing a composition relationship (a
LibrarycontainsBookandMemberobjects) - Using
datetime.datefor 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(fromabcmodule). - 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, callsget_display_info()so all subclasses get a consistent repr. - Any class that inherits from
LibraryItemand does not implement all abstract members must raiseTypeErrorwhen instantiated.
R2 - Book class
- Inherits from
LibraryItem. - Attributes:
isbn(str),title(str),author(str),year(int),genre(str, optional). item_idproperty: returns the ISBN.get_display_info(): returns"[ISBN: 978-0-06-112008-4] To Kill a Mockingbird - Harper Lee (1960)".__eq__: twoBookobjects are equal if and only if theirisbnvalues 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 toget_display_info()(inherited fromLibraryItem).
R3 - DVD class
- Inherits from
LibraryItem. - Attributes:
dvd_id(str),title(str),director(str),year(int),runtime_minutes(int). item_idproperty: returnsdvd_id.get_display_info(): returns"[DVD: D001] Inception - Christopher Nolan (2010, 148 min)".__eq__and__hash__: equality and hashing based ondvd_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 mappingLibraryItemtodue_date(adateobject). Items are keys - this only works ifLibraryItemsubclasses implement__hash__.checkout_item(item, due_date): adds the item tochecked_out. RaisesValueErrorif the item is already checked out by this member.return_item(item): removes the item fromchecked_out. RaisesValueErrorif 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 beforeas_of_date(defaults to today).__repr__:"Member(M001, Alice Chen, 3 items checked out)".
R5 - Library class
- Attributes:
name(str),catalogue(a set ofLibraryItem- this is why hash must work),members(dict mapping member_id toMember). 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. RaisesValueErrorif not found.register_member(member): adds member tomembersdict.checkout(member_id, item_id, loan_days=14): finds the member and item, callsmember.checkout_item(item, due_date)wheredue_date = today + timedelta(days=loan_days). RaisesValueErrorif member or item is not found. RaisesItemUnavailableErrorif the item is already checked out by someone else.return_item(member_id, item_id): processes a return. Callsmember.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 ofLibraryItemwhose title containsquery(case-insensitive).search_by_author(query): returns a list ofBookwhose author containsquery(case-insensitive).search_by_item_id(item_id): returns theLibraryItemwith thatitem_id, orNone.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
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
| Concept | Where it appears |
|---|---|
| Abstract Base Classes | LibraryItem(ABC) with @abstractmethod |
__eq__ | Book, DVD - equality by identifier |
__hash__ | Book, DVD - required for set membership |
__repr__ | All classes, template method via LibraryItem |
| Composition | Library contains Member and LibraryItem objects |
@staticmethod | Library.calculate_fine |
| Date arithmetic | Due dates, overdue detection, fine calculation |
| Custom exceptions | ItemUnavailableError |
| Type checking | isinstance(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.
