Python OOP · Learning Guide · Narra Book Haven
A complete walkthrough of a Python bookstore management system — every class, every method, every pattern dissected through three lenses: why it was written, why it matters, and exactly how it works. Extended with OOP theory, design patterns, a Python cheat sheet, and practice challenges.
from datetime import datetime from typing import Dict, Optional class Book: """Represents a single book in the bookstore inventory.""" def __init__(self, title: str, author: str, price_php: float, quantity: int): self.title = title.strip() self.author = author.strip() self.price_php = round(float(price_php), 2) self.quantity = max(0, int(quantity)) def __str__(self) -> str: return f"{self.title:<45} | {self.author:<30} | ₱{self.price_php:>8,.2f} | Stock: {self.quantity:>2}" def can_sell(self, qty: int) -> bool: return 0 < qty <= self.quantity def sell(self, qty: int): self.quantity -= qty class Bookstore: """Manages the bookstore inventory and sales.""" def __init__(self, name: str = "Narra Book Haven"): self.name = name self.books: Dict[str, Book] = {} self.sales_log: list = [] def add_book(self, title: str, author: str, price_php: float, quantity: int = 15) -> None: key = title.strip().lower() if key in self.books: self.books[key].quantity += max(0, quantity) else: self.books[key] = Book(title, author, price_php, quantity) def find_book(self, search_term: str) -> Optional[Book]: """Supports searching by Title or Row Number.""" search_term = search_term.strip().lower() if search_term.isdigit(): idx = int(search_term) - 1 all_books = list(self.books.values()) if 0 <= idx < len(all_books): return all_books[idx] return self.books.get(search_term) def purchase(self, customer_name: str, title_or_idx: str, quantity: int) -> None: book = self.find_book(title_or_idx) if not book: print(f"\n [!] '{title_or_idx}' not found.") return if quantity < 1: print("\n [!] Quantity must be at least 1.") return if not book.can_sell(quantity): print(f"\n [!] Only {book.quantity} copies left.") return total = book.price_php * quantity book.sell(quantity) timestamp = datetime.now().strftime("%Y-%m-%d %I:%M %p") self.sales_log.append({ "time": timestamp, "customer": customer_name, "title": book.title, "qty": quantity, "total": total }) print(f"\n ✅ Purchase successful!") print(f" Customer: {customer_name} | Total: ₱{total:,.2f}") def main(): store = Bookstore("Narra Book Haven") store.populate_with_sample_books() print("="*90) print(f"{store.name:^90}") print("="*90) while True: print("\n[1] View All Books [2] Buy a Book [3] Exit") choice = input("Enter Choice: ").strip() if choice == "1": store.list_available_books() elif choice == "2": store.list_available_books() target = input("Enter Title or Number (#): ").strip() try: qty = int(input("How many copies? : ")) except ValueError: print(" Invalid quantity."); continue customer = input("Your name: ").strip() or "Guest" store.purchase(customer, target, qty) elif choice == "3": total_rev = sum(s['total'] for s in store.sales_log) print(f"\nTotal Revenue: ₱{total_rev:,.2f}") print(f"Thank you for visiting {store.name}!") break if __name__ == "__main__": main()
Before dissecting the code, it helps to understand the thinking behind OOP. Every line in this program was shaped by four fundamental principles — the pillars that define how professional Python code is structured.
Encapsulation
Each class bundles its data and the methods that operate on it. Book guards its price and quantity behind its own methods — external code cannot reach in and corrupt them directly.
Abstraction
Callers don't need to know how purchase() validates or logs — they just call it and trust it. Complexity is hidden behind a simple, clean interface.
Separation of Concerns
Book knows about books. Bookstore knows about inventory. main() knows about the user. Each layer does exactly one job and does it well.
classA class is a template. It describes what data (attributes) an object holds and what actions (methods) it can perform. class Book: is the blueprint; each book in your inventory is an instance of that blueprint.
__init__When you write Book("Dune", "Herbert", 590, 15), Python calls __init__ automatically — it is the factory that stamps out a fresh object and stores its data in self.
Methods (can_sell, sell, add_book) are functions that live inside a class. They always receive self — a reference to the instance they belong to — so they can read and modify its data.
Bookstore contains a dictionary of Book objects. One class holding instances of another is called composition — the most common way to build complex systems from simple, tested pieces.
from datetime import datetime from typing import Dict, Optional
① Why used
datetime stamps every sale with the exact moment it occurred. Dict and Optional are type-annotation helpers — they let the programmer declare precisely what kind of data each variable holds or a function returns.
② Significance
Without datetime there is no sales timestamp — every transaction becomes anonymous in time. Without the typing imports, the code runs identically but becomes far harder to read, debug, and maintain as the codebase grows.
③ How it operates
datetime.now() captures the live system clock at the moment a sale is made. Dict[str, Book] annotates a dictionary whose keys are strings and values are Book objects. Optional[Book] signals a function returns either a Book or None.
dict[str, Book] and Book | None directly — the typing module helpers are no longer required. The code uses them for compatibility with Python 3.7+.The Book class is the atomic unit of the entire system. It models a single real-world book and contains everything needed to describe, display, and sell it.
__init__ Lines 7–11def __init__(self, title: str, author: str, price_php: float, quantity: int): self.title = title.strip() self.author = author.strip() self.price_php = round(float(price_php), 2) self.quantity = max(0, int(quantity))
① Why used
This is the blueprint for creating any book object. Every book in the inventory is built through this constructor. It also silently sanitises incoming data — bad input gets corrected rather than crashing the program.
② Significance
No book object can exist without passing through here. It is the single point of data integrity for the entire inventory — enforcing clean titles, valid prices, and non-negative stock counts from the very moment a book is created.
③ How it operates
.strip() removes stray whitespace from names. round(..., 2) locks prices to exactly 2 decimal places. max(0, int(quantity)) silently converts negatives to zero — passing -5 becomes 0.
__str__ Lines 13–15def __str__(self) -> str: return f"{self.title:<45} | {self.author:<30} | ₱{self.price_php:>8,.2f} | Stock: {self.quantity:>2}"
① Why used
Python calls this automatically whenever a Book object is passed to print(). It turns raw data into a formatted table row so the user sees a neat, aligned catalogue — not a raw memory dump.
② Significance
This is the entire visual interface for the inventory. Without it, print(book) would output <Book object at 0x7f…> — useless to a customer browsing titles.
③ How it operates
Format codes inside the f-string do all the column alignment. Each code sets a fixed field width, ensuring every row lines up perfectly regardless of title length.
| Format Code | Meaning | Example Output |
|---|---|---|
:<45 | Left-align, pad to 45 chars | The Kite Runner … |
:<30 | Left-align, pad to 30 chars | Khaled Hosseini … |
:>8,.2f | Right-align, 8 wide, comma thousands, 2 decimals | 425.75 |
:>2 | Right-align stock in 2 chars | 18 |
can_sell & selldef can_sell(self, qty: int) -> bool: return 0 < qty <= self.quantity def sell(self, qty: int): self.quantity -= qty
① Why used
The two methods are deliberately separated — one checks whether a sale is valid, the other executes it. Stock can never be deducted without first confirming availability.
② Significance
can_sell is the safety lock. sell is the trigger. You cannot fire the trigger without first passing the lock — preventing overselling regardless of how complex the calling code becomes.
③ How it operates
can_sell returns True only when qty > 0 and doesn't exceed current stock. If 5 copies remain and a customer requests 6, it returns False. sell then simply subtracts the sold number.
__init__ (constructor), __str__ (string representation for print()), __repr__ (developer representation), __len__ (for len()), __eq__ (for == comparisons), __lt__ (for < / sorting). Implementing dunders makes your class behave like a native Python type.The Bookstore class is the manager of the entire system. It owns the inventory dictionary and sales log, and exposes all the operations a real bookstore needs.
def __init__(self, name: str = "Narra Book Haven"): self.name = name self.books: Dict[str, Book] = {} self.sales_log: list = []
① Why used
books is a dictionary — O(1) average lookup by key — while sales_log is an append-only list that grows chronologically with every purchase.
② Significance
A dictionary keyed by lowercased title makes find_book() blazing fast even with thousands of entries. A list for sales preserves insertion order, making session revenue easy to compute.
③ How it operates
The default parameter name="Narra Book Haven" means Bookstore() works without arguments. Dict[str, Book] is a type hint: string keys mapping to Book objects. sales_log starts empty and grows with each purchase.
add_book — Upsert Patterndef add_book(self, title, author, price_php, quantity=15) -> None: key = title.strip().lower() if key in self.books: self.books[key].quantity += max(0, quantity) # restock else: self.books[key] = Book(title, author, price_php, quantity) # new
find_book — Dual Searchdef find_book(self, search_term: str) -> Optional[Book]: search_term = search_term.strip().lower() if search_term.isdigit(): idx = int(search_term) - 1 all_books = list(self.books.values()) if 0 <= idx < len(all_books): return all_books[idx] return self.books.get(search_term) # None if not found
① Why used
Users can find a book either by typing its title or by typing its row number. Combining both lookup strategies in one place means the rest of the code never has to worry about which input type the user chose.
② Significance
Every purchase flows through here. Making it flexible — number or title — dramatically improves usability with zero extra complexity for callers. Optional[Book] means callers must handle the None case.
③ How it operates
.isdigit() detects numeric input. The - 1 converts display numbering (starts at 1) to list indexing (starts at 0). Text input falls through to a direct dictionary lookup. Returns None if nothing matches.
The purchase method is the most critical in the program. It orchestrates the entire buying process — three sequential safety checks, calculation, stock update, and sale recording.
def purchase(self, customer_name: str, title_or_idx: str, quantity: int) -> None: book = self.find_book(title_or_idx) if not book: # Guard 1 — book exists? print(f"\n [!] '{title_or_idx}' not found."); return if quantity < 1: # Guard 2 — qty ≥ 1? print("\n [!] Quantity must be at least 1."); return if not book.can_sell(quantity): # Guard 3 — enough stock? print(f"\n [!] Only {book.quantity} copies left."); return total = book.price_php * quantity # calculate book.sell(quantity) # deduct stock timestamp = datetime.now().strftime("%Y-%m-%d %I:%M %p") self.sales_log.append({ # record sale "time": timestamp, "customer": customer_name, "title": book.title, "qty": quantity, "total": total }) print(f"\n ✅ Purchase successful!") print(f" Customer: {customer_name} | Total: ₱{total:,.2f}")
① Why used
Layered validation ensures no money is calculated, no stock reduced, and no sale recorded unless every condition is fully satisfied. Any failure exits immediately with a clear error message.
② Significance
This pattern — guard clauses or early return — keeps the success path readable and flat. Deeply nested if/else becomes unreadable as conditions grow. Flat is better than nested (The Zen of Python).
③ How it operates
After all guards pass: price × quantity calculates total, book.sell() deducts stock, datetime.now().strftime(...) records the timestamp, and a dictionary with all sale details is appended to sales_log.
"%Y-%m-%d %I:%M %p" produces output like 2026-03-06 02:45 PM. %I is 12-hour clock, %p adds AM/PM. The %Y-%m-%d ordering makes records sort chronologically by default.def main(): store = Bookstore("Narra Book Haven") store.populate_with_sample_books() print("="*90) print(f"{store.name:^90}") # :^90 — centre-align in 90 characters print("="*90) while True: print("\n[1] View All Books [2] Buy a Book [3] Exit") choice = input("Enter Choice: ").strip() if choice == "1": store.list_available_books() elif choice == "2": store.list_available_books() target = input("Enter Title or Number (#): ").strip() try: qty = int(input("How many copies? : ")) except ValueError: print(" Invalid quantity.") continue customer = input("Your name: ").strip() or "Guest" store.purchase(customer, target, qty) elif choice == "3": total_rev = sum(s['total'] for s in store.sales_log) print(f"\nTotal Revenue: ₱{total_rev:,.2f}") break
① Why used
while True creates an infinite loop that keeps the menu running until the user explicitly chooses option 3. This is the standard pattern for any interactive terminal menu in Python.
② Significance
Without this loop, the program runs once and immediately terminates. The loop transforms it from a one-shot script into an interactive application — cleanly separating program logic (the classes) from program control (the menu).
③ How it operates
Option 1 → list_available_books(). Option 2 → collects input, wraps quantity in try/except to catch non-numeric typing, calls purchase(). Option 3 → generator expression sums all totals, then break exits.
try/except ValueError block: If a user types "abc" when asked for a quantity, int("abc") raises ValueError and would crash the program. Wrapping it in try/except catches that error gracefully, prints a message, and continue restarts the loop — the program never crashes from bad typing.or "Guest" trick: input(...).strip() or "Guest" — if the user presses Enter without typing, .strip() returns an empty string "", which is falsy in Python. The or then falls back to "Guest". Clean one-liner default.if __name__ == "__main__": main()
① Why used
This guards against main() running automatically when another Python file imports this module. It is a universal Python convention used in every professional script and library.
② Significance
It makes the file dual-purpose — it can run as a standalone program and be imported as a module by other files that want to reuse Book or Bookstore without launching the interactive menu.
③ How it operates
Python sets __name__ to "__main__" when the file is run directly. When imported, __name__ becomes the module filename. The if check exploits this — only a direct run triggers main().
This program uses several well-known design patterns — recurring solutions to common programming problems. Recognising them helps you apply the same thinking to new projects.
Both Book.__init__ and Bookstore.__init__ act as factories — every valid object of that type is created through them. This centralises data validation so nothing invalid ever enters the system.
Instead of deeply nested if/else, purchase() checks failure conditions first and returns immediately. The happy path is flat, readable, and at the bottom — easy to scan at a glance.
add_book() checks whether a book already exists: if yes, it restocks; if no, it creates. One method handles both cases — callers don't need to know which scenario applies.
sales_log is never modified after a sale is appended. Each entry is immutable history — making the log safe, auditable, and trivially summed with a generator expression.
Bookstore contains Book objects rather than inheriting from them. Composition keeps classes small, testable, and loosely coupled — replacing Book later requires no changes to Bookstore.
Both add_book and find_book normalise input (.strip().lower()) before using it as a dictionary key. Callers don't need to know about the internal key format.
import this to read all 19 principles — they explain every design decision in this codebase.Every syntax pattern and built-in used in this program, with a concise definition. Bookmark this section for quick reference while coding.
| Feature | Python | Java equivalent | Notes |
|---|---|---|---|
| Class definition | class Book: | class Book {} | Python needs no braces |
| Constructor | def __init__(self) | Book() {} | self is explicit in Python |
| Access modifiers | _prot / __priv (convention) | private / protected | Python is convention-based |
| Interface/Protocol | Protocol (3.8+) or ABC | interface | Python uses duck typing |
| Multiple inheritance | ✓ Native | ✗ Not allowed | Python uses MRO to resolve order |
| Type hints | Optional, runtime-ignored | Mandatory | Checked by mypy/pyright |
| Operator overloading | ✓ via dunders | Partial | __add__, __eq__, etc. |
The best way to internalise OOP is to extend the system yourself. Each challenge below targets a specific concept covered in this guide. Try them in order — each one builds on the last.
__repr__ method to BookImplement __repr__ so that typing a Book object in the Python REPL shows something like Book(title="Dune", price=590.00, stock=15). This is what developers see when debugging.
category attribute to BookExtend Book.__init__ to accept a category: str = "Fiction" argument. Then add a Bookstore.list_by_category(cat) method that prints only books in that category.
print_receipt methodAdd Bookstore.print_receipt() that prints all entries in sales_log as a formatted receipt table — customer name, title, quantity, price per unit, and line total. Use f-string column alignment.
Add a discount attribute to Book (default 0.0 for 0%). Add a @property called final_price that returns price_php * (1 - discount). Use final_price in the purchase calculation.
Book objects sortable by priceImplement __lt__ on Book so that sorted(bookstore.books.values()) returns books sorted by price. Then add a menu option [4] that lists books sorted cheapest-first.
EBook subclass using inheritanceCreate class EBook(Book): that adds a file_format: str (e.g. "PDF", "ePub") and a download_size_mb: float. Override __str__ to append this info. Store both physical and digital books in the same inventory.
Add Bookstore.save_to_json(path) and Bookstore.load_from_json(path) using Python's built-in json module. Books should survive the program restarting — inventory persists across sessions.
unittestCreate test_bookstore.py using Python's standard unittest module. Write at least 8 test cases: test that can_sell correctly handles edge cases (0, exact stock, over stock), that add_book restocks correctly, and that purchase rejects invalid inputs.
Every important term used in this guide, defined precisely in the context of Python OOP.
Book("Dune", ...) creates one instance of the Book class. Each instance has its own copy of the attributes.
self.title, self.price_php, and self.quantity are all instance attributes of Book.
self — the instance — giving them access to the object's data and other methods.
__init__, __str__). Python calls these automatically in response to built-in operations like print(), len(), or +.
param: str or -> bool that describe expected types. Ignored at runtime; used by editors and tools like mypy to catch bugs before they happen.
return (or continue) that exits a function immediately when a pre-condition fails. Keeps the success path flat and readable instead of deeply nested.
add_book so callers don't need to know which case applies.
sum(s['total'] for s in sales_log). Unlike a list comprehension, it computes values on demand without building the full list first.
Bookstore containing a dict of Book objects). Usually preferred over inheritance for "has-a" relationships.
Encapsulation
Each class owns its own data. Book manages its title, price, and stock internally. Bookstore manages its inventory and sales log. Neither class reaches into the other's internals directly.
Validation
Data is cleaned on entry (.strip(), max(0,...), round(...,2)), checked on use (can_sell, guard clauses), and never mutated without first passing a check.
Separation of Concerns
Book knows only about itself. Bookstore knows how to manage books. main() knows only how to talk to the user. Each layer does exactly one job.
Book.__init__ — creates and cleans a bookBook.__str__ — formats a book as a table rowBook.can_sell — checks if stock allows a saleBook.sell — deducts sold quantity from stockBookstore.__init__ — creates empty storeBookstore.add_book — adds or restocks a bookBookstore.find_book — searches by title or row numberBookstore.purchase — validates, sells, and logs a transactionmain() — runs the interactive menu loop__init__, __str__Dict, Optional, str, intsum(s['total'] for s in ...)if __name__ == "__main__"add_bookThe Narra Book Haven is not just a code demo — it is a faithful model of a real Filipino independent bookstore. Every design decision in the Python code mirrors a real-world business rule. Here's what makes the store work.
Curated Philippine Catalogue
The inventory opens with Rizal's Noli Me Tangere and El Filibusterismo, Balagtas's Florante at Laura, and the legendary Ibong Adarna — literature that shaped Philippine national identity. The catalogue reflects what a real independent Filipino bookstore would proudly carry.
Philippine Peso Pricing
All prices are denominated in Philippine Pesos (₱). The formatting code ₱{total:,.2f} ensures every receipt reads naturally — ₱1,250.00 rather than bare floats. The round(..., 2) constructor prevents floating-point drift on large orders.
Inventory Protection
The can_sell → sell two-step guard is a deliberate architectural pattern: stock is never decremented without first passing a validation gate. This means the inventory can never go negative, no matter how the calling code evolves.
Flexible Search
Customers can find books by typing the title — or just the row number from the display. The find_book method handles both automatically, making the store friendly for casual browsers who don't know the exact title spelling.
Sales Audit Log
Every transaction appends a timestamped dictionary to sales_log. This is a simplified version of the audit trail that every real point-of-sale system maintains — customer, title, quantity, total, and timestamp.
Silent Data Sanitisation
.strip() on names, max(0, ...) on quantity, and round(..., 2) on price are all applied silently at construction. Bad input is corrected without crashing. This is the "defensive programming" approach used in production retail systems.
| # | Title | Author | Price (₱) | Stock | Genre |
|---|---|---|---|---|---|
| 1 | Noli Me Tangere | Jose Rizal | 220.00 | 10 | Philippine Classic |
| 2 | El Filibusterismo | Jose Rizal | 195.00 | 8 | Philippine Classic |
| 3 | Florante at Laura | Francisco Balagtas | 150.00 | 12 | Philippine Epic |
| 4 | Ibong Adarna | Anonymous | 175.00 | 6 | Philippine Folk Epic |
| 5 | The Great Gatsby | F. Scott Fitzgerald | 310.00 | 5 | American Classic |
main() collects the user's input string and passes it directly to purchase(). No pre-processing — the lookup logic lives entirely inside the class.
find_book resolves the titleThe search is normalised to lowercase and stripped. If it's numeric, it indexes the values list. If text, it hits the dictionary directly. Returns a Book object or None.
Three checks run in sequence: book must exist, quantity must be ≥ 1, and stock must cover the request. Any failure prints a clear error and returns immediately — the success path never nests.
book.sell(qty) reduces the quantity. The transaction — time, customer, title, qty, total — is appended to sales_log with datetime.now().
A confirmation line shows customer name and total in Philippine Peso format. At exit, the cumulative revenue across all transactions is calculated via a generator expression and displayed.
New Python deep-dives, OOP walkthroughs, design pattern breakdowns, and real project tutorials — delivered to your inbox every two weeks.
Your data stays private. We never sell or share subscriber information.
Got a question about the bookstore code, a pattern to suggest, or a challenge solution to share? Leave a comment below.
Leave a Comment