Project Goals
3 minBy the end of this project you can:
- Design a small class hierarchy (Pet parent + 3 children).
- Use dunders (
__repr__,__eq__,__lt__,__len__) to make a collection feel native. - Keep state encapsulated with
_underscoreattributes plus methods. - Wire it all into a 5-option CLI app with JSON persistence (from PY-L2-45).
The Spec
5 minYou're building petshop.py. It tracks:
Pets — Dog, Cat, Fish (children of Pet). Each has name, age, price.
Shop — a collection of Pet instances. Supports add, remove, search,
report, save, load.
CLI — five-option menu drives the Shop.Each Pet child overrides a polymorphic noise() method differently. The Shop uses dunders so len(shop), pet in shop, and for p in shop all just work.
This is the first project where you should feel that classes are making your code simpler, not adding ceremony. The CLI is short because the heavy lifting is encapsulated.
Task 1 · The Pet Hierarchy
10 minThree classes in one file pets.py:
# pets.py — Pet family with polymorphic noise() from functools import total_ordering @total_ordering class Pet: def __init__(self, name, age, price): if price < 0: raise ValueError(f"Price can't be negative: {price}") self.name = name self.age = age self._price = price # private — only via the methods below def noise(self): raise NotImplementedError("Subclasses must define noise()") def price(self): return self._price def discount(self, percent): self._price *= (1 - percent / 100) def __repr__(self): kind = type(self).__name__ return f"{kind}(name={self.name!r}, age={self.age}, price={self._price:.2f})" def __eq__(self, other): if not isinstance(other, Pet): return NotImplemented return self.name == other.name and type(self) is type(other) def __lt__(self, other): return self._price < other._price def __hash__(self): return hash((type(self).__name__, self.name)) class Dog(Pet): def noise(self): return "Woof!" class Cat(Pet): def noise(self): return "Meow." class Fish(Pet): def noise(self): return "blub blub"
Three classes for three pet types. The parent enforces price-positivity in __init__ — an invariant from PY-L3-10. Equality uses both name and class — a Dog called "Whiskers" isn't equal to a Cat called "Whiskers".
Task 2 · The Shop
10 minSave as shop.py:
# shop.py — Shop wrapping a list of pets, with dunders from pets import Pet, Dog, Cat, Fish class Shop: def __init__(self, name): self.name = name self._pets = [] # collection dunders def __len__(self): return len(self._pets) def __iter__(self): return iter(self._pets) def __contains__(self, pet): return pet in self._pets def __repr__(self): return f"Shop({self.name!r}, {len(self)} pets)" # business methods def add(self, pet): if pet in self._pets: raise ValueError(f"Already stocked: {pet!r}") self._pets.append(pet) def remove(self, pet): if pet not in self._pets: raise ValueError(f"Not stocked: {pet!r}") self._pets.remove(pet) def search_by_kind(self, kind): """Return all pets of the given class — Dog, Cat, Fish.""" return [p for p in self._pets if isinstance(p, kind)] def total_value(self): return sum(p.price() for p in self._pets) def discount_all(self, percent): for p in self._pets: p.discount(percent) def report(self): print(f"\n=== {self.name} ({len(self)} pets) ===") for p in sorted(self._pets): print(f" RM {p.price():>6.2f} {p!r} → {p.noise()}") print(f" Total value: RM {self.total_value():.2f}")
Shop is encapsulated — _pets is private, accessed only via methods. __len__, __iter__, __contains__ from PY-L3-16 make it behave like a built-in collection.
Task 3 · JSON Persistence
10 minPersisting custom objects to JSON takes a little manual work — JSON doesn't know about classes. We convert each pet to a dict for save; reconstruct from a dict on load.
# Add to Shop: import json KIND_MAP = {"Dog": Dog, "Cat": Cat, "Fish": Fish} def save(self, path): data = { "name": self.name, "pets": [ {"kind": type(p).__name__, "name": p.name, "age": p.age, "price": p.price()} for p in self._pets ], } with open(path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2) @classmethod def load(cls, path): with open(path, encoding="utf-8") as f: data = json.load(f) shop = cls(data["name"]) for d in data["pets"]: kind_cls = KIND_MAP[d["kind"]] shop._pets.append(kind_cls(d["name"], d["age"], d["price"])) return shop
@classmethod is new — it's a method that takes the class itself (not an instance) as the first argument. It's the standard way to define alternate constructors. We'll explore @classmethod properly in PY-L3-19.
Task 4 · The CLI
10 minSave the main loop as cli.py:
# cli.py — the user-facing menu import os from pets import Pet, Dog, Cat, Fish from shop import Shop FILE = "shop.json" KIND_TABLE = {"d": Dog, "c": Cat, "f": Fish} def make_pet(): kind = input("Kind (d/c/f): ").lower() cls = KIND_TABLE.get(kind) if not cls: print(" ! Unknown kind."); return None name = input("Name : ").strip() age = int(input("Age : ")) price = float(input("Price: ")) return cls(name, age, price) # Load or create if os.path.exists(FILE): shop = Shop.load(FILE) print(f"Loaded shop with {len(shop)} pets.") else: shop = Shop("Pet Heaven") while True: print() print("1 add 2 remove 3 report 4 discount-all 5 quit") pick = input("> ").strip() try: if pick == "1": pet = make_pet() if pet: shop.add(pet) shop.save(FILE) elif pick == "2": name = input("Name to remove: ") for p in shop: if p.name == name: shop.remove(p) shop.save(FILE) break else: print(" ! Not found.") elif pick == "3": shop.report() elif pick == "4": pct = float(input("Discount %: ")) shop.discount_all(pct) shop.save(FILE) elif pick == "5": print("Bye!") break except (ValueError, KeyError) as e: print(f" ! {e}")
Test It Out
8 minRun cli.py. Add a few pets:
> 1 Kind (d/c/f): d Name : Fido Age : 3 Price: 450 > 1 Kind (d/c/f): c Name : Whiskers Age : 2 Price: 250 > 3 === Pet Heaven (2 pets) === RM 250.00 Cat(name='Whiskers', age=2, price=250.00) → Meow. RM 450.00 Dog(name='Fido', age=3, price=450.00) → Woof! Total value: RM 700.00 > 4 Discount %: 10 > 3 === Pet Heaven (2 pets) === RM 225.00 ... RM 405.00 ... Total value: RM 630.00 > 5 Bye!
Quit. Run cli.py again. Same pets are still there. That's the round-trip working.
The CLI is 35 lines. Most of the logic lives in Shop and Pet. The CLI just translates user input into method calls. That separation — domain logic in classes, IO in the CLI — is what makes the code shippable. Yesterday's lessons gave you the bricks; today shows what they build.
Recap
3 minSixteen lessons of OOP collapsed into one tiny app. Pet (parent) + Dog / Cat / Fish (children) with noise() overridden. Shop wrapping a private list, with dunders that make it feel native. JSON persistence via per-record dicts. CLI that just dispatches. Each piece is small; the whole hangs together. That's what OOP is for — keep complexity local; let users of the class write simple code.
PY-L3-18 introduces @property — making attributes computed on the fly while still looking like attributes. Then @staticmethod/@classmethod, lambdas, comprehensions deep dive, and the challenge in lesson 24.
Homework
4 minAdd three features:
- Search. Add a CLI option
6 searchthat asks for a kind (d/c/f) and prints just those pets usingshop.search_by_kind(cls). - Sort by age. Make Pet sortable by age too — add a
sort_by(self, field)method to Shop.shop.sort_by("age")returns a list of pets sorted by age. - Adopt. Add a CLI option
7 adoptthat removes a pet by name and prints "Adopted!".
Sample · sort_by + search
# in Shop: def sort_by(self, field): return sorted(self._pets, key=lambda p: getattr(p, field)) # in CLI: elif pick == "6": kind = input("Kind (d/c/f): ").lower() cls = KIND_TABLE.get(kind) for p in shop.search_by_kind(cls): print(f" {p!r}") elif pick == "7": name = input("Adopt: ") for p in shop: if p.name == name: shop.remove(p) shop.save(FILE) print("Adopted!") break
Non-negotiables: three new menu options, all backed by methods on Shop, all saving to disk. getattr(p, field) is the dynamic attribute access from PY-L3-02 — gives you a generic sort_by.