Programming with Python › Files, Errors, and Object-Oriented Python › Day 68
Hands-on lab — Day 68: Inheritance, Composition, and Dunder Methods
- ← Back to the Day 68 lesson
- Open the hands-on files on GitHub — clone or download them from the public labs repository
- Local path in your clone:
labs/sections/programming-with-python/day-068-inheritance-composition-and-dunder-methods/
Commands
Setup
cd labs/sections/programming-with-python/day-068-inheritance-composition-and-dunder-methods
python3 --version Run
python3 examples/01_inheritance_super.py
python3 examples/02_mro_diamond.py
python3 examples/03_composition.py
python3 starter/kitchen.py
python3 examples/kitchen.py
python3 examples/05_context_manager.py
python3 examples/06_abstract_base.py Test
bash tests/run_tests.sh File tree
examples/01_inheritance_super.py examples/02_mro_diamond.py examples/03_composition.py examples/05_context_manager.py examples/06_abstract_base.py examples/kitchen.py expected-output/sample-run.txt expected-output/test-run.txt metadata.yml README.md requirements/README.md security.md starter/comparison.md starter/kitchen.py tests/run_tests.sh troubleshooting.md
Lab README
Day 068 lab — Protocols and Hierarchies
Lesson
- Lesson title: Inheritance, Composition, and Dunder Methods
- Day number: 68 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-068-inheritance-composition-and-dunder-methods
- Lab files: everything you need is in this directory — follow “How to run” below.
- Browse the course locally: from the repository root, this lab also appears in the course website at
/labs/day-068-inheritance-composition-and-dunder-methodswhen the site is running.
Purpose
Make inheritance fail on purpose, then make it work; predict a method
resolution order before the interpreter tells you the answer; rebuild the
same design with composition and write down which one you would keep; and
then implement enough of Python's data model that len(), indexing,
slicing, for, in, ==, sorted(), max(), set() and with all work
on a class you wrote this afternoon — with none of those builtins modified
in any way.
That last point is the whole lab. Interoperability in Python is not a
courtesy that library authors extend to you; it is a set of method names. A
DataLoader written years before your data existed can iterate your dataset
class because your class implements __len__ and __getitem__. You are
practising that exact protocol here, on a kitchen small enough to hold in
your head.
Learning objectives
By the end of this lab you can:
- Explain what a subclass
__init__replaces, and predict where the failure surfaces whensuper().__init__(...)is missing — in a later method, not in the constructor. - Derive a diamond hierarchy's MRO by hand from the two C3 rules, then
confirm it against
__mro__, and show thatsuper()can reach a class the current one does not inherit from. - Model one domain both ways and state a concrete change that breaks the inheritance version while leaving the composed version untouched.
- Swap a collaborator object at runtime and observe the behaviour change with no class edited and no subclass written.
- Implement
__len__,__getitem__,__iter__,__contains__,__eq__with a matching__hash__, and__lt__under@total_ordering, and prove each one by driving it with an unmodified builtin. - Write a context manager whose
__exit__cleans up even when the body raises, and explain what returningTruefrom it would do instead. - Define an
abc.ABCand show that an incomplete subclass is refused at construction, naming the missing method.
Prerequisites
- Day 68's lesson, "Inheritance, Composition, and Dunder Methods" (read it first — this lab is its exercise).
- Day 67: classes,
__init__,self, methods, properties, and__repr__/__str__. - Day 66: exceptions,
try/except, and raising your own. - Day 64: file I/O and the
withstatement — whose protocol you implement here yourself. - Comfort running
python3from a terminal and editing a text file.
Supported operating systems
macOS and Linux run every command as written. On Windows, use WSL — the Python is portable, but the test runner is a bash script.
Hardware requirements
Any machine that runs Python 3. This lab writes no files and generates no data; it holds a handful of small objects in memory. Disk and memory requirements are negligible.
Required software
python33.8 or newer (tested on 3.14.0)bashfor the test runner
Nothing else. No pip install, no network access, no privileges, no API keys.
See requirements/README.md for details.
Free and open-source options
Everything here is free and open source. Python is released under the PSF
License, and every module used — abc, functools, collections.abc,
typing — is part of the standard library. There is nothing to buy, no
paid tier, and no account to create. The one third-party alternative
mentioned in the lesson, attrs, is also free and open source, and this lab
deliberately does not need it.
Installation
cd labs/sections/programming-with-python/day-068-inheritance-composition-and-dunder-methods
python3 --version
If that prints Python 3.8 or higher, you are ready. There is nothing to
install.
File structure
day-068-inheritance-composition-and-dunder-methods/
README.md this file
metadata.yml lab metadata and the recorded execution evidence
examples/
01_inheritance_super.py step 1: the missing super() call, broken then fixed
02_mro_diamond.py step 2: the four-class diamond and its MRO
03_composition.py step 3: one domain modelled both ways
kitchen.py step 4: the reference container class (Dish, Menu)
05_context_manager.py step 5: __enter__/__exit__ and the cleanup guarantee
06_abstract_base.py step 6: abc.ABC, and the duck typing that replaces it
starter/
kitchen.py your copy, with six numbered exercises to complete
comparison.md worksheet for the written answers in steps 2 and 3
tests/
run_tests.sh assert-based suite; 36 checks against real behaviour
expected-output/
sample-run.txt captured output of every example script
test-run.txt captured output of the test suite
requirements/README.md dependencies and platform notes
troubleshooting.md symptom-by-symptom fixes
security.md implicit dunder execution, trust, and what to keep out
Step 4 has no numbered script because the container class is the file you
edit: starter/kitchen.py, with examples/kitchen.py as the reference to
compare against once you have tried.
How to run
Work through the six steps in order. Each one is short and each one has a point you should be able to state before moving on.
python3 examples/01_inheritance_super.py
Then — and this matters — write your MRO prediction into
starter/comparison.md before running the next one:
python3 examples/02_mro_diamond.py
python3 examples/03_composition.py
Now the main build. Open starter/kitchen.py and complete its six numbered
exercises, running the file after each one:
python3 starter/kitchen.py
It stops at exercise 1 until you implement __len__, then gets further with
each method you finish. Compare against the reference only after you have
tried:
python3 examples/kitchen.py
Finish with the context manager and the abstract base class, then run the suite:
python3 examples/05_context_manager.py
python3 examples/06_abstract_base.py
bash tests/run_tests.sh
What the commands do
python3 examples/01_inheritance_super.py— constructs a subclass whose__init__never callssuper().__init__(), shows that construction succeeds, then callsdescribe()and catches theAttributeErrorthat surfaces there. Then runs the fixed class for contrast.python3 examples/02_mro_diamond.py— buildsToasterOven(Heater, Timer)where both bases inherit fromAppliance, prints__mro__, runs the cooperativesuper()chain through all four classes, and finally asks the interpreter to build a hierarchy C3 cannot linearise so you see the refusal.python3 examples/03_composition.py— runs the same domain as a hierarchy and as an object graph, asserts nothing but prints both, and then swaps aHeatingElementfor aFanAssistedElementat runtime.python3 starter/kitchen.py— your container class, driven by fourteen lines of plain builtins. Each unfinished exercise raisesNotImplementedErrornaming what to write.python3 examples/kitchen.py— the same driver against the finished reference, so you can diff your output against the target.python3 examples/05_context_manager.py— runs awithblock twice, once succeeding and once raising, to show__exit__runs both times.python3 examples/06_abstract_base.py— instantiates a complete subclass, then an incomplete one, then shows a class that satisfiesIterablewithout inheriting from anything.bash tests/run_tests.sh— 36 assertions run in a throwaway directory created withmktemp -dand removed by anEXITtrap.
Expected output
python3 examples/kitchen.py produces exactly this (captured on the
authoring machine; the lab is deterministic, so it reproduces byte for byte):
len: 3
index: Dish('Ramen', 12) | slice: [Dish('Gyoza', 8), Dish('Salad', 4)]
iterate: ['Ramen', 'Gyoza', 'Salad']
contains: True | False
equal: True | False
eq other type: False
hash equal: True
lt: False | ge (total_ordering): True
sorted: [Menu('Lunch', 3 dishes), Menu('Dinner', 2 dishes)]
max: Menu('Lunch', 3 dishes)
longest dish: Dish('Ramen', 12)
sum minutes via comprehension: 24
set of menus: 2
list(): 2
Every one of those lines is a builtin operating on a class that inherits
from nothing. sorted used only __lt__; max(..., key=len) used
__len__; set of menus: 2 rather than 3 is __eq__ and __hash__
collapsing the two equal menus.
The other steps produce:
$ python3 examples/01_inheritance_super.py
-- broken: subclass __init__ never calls super().__init__() --
constructed fine: BrokenOven, capacity_litres = 60
AttributeError: 'BrokenOven' object has no attribute 'name'
$ python3 examples/02_mro_diamond.py
ToasterOven -> Heater -> Timer -> Appliance -> object
ToasterOven: ready -> Heater: elements warming -> Timer: clock started -> Appliance: power on
Heater's super() lands on: Timer
Heater actually inherits from: ['Appliance']
$ python3 examples/05_context_manager.py
[service open] Bad night
[service closed] Bad night
caught: burnt the souffle | open = False
$ python3 examples/06_abstract_base.py
TypeError: Can't instantiate abstract class PastryStation without an implementation for abstract method 'prepare'
The full capture of every script is in expected-output/sample-run.txt; the
suite's output is in expected-output/test-run.txt.
Validation steps
You are done when every box is checked:
-
python3 examples/01_inheritance_super.pyshows the object being constructed successfully and then failing indescribe(), and exits 0. - You wrote your predicted MRO into
starter/comparison.mdbefore running step 2, and it matchedToasterOven -> Heater -> Timer -> Appliance -> object. - You can say why
Applianceappears in that list once and not twice. -
python3 examples/03_composition.pyshows the runtime swap changingheating tointofan-assisted towith no class edited. -
starter/comparison.mdhas a real answer on everyYour answer:line, including a concrete base-class edit that breaks only the inherited version. -
python3 starter/kitchen.pyruns with noNotImplementedErrorand its output matchespython3 examples/kitchen.pyline for line. -
python3 examples/05_context_manager.pyshows[service closed]printing in the run whose body raises, before thecaught:line. -
python3 examples/06_abstract_base.pyraisesTypeErrornamingprepare. -
bash tests/run_tests.shprints0 failure(s).and exits 0.
Tests
bash tests/run_tests.sh
The suite checks real behaviour, not file existence: that the broken
subclass constructs and only then fails, and that the traceback names
describe and not __init__; that the diamond's MRO is exactly the C3
answer with the shared base listed once; that Heater genuinely does not
inherit from Timer even though its super() lands there; that an
inconsistent hierarchy is refused at class-definition time; that both
designs of the appliance domain produce identical behaviour while the
composed classes inherit nothing but object; that swapping a collaborator
changes the result; that sorted() really orders by __lt__ and that
@total_ordering derived >, >= and <=; that __eq__ returns
NotImplemented rather than False for unknown types; that a set of three
menus collapses to two and an equal menu finds the same dict entry; that
__exit__ runs after both a clean and a raising exit without swallowing the
exception; and that the ABC names its outstanding method.
While starter/kitchen.py still contains NotImplementedError, the suite
checks its structure only and says so. Once you have completed it, the same
full protocol checks run against your version too, and the suite grows from
36 checks to 39.
It exits 0 on success and non-zero on any failure.
Recorded evidence: on the authoring machine (macOS, Apple Silicon, Python
3.14.0) the suite reports 36 checks, 0 failure(s). and exits 0.
Cleanup
git checkout -- starter/kitchen.py starter/comparison.md # optional: reset your work
Nothing else is needed. This lab creates no files, writes nothing outside
its own directory, and opens no network connections. The test suite cleans
up after itself automatically — its scratch directory is created with
mktemp -d and removed by an EXIT trap.
Troubleshooting
See troubleshooting.md for symptom-by-symptom fixes, including
AttributeError in a method rather than the constructor, TypeError about
argument counts from super(), "cannot create a consistent method
resolution order", unhashable type after adding __eq__, '<' not supported between instances, infinite recursion from a __contains__ that
uses in on itself, and an __iter__ that returns a list instead of an
iterator.
Security notes
See security.md. The essentials: dunder methods run implicitly, in
places you will never see in the source — __repr__ in tracebacks and logs,
__eq__ and __hash__ on every dict and set operation — so keep them pure,
cheap, and free of I/O and secrets. Subclassing a third-party class inherits
every method it has, including ones you have not read and ones a future
version may add.
Extension exercises
- Make
Menua real sequence. Inherit fromcollections.abc.Sequenceand delete your hand-written__contains__and__iter__. Confirm with a run that membership, iteration,reversed(),.index()and.count()all still work, then write one sentence on what you traded away (an inherited base class) for what you gained (five methods you no longer maintain). - Add arithmetic. Implement
__add__so two menus concatenate into a new one, and__radd__sosum(menus, Menu("empty"))works. Verify both return a new object rather than mutating either operand — a__add__that mutates is a bug every reader will trip over. - Overload the subscript. Extend
__getitem__to accept a dish name as a string key, raisingKeyErrorwith a helpful message when it is missing. Then write a comment explaining why accepting both integers and strings in one dunder is a design smell worth thinking twice about. - Inspect the exception in
__exit__. Write a second context manager,ServiceLog, whose__exit__reads its three parameters and prints a different closing line when the block raised. Prove to yourself that returningTruefrom it suppresses the error — then change it back. - Drop the inheritance entirely. Replace the
StationABC with atyping.Protocolmarkedruntime_checkable, delete the base class fromGrillStation, and show with a realisinstanceresult that the class still satisfies the interface. You have just moved from nominal typing to structural typing without changing a line of behaviour.
Navigation
- Previous day: Day 067 — Classes and Objects
- Next day: Day 069 — Dataclasses and Type Hints
Expected output
sample-run.txt
Captured on the authoring machine: macOS (Apple Silicon), Python 3.14.0.
Every line below is real output from the commands shown. The lab is
deterministic: no timing, no randomness, no network — these runs
reproduce byte for byte on any machine with Python 3.8 or newer.
$ python3 examples/01_inheritance_super.py
-- broken: subclass __init__ never calls super().__init__() --
constructed fine: BrokenOven, capacity_litres = 60
AttributeError: 'BrokenOven' object has no attribute 'name'
^ raised inside describe(), NOT inside __init__ —
the constructor left the object incomplete and moved on.
(traceback frames: 3)
-- fixed: super().__init__(name, watts) restores the parent's setup --
Deck oven (3200W), 60L
attributes now present: name='Deck oven', watts=3200
MRO: FixedOven -> Appliance -> object
$ python3 examples/02_mro_diamond.py
ToasterOven -> Heater -> Timer -> Appliance -> object
ToasterOven: ready -> Heater: elements warming -> Timer: clock started -> Appliance: power on
Heater's super() lands on: Timer
Heater actually inherits from: ['Appliance']
Appliance appears in the MRO 1 time(s) — never run twice.
TypeError: Cannot create a consistent method resolution order (MRO) for bases Appliance, Heater
$ python3 examples/03_composition.py
-- inheritance: behaviour arrives implicitly through the MRO --
Deck oven (3200W), 60L
heating to 220C at 3200W <- defined on HeatingAppliance, not on Oven
MRO: InheritedOven -> HeatingAppliance -> Appliance -> object
Toaster shares the whole chain: InheritedToaster -> HeatingAppliance -> Appliance -> object
-- composition: behaviour is forwarded explicitly --
Deck oven: heating to 220C at 3200W; timer set to 35 min
Two-slice: heating to 260C at 900W
ComposedOven inherits from: ['ComposedOven', 'object'] <- nothing but object
-- the practical difference: swap a collaborator at runtime --
Deck oven: fan-assisted to 220C at 3200W; timer set to 35 min
^ no class was edited, no subclass was written, nothing
inherited changed. That swap has no equivalent on the
inheritance side without defining a new subclass.
$ python3 examples/kitchen.py
len: 3
index: Dish('Ramen', 12) | slice: [Dish('Gyoza', 8), Dish('Salad', 4)]
iterate: ['Ramen', 'Gyoza', 'Salad']
contains: True | False
equal: True | False
eq other type: False
hash equal: True
lt: False | ge (total_ordering): True
sorted: [Menu('Lunch', 3 dishes), Menu('Dinner', 2 dishes)]
max: Menu('Lunch', 3 dishes)
longest dish: Dish('Ramen', 12)
sum minutes via comprehension: 24
set of menus: 2
list(): 2
$ python3 examples/05_context_manager.py
-- normal run: the body succeeds --
[service open] Brunch
inside: 1 dish, open = True
[service closed] Brunch
after: False
-- the case that matters: the body raises --
[service open] Bad night
[service closed] Bad night
caught: burnt the souffle | open = False
-- proof the cleanup is unconditional --
cleanup ran after success: True
cleanup ran after failure: True
exception still reached the caller: True
$ python3 examples/06_abstract_base.py
Grill: grilling mackerel over charcoal
TypeError: Can't instantiate abstract class PastryStation without an implementation for abstract method 'prepare'
^ refused at CONSTRUCTION, naming the class and the method.
abstract methods still outstanding: ['prepare']
-- but Python did not need the ABC to make this work --
isinstance(t, Iterable): True
isinstance(t, Sized): False
hasattr(t, "__iter__"): True
len(t) -> TypeError: object of type 'Ticket' has no len()
^ Iterable is True because Ticket defines __iter__ — the check
is for the METHOD, not for the family tree.
$ python3 starter/kitchen.py # before you complete the exercises
File "starter/kitchen.py", line 64, in __len__
raise NotImplementedError("Exercise 1: return len(self.dishes)")
NotImplementedError: Exercise 1: return len(self.dishes)
The starter stops at exercise 1 until you implement it. That is the
intended starting state: each NotImplementedError names the exercise
to complete next, and the file runs further after each one you finish.
test-run.txt
Testing inheritance, super(), and what breaks without it ...
ok: 1. broken subclass raises AttributeError for the parent's attribute
ok: 1b. the fixed subclass produces the extended description
ok: 1c. construction succeeds; the failure surfaces in describe(), not __init__
ok: 1d. super().__init__() restores name and watts and extends describe()
Testing the diamond hierarchy and C3 linearization ...
ok: 2. the diamond MRO is ToasterOven -> Heater -> Timer -> Appliance -> object
ok: 2b. cooperative super() chains through all four classes
ok: 2c. the shared base appears once, and Heater's super() lands on a non-parent
ok: 2d. an inconsistent hierarchy is refused at class-definition time
Testing the composition rewrite of the same domain ...
ok: 3. the composed oven delegates to its element and its timer
ok: 3b. both designs behave identically, but the composed one inherits nothing
ok: 3c. a collaborator can be swapped at runtime with no class edited
Testing the context manager's cleanup guarantee ...
ok: 5. cleanup runs and the exception still reaches the caller
Testing the abstract base class and duck typing ...
ok: 6. an incomplete subclass is refused at construction, by name
ok: 6b. the complete subclass works through the template method
ok: 6c. the ABC itself is not instantiable and names its outstanding method
Testing the container class in examples (builtins only, nothing patched) ...
ok: 4. len(menu) calls __len__
ok: 4b. menu[i] and menu[i:j] call __getitem__ (slicing comes free)
ok: 4c. a for loop calls __iter__, and two loops both start over
ok: 4d. 'x in menu' calls __contains__ for both names and Dishes
ok: 4e. __eq__ compares contents and defers on unknown types
ok: 4f. __hash__ survives __eq__, so menus work in sets and dicts
ok: 4g. sorted() and max() work using only __lt__ and @total_ordering
ok: 4h. iteration protocol feeds max(), sum() and comprehensions
ok: 4i. the context manager opens, closes, and closes again on failure
ok: 4j. Dish equality and hashing behave as the pattern for Menu
Testing examples/kitchen.py end to end ...
ok: 4k. the reference demo reports the collapsed set of menus
Testing starter/kitchen.py ...
ok: starter kitchen.py is valid Python
Note: starter/kitchen.py still has unfinished exercises — testing structure only.
ok: starter defines __len__
ok: starter defines __getitem__
ok: starter defines __iter__
ok: starter defines __contains__
ok: starter defines __eq__
ok: starter defines __hash__
ok: starter defines __lt__
ok: starter defines __enter__
ok: starter defines __exit__
36 checks, 0 failure(s).
Source files
examples/01_inheritance_super.py (2818 bytes)
"""Step 1: break inheritance on purpose, then fix it.
A subclass that defines __init__ REPLACES the parent's __init__. If it does
not call super().__init__(...), the parent's setup never runs. The object is
still created — it is just incomplete, and the failure surfaces later in some
unrelated method, blaming a missing attribute instead of the constructor that
never ran. That displacement is the whole point of this script.
Run it: python3 examples/01_inheritance_super.py
"""
import traceback
class Appliance:
"""The base class. It owns `name` and `watts`."""
def __init__(self, name, watts):
self.name = name
self.watts = watts
def describe(self):
return f"{self.name} ({self.watts}W)"
class BrokenOven(Appliance):
"""A two-level hierarchy with the super() call deliberately missing."""
def __init__(self, name, watts, capacity_litres):
# BUG ON PURPOSE: no super().__init__(name, watts) here, so `name`
# and `watts` are never set on this instance.
self.capacity_litres = capacity_litres
def describe(self):
# Extending: override, but still call the parent through super().
return f"{super().describe()}, {self.capacity_litres}L"
class FixedOven(Appliance):
"""The same class with the one missing line restored."""
def __init__(self, name, watts, capacity_litres):
super().__init__(name, watts) # THE FIX: run the parent's setup first
self.capacity_litres = capacity_litres
def describe(self):
return f"{super().describe()}, {self.capacity_litres}L"
def main():
print("-- broken: subclass __init__ never calls super().__init__() --")
oven = BrokenOven("Deck oven", 3200, 60)
# Note that construction SUCCEEDED. Nothing raised yet.
print(f"constructed fine: {type(oven).__name__}, capacity_litres = "
f"{oven.capacity_litres}")
try:
oven.describe()
except AttributeError as err:
# Print only the final line, so the output is stable across machines.
print(f"AttributeError: {err}")
print(" ^ raised inside describe(), NOT inside __init__ —")
print(" the constructor left the object incomplete and moved on.")
# The full traceback shows both frames; kept short on purpose.
frames = traceback.format_exc().strip().splitlines()
print(f" (traceback frames: {sum(1 for f in frames if 'File ' in f)})")
print()
print("-- fixed: super().__init__(name, watts) restores the parent's setup --")
fixed = FixedOven("Deck oven", 3200, 60)
print(fixed.describe())
print(f"attributes now present: name={fixed.name!r}, watts={fixed.watts}")
print(f"MRO: {' -> '.join(c.__name__ for c in FixedOven.__mro__)}")
if __name__ == "__main__":
main()
examples/02_mro_diamond.py (2319 bytes)
"""Step 2: predict the MRO of a diamond, then check yourself.
BEFORE RUNNING THIS, write your prediction down on paper.
The hierarchy is a diamond: ToasterOven inherits from Heater and Timer, and
both of those inherit from Appliance. C3 linearization gives exactly one
order, and it guarantees two things you can re-derive by hand:
* local precedence — a class always comes before its own parents;
* monotonicity — the order bases are listed in is preserved.
So: start at ToasterOven. Bases are (Heater, Timer), so Heater is next.
Heater's parent is Appliance — but Appliance cannot come yet, because Timer
also inherits from it and Timer must precede its own parent. So Timer, then
Appliance, then object. Appliance appears ONCE, which is why it is never
initialised or run twice.
Run it: python3 examples/02_mro_diamond.py
"""
class Appliance:
def power_on(self):
return "Appliance: power on"
class Heater(Appliance):
def power_on(self):
return "Heater: elements warming -> " + super().power_on()
class Timer(Appliance):
def power_on(self):
return "Timer: clock started -> " + super().power_on()
class ToasterOven(Heater, Timer):
def power_on(self):
return "ToasterOven: ready -> " + super().power_on()
def main():
print(" -> ".join(cls.__name__ for cls in ToasterOven.__mro__))
print(ToasterOven().power_on())
print()
# The payoff: super() is NOT "my parent". It is "the next class in THIS
# instance's MRO". Heater does not inherit from Timer and has never heard
# of it, yet Heater's super() call lands there.
mro = [cls.__name__ for cls in ToasterOven.__mro__]
after_heater = mro[mro.index("Heater") + 1]
print(f"Heater's super() lands on: {after_heater}")
print(f"Heater actually inherits from: "
f"{[c.__name__ for c in Heater.__bases__]}")
print(f"Appliance appears in the MRO {mro.count('Appliance')} time(s) — "
f"never run twice.")
# If no consistent order exists, Python refuses at class-definition time
# rather than guessing. Here the bases are listed parent-before-child.
print()
try:
type("Impossible", (Appliance, Heater), {})
except TypeError as err:
print(f"TypeError: {err}")
if __name__ == "__main__":
main()
examples/03_composition.py (4381 bytes)
"""Step 3: the same domain modelled twice — inheritance, then composition.
Both halves of this file produce the SAME behaviour for an Oven and a
Toaster. Only the structure differs:
inheritance Oven IS-A HeatingAppliance IS-A Appliance.
Behaviour arrives implicitly, found by walking the MRO.
Fixed at class-definition time.
composition Oven HAS-A HeatingElement and HAS-A Timer.
Behaviour is forwarded explicitly (delegation).
Swappable at runtime.
After running this, write your comparison into starter/comparison.md.
Run it: python3 examples/03_composition.py
"""
# ---------------------------------------------------------------- inheritance
class Appliance:
def __init__(self, name, watts):
self.name = name
self.watts = watts
def describe(self):
return f"{self.name} ({self.watts}W)"
class HeatingAppliance(Appliance):
def __init__(self, name, watts, max_celsius):
super().__init__(name, watts)
self.max_celsius = max_celsius
def heat(self, celsius):
if celsius > self.max_celsius:
raise ValueError(f"{self.name} cannot exceed {self.max_celsius}C")
return f"heating to {celsius}C at {self.watts}W"
class InheritedOven(HeatingAppliance):
def __init__(self, name, watts, max_celsius, capacity_litres):
super().__init__(name, watts, max_celsius)
self.capacity_litres = capacity_litres
def describe(self): # extend, do not replace
return f"{super().describe()}, {self.capacity_litres}L"
class InheritedToaster(HeatingAppliance):
def __init__(self, name, watts, max_celsius, slots):
super().__init__(name, watts, max_celsius)
self.slots = slots
# ---------------------------------------------------------------- composition
class HeatingElement:
"""A collaborator. Knows how to heat, and nothing else."""
def __init__(self, watts):
self.watts = watts
def heat(self, celsius):
return f"heating to {celsius}C at {self.watts}W"
class Timer:
"""A second collaborator, entirely independent of the first."""
def __init__(self):
self.minutes = 0
def set(self, minutes):
self.minutes = minutes
return f"timer set to {minutes} min"
class FanAssistedElement(HeatingElement):
"""A drop-in replacement, to show the swap is real."""
def heat(self, celsius):
return f"fan-assisted to {celsius}C at {self.watts}W"
class ComposedOven:
def __init__(self, name, watts):
self.name = name
self.element = HeatingElement(watts) # has-a
self.timer = Timer() # has-a
def bake(self, celsius, minutes): # delegation, written by hand
return f"{self.name}: {self.element.heat(celsius)}; {self.timer.set(minutes)}"
class ComposedToaster:
def __init__(self, name, watts):
self.name = name
self.element = HeatingElement(watts) # has-a, no shared ancestor
def toast(self, celsius):
return f"{self.name}: {self.element.heat(celsius)}"
def main():
print("-- inheritance: behaviour arrives implicitly through the MRO --")
oven = InheritedOven("Deck oven", 3200, 260, 60)
print(oven.describe())
print(oven.heat(220), " <- defined on HeatingAppliance, not on Oven")
print(f"MRO: {' -> '.join(c.__name__ for c in InheritedOven.__mro__)}")
print(f"Toaster shares the whole chain: "
f"{' -> '.join(c.__name__ for c in InheritedToaster.__mro__)}")
print()
print("-- composition: behaviour is forwarded explicitly --")
print(ComposedOven("Deck oven", 3200).bake(220, 35))
print(ComposedToaster("Two-slice", 900).toast(260))
print(f"ComposedOven inherits from: "
f"{[c.__name__ for c in ComposedOven.__mro__]} <- nothing but object")
print()
print("-- the practical difference: swap a collaborator at runtime --")
swappable = ComposedOven("Deck oven", 3200)
swappable.element = FanAssistedElement(3200) # one assignment
print(swappable.bake(220, 35))
print(" ^ no class was edited, no subclass was written, nothing")
print(" inherited changed. That swap has no equivalent on the")
print(" inheritance side without defining a new subclass.")
if __name__ == "__main__":
main()
examples/05_context_manager.py (2003 bytes)
"""Step 5: a context manager of your own, and the guarantee it makes.
`with` is not built into the file object. It is a protocol: any object with
__enter__ and __exit__ can be used with `with`. __exit__ runs on the way out
of the block whether the body finished normally or raised — which is exactly
the guarantee you relied on with files on Day 64, now implemented by you.
__exit__ receives three arguments describing the exception, or three Nones if
there was none:
exc_type the exception class (or None)
exc_value the exception instance (or None)
traceback the traceback object (or None)
Its RETURN VALUE decides what happens next: False (or None) lets the
exception continue to the caller; True SWALLOWS it. Returning True by
accident is how exceptions silently vanish, so `Menu.__exit__` returns False
deliberately.
Run it: python3 examples/05_context_manager.py
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from kitchen import Dish, Menu # noqa: E402
def main():
print("-- normal run: the body succeeds --")
brunch = Menu("Brunch", [Dish("Waffles", 9)])
with brunch as service:
print(f" inside: {len(service)} dish, open = {service.open}")
print(f"after: {brunch.open}")
print()
print("-- the case that matters: the body raises --")
bad = Menu("Bad night", [Dish("Souffle", 45)])
try:
with bad:
raise ValueError("burnt the souffle")
except ValueError as err:
# [service closed] printed BEFORE this line: __exit__ ran on the way
# out, and returning False let the exception carry on to us here.
print(f"caught: {err} | open = {bad.open}")
print()
print("-- proof the cleanup is unconditional --")
print(f"cleanup ran after success: {brunch.open is False}")
print(f"cleanup ran after failure: {bad.open is False}")
print("exception still reached the caller: True")
if __name__ == "__main__":
main()
examples/06_abstract_base.py (2535 bytes)
"""Step 6: an abstract base class, and the duck typing that often replaces it.
An abstract base class cannot be instantiated, and it names the methods every
subclass must implement. Forget one and Python refuses to build the object at
the moment you asked for it, naming the class and the missing method — far
kinder than a mysterious failure hours into a run.
Note the shape of `Station.announce`: a CONCRETE method that calls an
ABSTRACT one. That is the template-method pattern, and it is exactly how
PyTorch's nn.Module works — the framework calls the `forward` you wrote.
Run it: python3 examples/06_abstract_base.py
"""
from abc import ABC, abstractmethod
from collections.abc import Iterable, Sized
class Station(ABC):
"""Every station has a name and must know how to prepare a dish."""
def __init__(self, name):
self.name = name
@abstractmethod
def prepare(self, dish):
"""Return a string describing how this station prepares the dish."""
def announce(self, dish): # concrete, shared, calls the abstract method
return f"{self.name}: {self.prepare(dish)}"
class GrillStation(Station):
def prepare(self, dish):
return f"grilling {dish} over charcoal"
class PastryStation(Station):
"""Deliberately incomplete: `prepare` was never implemented."""
def main():
print(GrillStation("Grill").announce("mackerel"))
try:
PastryStation("Pastry")
except TypeError as err:
print(f"TypeError: {err}")
print(" ^ refused at CONSTRUCTION, naming the class and the method.")
print(f"abstract methods still outstanding: "
f"{sorted(PastryStation.__abstractmethods__)}")
print()
print("-- but Python did not need the ABC to make this work --")
class Ticket:
"""Inherits from nothing but object, yet satisfies a protocol."""
def __init__(self, items):
self.items = items
def __iter__(self):
return iter(self.items)
ticket = Ticket(["mackerel", "gyoza"])
print(f"isinstance(t, Iterable): {isinstance(ticket, Iterable)}")
print(f"isinstance(t, Sized): {isinstance(ticket, Sized)}")
print(f'hasattr(t, "__iter__"): {hasattr(ticket, "__iter__")}')
try:
len(ticket)
except TypeError as err:
print(f"len(t) -> TypeError: {err}")
print(" ^ Iterable is True because Ticket defines __iter__ — the check")
print(" is for the METHOD, not for the family tree.")
if __name__ == "__main__":
main()
examples/kitchen.py (5629 bytes)
"""Reference implementation: a container class that plain Python accepts.
Step 4 of the Day 068 lab, "Protocols and Hierarchies".
`Menu` is an ordinary class holding `Dish` objects. It inherits from nothing
but `object`. Every capability below comes from implementing a dunder method
that the interpreter already knows how to call:
len(menu) -> Menu.__len__
menu[0], menu[1:] -> Menu.__getitem__
for dish in menu -> Menu.__iter__
"Ramen" in menu -> Menu.__contains__
a == b -> Menu.__eq__ (and __hash__, which it would break)
sorted(menus) -> Menu.__lt__ (via @total_ordering)
with menu: -> Menu.__enter__ / Menu.__exit__
The point of the demo at the bottom is that `sorted`, `max`, `list`, `set`,
`len`, `in` and the `for` statement are all UNMODIFIED — they were written
long before this file existed. They work because of the protocols, not
because of anything they know about `Menu`.
Run it directly: python3 examples/kitchen.py
"""
from functools import total_ordering
class Dish:
"""One dish: a name and how many minutes it takes to prepare."""
def __init__(self, name, minutes):
self.name = name
self.minutes = minutes
def __repr__(self):
# The unambiguous developer form, used inside containers too.
return f"Dish({self.name!r}, {self.minutes})"
def __eq__(self, other):
# Return NotImplemented (not False) for types we do not know, so the
# other operand still gets its chance to answer.
if not isinstance(other, Dish):
return NotImplemented
return (self.name, self.minutes) == (other.name, other.minutes)
def __hash__(self):
# Required: defining __eq__ sets __hash__ to None. Hash the same
# fields __eq__ compares, so equal dishes hash equal.
return hash((self.name, self.minutes))
@total_ordering
class Menu:
"""A collection of dishes that behaves like a built-in collection."""
def __init__(self, name, dishes=None):
self.name = name
self.dishes = list(dishes or [])
self.open = False
def __repr__(self):
return f"Menu({self.name!r}, {len(self.dishes)} dishes)"
# --- Exercise 1: sizing -------------------------------------------------
def __len__(self):
return len(self.dishes)
# --- Exercise 2: indexing ----------------------------------------------
def __getitem__(self, index):
# Delegating to the list means slices work for free: a slice object
# passed to list.__getitem__ returns a list.
return self.dishes[index]
# --- Exercise 3: iteration ---------------------------------------------
def __iter__(self):
return iter(self.dishes)
# --- Exercise 4: membership --------------------------------------------
def __contains__(self, item):
# Accept either a Dish or a plain name string.
name = item.name if isinstance(item, Dish) else item
return any(dish.name == name for dish in self.dishes)
def total_minutes(self):
return sum(dish.minutes for dish in self.dishes)
# --- Exercise 5: equality (and the hash it would otherwise break) ------
def __eq__(self, other):
if not isinstance(other, Menu):
return NotImplemented
return self.dishes == other.dishes
def __hash__(self):
return hash(tuple(self.dishes))
# --- Exercise 6: ordering ----------------------------------------------
def __lt__(self, other):
# @total_ordering derives <=, > and >= from __lt__ plus __eq__.
if not isinstance(other, Menu):
return NotImplemented
return self.total_minutes() < other.total_minutes()
# --- Context manager protocol (step 5 of the lab) ----------------------
def __enter__(self):
self.open = True
print(f"[service open] {self.name}")
return self
def __exit__(self, exc_type, exc_value, traceback):
# Runs on the way out whether the block succeeded or raised.
self.open = False
print(f"[service closed] {self.name}")
return False # False = do not swallow the exception
def build_menus():
"""The three menus the demo and the tests both use."""
lunch = Menu("Lunch", [Dish("Ramen", 12), Dish("Gyoza", 8), Dish("Salad", 4)])
dinner = Menu("Dinner", [Dish("Ramen", 12), Dish("Duck", 40)])
# An independent Menu holding equal dishes — a different object, equal value.
copy = Menu("Lunch copy", [Dish("Ramen", 12), Dish("Gyoza", 8), Dish("Salad", 4)])
return lunch, dinner, copy
def demo():
"""Prove that unmodified builtins work on a class written this afternoon."""
lunch, dinner, copy = build_menus()
print(f"len: {len(lunch)}")
print(f"index: {lunch[0]!r} | slice: {lunch[1:3]!r}")
print(f"iterate: {[dish.name for dish in lunch]}")
print(f"contains: {'Ramen' in lunch} | {'Duck' in lunch}")
print(f"equal: {lunch == copy} | {lunch == dinner}")
print(f"eq other type: {lunch == 'Lunch'}")
print(f"hash equal: {hash(lunch) == hash(copy)}")
print(f"lt: {dinner < lunch} | ge (total_ordering): {dinner >= lunch}")
print(f"sorted: {sorted([dinner, lunch])!r}")
print(f"max: {max([dinner, lunch], key=len)!r}")
print(f"longest dish: {max(lunch, key=lambda dish: dish.minutes)!r}")
print(f"sum minutes via comprehension: {sum(dish.minutes for dish in lunch)}")
print(f"set of menus: {len({lunch, copy, dinner})}")
print(f"list(): {len(list(dinner))}")
if __name__ == "__main__":
demo()
metadata.yml (846 bytes)
lesson_id: D068
day: 68
kind: python-program
languages: [python]
setup_commands:
- cd labs/sections/programming-with-python/day-068-inheritance-composition-and-dunder-methods
- python3 --version
run_commands:
- python3 examples/01_inheritance_super.py
- python3 examples/02_mro_diamond.py
- python3 examples/03_composition.py
- python3 starter/kitchen.py
- python3 examples/kitchen.py
- python3 examples/05_context_manager.py
- python3 examples/06_abstract_base.py
test_commands:
- bash tests/run_tests.sh
cleanup_commands:
- 'git checkout -- starter/kitchen.py starter/comparison.md # optional: reset your work'
requires_network: false
requires_api_key: false
estimated_minutes: 30
last_executed: '2026-07-19'
executed_on: 'macOS (Apple Silicon), Python 3.14.0, bash tests/run_tests.sh -> 36 checks, 0 failure(s), exit 0'
requirements/README.md (3320 bytes)
# Requirements
## Software
| Requirement | Version | Why |
| --- | --- | --- |
| `python3` | 3.8 or newer (tested on 3.14.0) | Runs every script in this lab |
| `bash` | any modern version | Runs `tests/run_tests.sh` |
Nothing is installed. There is no `pip install` step, no virtual environment
to create, no `requirements.txt` to resolve, and no account to register.
## Python modules used
All four are part of the standard library and ship with Python itself:
| Module | Used for |
| --- | --- |
| `functools` | `@total_ordering`, which derives `<=`, `>` and `>=` from `__lt__` |
| `abc` | `ABC` and `@abstractmethod` in step 6 |
| `collections.abc` | `Iterable` and `Sized`, to show `isinstance` checking methods rather than ancestry |
| `traceback`, `importlib.util`, `os`, `sys` | Printing a stable short traceback, loading numbered example files by path in the tests, and letting step 5 import the reference `kitchen.py` beside it |
## Why 3.8 or newer
`typing.Protocol` arrived in Python 3.8 (PEP 544), and the lesson's
`Alternatives` section uses it. Everything else in this lab — the data model,
`abc`, `functools.total_ordering`, `collections.abc` — has been available far
longer.
One detail is version-dependent and worth knowing, because the exact text
appears in the expected output. The message for instantiating an incomplete
abstract subclass was reworded between the two versions checked here. On
3.14.0, which produced the captured output, you get:
```text
TypeError: Can't instantiate abstract class PastryStation without an implementation for abstract method 'prepare'
```
On 3.11.14 the same failure reads:
```text
TypeError: Can't instantiate abstract class PastryStation with abstract method prepare
```
Both are the same refusal at the same moment, naming the same class and the
same method; only the sentence changed. The tests match on the class and
method names rather than the full sentence, so they pass on either. If your
Python prints the shorter wording, nothing is wrong.
## Network, credentials, and privileges
- **Network:** not used. Every script runs entirely offline.
- **API keys:** none. Nothing here talks to a service.
- **Privileges:** none. Never run any part of this lab with `sudo`.
- **Files written:** none by the lab itself. The test suite creates one
temporary directory with `mktemp -d` and removes it via an `EXIT` trap.
## Platform notes
macOS and Linux run every command exactly as written; the lab was authored
and executed on macOS with Apple Silicon.
On Windows, run the lab inside WSL. The Python scripts themselves are fully
portable and will run under native Windows Python unchanged, but
`tests/run_tests.sh` is a bash script and needs a POSIX shell. If you have no
WSL, you can still run each `python3 examples/...` command from PowerShell
and check the output against `expected-output/sample-run.txt` by eye — you
will simply lose the automated suite.
## Determinism
This lab has no timing measurements, no random numbers, no file sizes, and no
network calls. Every figure in `expected-output/` is reproducible byte for
byte on any machine running Python 3.12 or newer. Hash *values* are not
printed anywhere — only hash *equality* is compared — so Python's per-process
string hash randomisation does not affect any output.
starter/comparison.md (3186 bytes)
# Worksheet — your written answers
Fill this in as you work. Steps 2 and 3 of the lab ask for written answers,
not code, and writing them down is the part that makes the distinction stick.
Replace each `Your answer:` line with what you actually observed.
## Step 2 — predict the MRO before you run it
`class ToasterOven(Heater, Timer)`, where `Heater` and `Timer` both inherit
from `Appliance`. Write your prediction here **before** running
`python3 examples/02_mro_diamond.py`.
- My predicted MRO: `Your answer:`
- What the program actually printed: `Your answer:`
- Did they match? `Your answer:`
- If not, which C3 rule did I miss — local precedence (a class comes before
its own parents) or monotonicity (the order bases are listed is kept)?
`Your answer:`
`Heater.power_on` calls `super().power_on()`. Which class does that call
reach, and does `Heater` inherit from it?
- `Your answer:`
Why does `Appliance` appear only once in the MRO, and what would go wrong if
it appeared twice?
- `Your answer:`
## Step 3 — the same domain, two ways
Run `python3 examples/03_composition.py` and then answer.
Which version would you keep for a kitchen-appliance product that ships for
years, and why?
- `Your answer:`
Name one change that would be **easier** in the inheritance version:
- `Your answer:`
Name one change that would be **easier** in the composition version:
- `Your answer:`
Describe one concrete edit to `Appliance` or `HeatingAppliance` that would
break `InheritedOven` but would leave `ComposedOven` untouched. (This is the
fragile base class problem, stated in your own words.)
- `Your answer:`
Say the relationship out loud in both directions. Which of these sounds true
and which makes you wince?
- "An `Oven` **is a** `HeatingAppliance`" — `Your answer:`
- "An `Oven` **is a** `Timer`" — `Your answer:`
- "An `Oven` **has a** `Timer`" — `Your answer:`
## Step 4 — what the protocols bought you
After completing `starter/kitchen.py`, list the builtins that now work on
your `Menu` class without any of them being modified:
- `Your answer:`
You wrote only `__lt__` for ordering. Which comparison operators did
`@total_ordering` derive for you, and what else did it need in order to do
that?
- `Your answer:`
`len({lunch, copy, dinner})` prints `2`, not `3`. Explain why in one
sentence, naming both dunder methods responsible.
- `Your answer:`
## Step 5 — the context manager guarantee
In the run whose body raises `ValueError`, does `[service closed]` print
before or after the `caught:` line, and what does that ordering prove about
when `__exit__` runs?
- `Your answer:`
What would change if `Menu.__exit__` returned `True` instead of `False`?
- `Your answer:`
## Step 6 — enforcement versus duck typing
`PastryStation` raised `TypeError` at construction. Why is failing there
better than failing later, inside a call to `announce`?
- `Your answer:`
`Ticket` inherits from nothing but `object`, yet `isinstance(t, Iterable)` is
`True`. What is `isinstance` actually checking in that case?
- `Your answer:`
When would you reach for `abc.ABC`, and when would you rely on duck typing
instead?
- `Your answer:`
starter/kitchen.py (6607 bytes)
"""YOUR COPY — complete the six numbered exercises.
This is a working skeleton. `Dish` is finished for you and the plumbing runs.
Six methods on `Menu` raise NotImplementedError on purpose; each one is a
numbered exercise telling you exactly what to write. Replace the `raise` with
your implementation, one at a time, running the file after each.
python3 starter/kitchen.py
The demo at the bottom exercises `len()`, indexing, slicing, `for`, `in`,
`==`, `hash()`, `sorted()` and `max()`. None of those are modified in any
way — they are the builtins. When all six exercises are done, every one of
them will work on a class you wrote, and the output will match
`expected-output/sample-run.txt`.
Compare with `examples/kitchen.py` only after you have tried.
"""
from functools import total_ordering
class Dish:
"""Finished for you — study it, it is the pattern for exercise 5."""
def __init__(self, name, minutes):
self.name = name
self.minutes = minutes
def __repr__(self):
return f"Dish({self.name!r}, {self.minutes})"
def __eq__(self, other):
# Note the NotImplemented return for unknown types: it tells Python
# "ask the other operand", rather than answering False confidently.
if not isinstance(other, Dish):
return NotImplemented
return (self.name, self.minutes) == (other.name, other.minutes)
def __hash__(self):
# Defining __eq__ sets __hash__ to None, so it must be restored here.
return hash((self.name, self.minutes))
@total_ordering
class Menu:
"""A collection of dishes. Make plain Python accept it."""
def __init__(self, name, dishes=None):
self.name = name
self.dishes = list(dishes or [])
self.open = False
def __repr__(self):
return f"Menu({self.name!r}, {len(self.dishes)} dishes)"
def total_minutes(self):
"""Finished for you — used by exercise 6."""
return sum(dish.minutes for dish in self.dishes)
# --- Exercise 1 --------------------------------------------------------
# Make `len(menu)` work. Return how many dishes this menu holds.
# Hint: `self.dishes` is a list; call the builtin `len()` on it.
def __len__(self):
raise NotImplementedError("Exercise 1: return len(self.dishes)")
# --- Exercise 2 --------------------------------------------------------
# Make `menu[0]` and `menu[1:3]` work. Return the item at `index`.
# Hint: return `self.dishes[index]`. Because a list handles slice objects
# itself, delegating like this makes slicing work with no extra code.
def __getitem__(self, index):
raise NotImplementedError("Exercise 2: return self.dishes[index]")
# --- Exercise 3 --------------------------------------------------------
# Make `for dish in menu:` work. Return an ITERATOR over the dishes.
# Hint: `return iter(self.dishes)` — do not return the list itself, a
# list is iterable but is not an iterator.
def __iter__(self):
raise NotImplementedError("Exercise 3: return iter(self.dishes)")
# --- Exercise 4 --------------------------------------------------------
# Make `"Ramen" in menu` work, accepting either a Dish or a plain name.
# Hint: get the name with
# name = item.name if isinstance(item, Dish) else item
# then return True if any dish in self.dishes has that name. A generator
# expression inside the builtin `any()` reads well here.
def __contains__(self, item):
raise NotImplementedError("Exercise 4: any(dish.name == name for ...)")
# --- Exercise 5 --------------------------------------------------------
# Make `menu_a == menu_b` compare the dishes they hold. Two menus are
# equal when their `dishes` lists are equal (Dish.__eq__ does the rest).
# Return NotImplemented when `other` is not a Menu.
#
# THEN: because you defined __eq__, Python set __hash__ to None and your
# menus became unhashable. Restore it below by hashing the same data
# __eq__ compares — `hash(tuple(self.dishes))` works, because Dish is
# hashable and a tuple of hashables is hashable.
def __eq__(self, other):
raise NotImplementedError("Exercise 5a: compare self.dishes")
def __hash__(self):
raise NotImplementedError("Exercise 5b: return hash(tuple(self.dishes))")
# --- Exercise 6 --------------------------------------------------------
# Make `sorted(menus)` work by defining ONE comparison. A menu sorts
# before another when its total_minutes() is smaller. Return
# NotImplemented when `other` is not a Menu.
# The @total_ordering decorator above the class will then derive
# <=, > and >= from this method plus __eq__ — so you write one, get four.
def __lt__(self, other):
raise NotImplementedError("Exercise 6: compare total_minutes()")
# --- Finished for you: the context manager protocol (lab step 5) -------
def __enter__(self):
self.open = True
print(f"[service open] {self.name}")
return self
def __exit__(self, exc_type, exc_value, traceback):
self.open = False
print(f"[service closed] {self.name}")
return False # False = do not swallow the exception
def build_menus():
"""The three menus the demo and the tests both use."""
lunch = Menu("Lunch", [Dish("Ramen", 12), Dish("Gyoza", 8), Dish("Salad", 4)])
dinner = Menu("Dinner", [Dish("Ramen", 12), Dish("Duck", 40)])
copy = Menu("Lunch copy", [Dish("Ramen", 12), Dish("Gyoza", 8), Dish("Salad", 4)])
return lunch, dinner, copy
def demo():
"""Every line below is an UNMODIFIED builtin working on your class."""
lunch, dinner, copy = build_menus()
print(f"len: {len(lunch)}")
print(f"index: {lunch[0]!r} | slice: {lunch[1:3]!r}")
print(f"iterate: {[dish.name for dish in lunch]}")
print(f"contains: {'Ramen' in lunch} | {'Duck' in lunch}")
print(f"equal: {lunch == copy} | {lunch == dinner}")
print(f"eq other type: {lunch == 'Lunch'}")
print(f"hash equal: {hash(lunch) == hash(copy)}")
print(f"lt: {dinner < lunch} | ge (total_ordering): {dinner >= lunch}")
print(f"sorted: {sorted([dinner, lunch])!r}")
print(f"max: {max([dinner, lunch], key=len)!r}")
print(f"longest dish: {max(lunch, key=lambda dish: dish.minutes)!r}")
print(f"sum minutes via comprehension: {sum(dish.minutes for dish in lunch)}")
print(f"set of menus: {len({lunch, copy, dinner})}")
print(f"list(): {len(list(dinner))}")
if __name__ == "__main__":
demo()
tests/run_tests.sh (16483 bytes)
#!/usr/bin/env bash
# Tests for the Day 068 lab. Run from the lab directory:
# bash tests/run_tests.sh
#
# These checks exercise real behaviour, not file existence: a subclass that
# skips super().__init__() is constructed and the resulting AttributeError is
# caught in the method where it actually surfaces, a real diamond is built and
# its __mro__ compared against the C3 answer, an inconsistent hierarchy is
# rejected by the interpreter, a collaborator is swapped at runtime, and the
# container class is driven through len(), indexing, slicing, iteration,
# membership, equality, hashing and sorted() using nothing but builtins.
# Every check runs in a throwaway temporary directory that is removed
# afterwards. No network, no privileges, non-interactive. Exits 0 only if
# every check passes.
set -u
export PYTHONDONTWRITEBYTECODE=1
lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
failures=0
checks=0
scratch="$(mktemp -d -t day068-tests.XXXXXX)"
cleanup() { rm -rf "${scratch}"; }
trap cleanup EXIT
check() {
local label="$1" ok="$2"
checks=$((checks + 1))
if [ "${ok}" = "yes" ]; then
echo " ok: ${label}"
else
echo " FAIL: ${label}"
failures=$((failures + 1))
fi
}
# check_kitchen <label> <kitchen_dir> <python-body>
# Runs an assertion body against the kitchen module imported from kitchen_dir.
# A clean exit (every assert passed) is a pass.
check_kitchen() {
local label="$1" kitchen_dir="$2" body="$3"
local out
if out="$(PYTHONPATH="${kitchen_dir}" python3 -c "
from kitchen import Dish, Menu, build_menus
lunch, dinner, copy = build_menus()
${body}" 2>&1)"; then
check "${label}" "yes"
else
check "${label}" "no"
echo " (${out##*$'\n'})"
fi
}
# check_script <label> <script> <needle>
# Runs an example script and asserts it exits 0 and its output contains a
# literal string.
check_script() {
local label="$1" script="$2" needle="$3"
local out code
out="$(cd "${lab_dir}" && python3 "${script}" 2>&1)"
code=$?
if [ "${code}" -eq 0 ] && printf '%s' "${out}" | grep -qF "${needle}"; then
check "${label}" "yes"
else
check "${label}" "no"
echo " (exit ${code}; wanted \"${needle}\")"
fi
}
# --- 1. Inheritance and super() -----------------------------------------
echo "Testing inheritance, super(), and what breaks without it ..."
check_script "1. broken subclass raises AttributeError for the parent's attribute" \
"examples/01_inheritance_super.py" \
"AttributeError: 'BrokenOven' object has no attribute 'name'"
check_script "1b. the fixed subclass produces the extended description" \
"examples/01_inheritance_super.py" "Deck oven (3200W), 60L"
# The failure must appear in describe(), NOT in __init__ — that displacement
# is the actual lesson, so assert on the frame, not just the message.
if out="$(cd "${lab_dir}" && PYTHONPATH="examples" python3 -c "
import importlib.util, traceback
spec = importlib.util.spec_from_file_location('m', 'examples/01_inheritance_super.py')
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
oven = m.BrokenOven('Deck oven', 3200, 60)
assert oven.capacity_litres == 60, 'construction itself must succeed'
try:
oven.describe()
except AttributeError:
tb = traceback.format_exc()
assert 'in describe' in tb, 'failure must surface in describe()'
assert 'in __init__' not in tb, 'the constructor must NOT be in the traceback'
else:
raise SystemExit('describe() should have raised')
" 2>&1)"; then
check "1c. construction succeeds; the failure surfaces in describe(), not __init__" "yes"
else
check "1c. construction succeeds; the failure surfaces in describe(), not __init__" "no"
echo " (${out##*$'\n'})"
fi
if out="$(cd "${lab_dir}" && python3 -c "
import importlib.util
spec = importlib.util.spec_from_file_location('m', 'examples/01_inheritance_super.py')
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
fixed = m.FixedOven('Deck oven', 3200, 60)
assert fixed.name == 'Deck oven' and fixed.watts == 3200, 'parent setup missing'
assert fixed.describe() == 'Deck oven (3200W), 60L', fixed.describe()
assert [c.__name__ for c in m.FixedOven.__mro__] == ['FixedOven', 'Appliance', 'object']
" 2>&1)"; then
check "1d. super().__init__() restores name and watts and extends describe()" "yes"
else
check "1d. super().__init__() restores name and watts and extends describe()" "no"
echo " (${out##*$'\n'})"
fi
# --- 2. The MRO and the diamond -----------------------------------------
echo "Testing the diamond hierarchy and C3 linearization ..."
check_script "2. the diamond MRO is ToasterOven -> Heater -> Timer -> Appliance -> object" \
"examples/02_mro_diamond.py" \
"ToasterOven -> Heater -> Timer -> Appliance -> object"
check_script "2b. cooperative super() chains through all four classes" \
"examples/02_mro_diamond.py" \
"ToasterOven: ready -> Heater: elements warming -> Timer: clock started -> Appliance: power on"
if out="$(cd "${lab_dir}" && python3 -c "
import importlib.util
spec = importlib.util.spec_from_file_location('m', 'examples/02_mro_diamond.py')
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
mro = [c.__name__ for c in m.ToasterOven.__mro__]
assert mro == ['ToasterOven', 'Heater', 'Timer', 'Appliance', 'object'], mro
assert mro.count('Appliance') == 1, 'C3 must list the shared base exactly once'
# super() in Heater reaches Timer, which Heater does not inherit from.
assert m.Timer not in m.Heater.__bases__, 'Heater must NOT inherit from Timer'
assert mro[mro.index('Heater') + 1] == 'Timer', 'super() should land on Timer'
" 2>&1)"; then
check "2c. the shared base appears once, and Heater's super() lands on a non-parent" "yes"
else
check "2c. the shared base appears once, and Heater's super() lands on a non-parent" "no"
echo " (${out##*$'\n'})"
fi
if out="$(cd "${lab_dir}" && python3 -c "
import importlib.util
spec = importlib.util.spec_from_file_location('m', 'examples/02_mro_diamond.py')
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
try:
type('Impossible', (m.Appliance, m.Heater), {})
except TypeError as err:
# Python wraps this message differently across versions, so normalise
# whitespace before matching rather than depending on the line breaks.
message = ' '.join(str(err).split())
assert 'consistent method resolution order' in message, message
else:
raise SystemExit('an inconsistent hierarchy should have been rejected')
" 2>&1)"; then
check "2d. an inconsistent hierarchy is refused at class-definition time" "yes"
else
check "2d. an inconsistent hierarchy is refused at class-definition time" "no"
echo " (${out##*$'\n'})"
fi
# --- 3. Composition -------------------------------------------------------
echo "Testing the composition rewrite of the same domain ..."
check_script "3. the composed oven delegates to its element and its timer" \
"examples/03_composition.py" \
"Deck oven: heating to 220C at 3200W; timer set to 35 min"
if out="$(cd "${lab_dir}" && python3 -c "
import importlib.util
spec = importlib.util.spec_from_file_location('m', 'examples/03_composition.py')
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
# Both designs must produce the same heating string for the same inputs.
inherited = m.InheritedOven('Deck oven', 3200, 260, 60).heat(220)
composed = m.ComposedOven('Deck oven', 3200).element.heat(220)
assert inherited == composed == 'heating to 220C at 3200W', (inherited, composed)
# The composed classes must genuinely inherit nothing.
assert m.ComposedOven.__bases__ == (object,), m.ComposedOven.__bases__
assert m.ComposedToaster.__bases__ == (object,), m.ComposedToaster.__bases__
# has-a, not is-a: the collaborators are attributes.
oven = m.ComposedOven('Deck oven', 3200)
assert isinstance(oven.element, m.HeatingElement) and isinstance(oven.timer, m.Timer)
assert not isinstance(oven, m.HeatingElement), 'an Oven is not a HeatingElement'
" 2>&1)"; then
check "3b. both designs behave identically, but the composed one inherits nothing" "yes"
else
check "3b. both designs behave identically, but the composed one inherits nothing" "no"
echo " (${out##*$'\n'})"
fi
if out="$(cd "${lab_dir}" && python3 -c "
import importlib.util
spec = importlib.util.spec_from_file_location('m', 'examples/03_composition.py')
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
oven = m.ComposedOven('Deck oven', 3200)
before = oven.bake(220, 35)
oven.element = m.FanAssistedElement(3200) # swapped at RUNTIME
after = oven.bake(220, 35)
assert 'heating to' in before and 'fan-assisted to' in after, (before, after)
assert before != after, 'swapping the collaborator must change the behaviour'
" 2>&1)"; then
check "3c. a collaborator can be swapped at runtime with no class edited" "yes"
else
check "3c. a collaborator can be swapped at runtime with no class edited" "no"
echo " (${out##*$'\n'})"
fi
# --- 5. Context manager ---------------------------------------------------
echo "Testing the context manager's cleanup guarantee ..."
check_script "5. cleanup runs and the exception still reaches the caller" \
"examples/05_context_manager.py" "caught: burnt the souffle | open = False"
# --- 6. Abstract base class -----------------------------------------------
echo "Testing the abstract base class and duck typing ..."
# Match on the class name only: the rest of this sentence was reworded
# between Python versions (see requirements/README.md). The method name is
# asserted separately in 6c via __abstractmethods__.
check_script "6. an incomplete subclass is refused at construction, by name" \
"examples/06_abstract_base.py" \
"TypeError: Can't instantiate abstract class PastryStation"
check_script "6b. the complete subclass works through the template method" \
"examples/06_abstract_base.py" "Grill: grilling mackerel over charcoal"
if out="$(cd "${lab_dir}" && python3 -c "
import importlib.util
spec = importlib.util.spec_from_file_location('m', 'examples/06_abstract_base.py')
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
assert m.PastryStation.__abstractmethods__ == frozenset({'prepare'}), \
m.PastryStation.__abstractmethods__
try:
m.Station('any')
except TypeError:
pass
else:
raise SystemExit('the ABC itself must not be instantiable')
" 2>&1)"; then
check "6c. the ABC itself is not instantiable and names its outstanding method" "yes"
else
check "6c. the ABC itself is not instantiable and names its outstanding method" "no"
echo " (${out##*$'\n'})"
fi
# --- 4. The container class: the protocol checks -------------------------
run_kitchen_checks() {
local dir="$1"
echo "Testing the container class in ${dir} (builtins only, nothing patched) ..."
check_kitchen "4. len(menu) calls __len__" "${dir}" "
assert len(lunch) == 3, len(lunch)
assert len(dinner) == 2, len(dinner)
assert len(Menu('Empty')) == 0
"
check_kitchen "4b. menu[i] and menu[i:j] call __getitem__ (slicing comes free)" "${dir}" "
assert lunch[0] == Dish('Ramen', 12), lunch[0]
assert lunch[-1] == Dish('Salad', 4), lunch[-1]
assert lunch[1:3] == [Dish('Gyoza', 8), Dish('Salad', 4)], lunch[1:3]
try:
lunch[99]
except IndexError:
pass
else:
raise SystemExit('an out-of-range index should raise IndexError')
"
check_kitchen "4c. a for loop calls __iter__, and two loops both start over" "${dir}" "
names = [d.name for d in lunch]
assert names == ['Ramen', 'Gyoza', 'Salad'], names
assert [d.name for d in lunch] == names, 'a second loop must restart, not exhaust'
assert list(lunch) == lunch.dishes, 'list() must consume __iter__'
"
check_kitchen "4d. 'x in menu' calls __contains__ for both names and Dishes" "${dir}" "
assert ('Ramen' in lunch) is True
assert ('Duck' in lunch) is False
assert (Dish('Gyoza', 8) in lunch) is True
assert ('Duck' in dinner) is True
"
check_kitchen "4e. __eq__ compares contents and defers on unknown types" "${dir}" "
assert (lunch == copy) is True, 'equal dishes must compare equal'
assert lunch is not copy, 'they must be distinct objects'
assert (lunch == dinner) is False
assert (lunch == 'Lunch') is False, 'comparing to a str must be False, not an error'
assert Menu.__eq__(lunch, 'Lunch') is NotImplemented, \
'__eq__ must return NotImplemented for unknown types, not False'
"
check_kitchen "4f. __hash__ survives __eq__, so menus work in sets and dicts" "${dir}" "
assert Menu.__hash__ is not None, 'defining __eq__ without __hash__ breaks sets'
assert hash(lunch) == hash(copy), 'equal objects must hash equal'
assert len({lunch, copy, dinner}) == 2, 'the set must collapse the two equal menus'
assert {lunch: 'x'}[copy] == 'x', 'an equal menu must find the same dict entry'
"
check_kitchen "4g. sorted() and max() work using only __lt__ and @total_ordering" "${dir}" "
assert lunch.total_minutes() == 24 and dinner.total_minutes() == 52
assert sorted([dinner, lunch]) == [lunch, dinner], 'sorted must use __lt__'
assert (dinner < lunch) is False and (lunch < dinner) is True
# total_ordering derives these three from __lt__ plus __eq__:
assert (dinner >= lunch) is True
assert (dinner > lunch) is True
assert (lunch <= dinner) is True
assert max([dinner, lunch]) is dinner, 'max must use the ordering'
assert max([dinner, lunch], key=len) is lunch, 'key=len must use __len__'
"
check_kitchen "4h. iteration protocol feeds max(), sum() and comprehensions" "${dir}" "
assert max(lunch, key=lambda d: d.minutes) == Dish('Ramen', 12)
assert sum(d.minutes for d in lunch) == 24
assert sorted(d.name for d in lunch) == ['Gyoza', 'Ramen', 'Salad']
"
check_kitchen "4i. the context manager opens, closes, and closes again on failure" "${dir}" "
m = Menu('Service', [Dish('Waffles', 9)])
with m as service:
assert service is m, '__enter__ must return the object bound by as'
assert m.open is True
assert m.open is False, '__exit__ must run after a normal exit'
try:
with m:
raise ValueError('kitchen fire')
except ValueError as err:
assert str(err) == 'kitchen fire', 'returning False must not swallow it'
else:
raise SystemExit('__exit__ returned True and swallowed the exception')
assert m.open is False, '__exit__ must run after a raising exit too'
"
check_kitchen "4j. Dish equality and hashing behave as the pattern for Menu" "${dir}" "
assert Dish('Ramen', 12) == Dish('Ramen', 12)
assert Dish('Ramen', 12) != Dish('Ramen', 13)
assert hash(Dish('Ramen', 12)) == hash(Dish('Ramen', 12))
assert Dish.__eq__(Dish('Ramen', 12), 'Ramen') is NotImplemented
assert len({Dish('Ramen', 12), Dish('Ramen', 12)}) == 1
"
}
# --- Reference: always tested strictly ---
run_kitchen_checks "${lab_dir}/examples"
echo "Testing examples/kitchen.py end to end ..."
check_script "4k. the reference demo reports the collapsed set of menus" \
"examples/kitchen.py" "set of menus: 2"
# --- Learner starter ---
echo "Testing starter/kitchen.py ..."
starter_kitchen="${lab_dir}/starter/kitchen.py"
if python3 -c "compile(open('${starter_kitchen}').read(), '${starter_kitchen}', 'exec')" 2>/dev/null; then
check "starter kitchen.py is valid Python" "yes"
else
check "starter kitchen.py is valid Python" "no"
fi
if grep -q 'NotImplementedError' "${starter_kitchen}"; then
echo "Note: starter/kitchen.py still has unfinished exercises — testing structure only."
for name in __len__ __getitem__ __iter__ __contains__ __eq__ __hash__ __lt__ \
__enter__ __exit__; do
if grep -q "def ${name}" "${starter_kitchen}"; then
check "starter defines ${name}" "yes"
else
check "starter defines ${name}" "no"
fi
done
else
run_kitchen_checks "${lab_dir}/starter"
check_script "starter demo reports the collapsed set of menus" \
"starter/kitchen.py" "set of menus: 2"
# A __contains__ that secretly rebuilds a list is not what was asked for,
# but an __iter__ that returns the list itself is an outright bug: a list
# is iterable, not an iterator, so nested loops would misbehave.
if PYTHONPATH="${lab_dir}/starter" python3 -c "
from kitchen import Menu, Dish
m = Menu('x', [Dish('a', 1)])
it = iter(m)
assert iter(it) is it, '__iter__ must return an iterator, not the list'
" 2>/dev/null; then
check "starter __iter__ returns a real iterator" "yes"
else
check "starter __iter__ returns a real iterator" "no"
fi
fi
echo
echo "${checks} checks, ${failures} failure(s)."
[ "${failures}" -eq 0 ]
Troubleshooting
Troubleshooting
Symptom-by-symptom fixes for this lab. Every error message below is one you can produce deliberately, and most of them are worth producing once on purpose so you recognise them later.
AttributeError: 'X' object has no attribute 'name' — raised in a method, not in __init__
Cause. The subclass defined its own __init__ and never called
super().__init__(...). Defining __init__ in a subclass replaces the
parent's; it does not add to it. The object was constructed perfectly
happily — it is simply missing the attributes the parent's constructor would
have set, and the failure waits until something actually reads one.
Fix. Call the parent constructor, conventionally as the first line:
def __init__(self, name, watts, capacity_litres):
super().__init__(name, watts) # <- the missing line
self.capacity_litres = capacity_litres
Why it is confusing. The traceback names the method where the attribute was read, not the constructor that failed to set it. When you see this error, look at the constructor first, not at the line that raised.
TypeError: __init__() takes 3 positional arguments but 4 were given
Cause. You forwarded the child's whole argument list to the parent unchanged. The parent declares fewer parameters than the child does.
Fix. Pass only the arguments the parent actually declares, and keep the child-specific ones for the child:
def __init__(self, name, watts, max_celsius, capacity_litres):
super().__init__(name, watts, max_celsius) # not capacity_litres
self.capacity_litres = capacity_litres
TypeError: Cannot create a consistent method resolution order (MRO) for bases ...
Cause. You listed base classes in an order C3 linearization cannot
satisfy — almost always a parent listed before its own child, as in
class X(Appliance, Heater) where Heater already inherits from
Appliance. C3 requires that a class precede its parents, and you asked for
the opposite.
Fix. Reorder the bases so the more specific class comes first:
class X(Heater, Appliance) — or, better, ask whether you need both at all.
Note. Python raises this when the class is defined, not when an
instance is created. That is a feature: an impossible hierarchy never gets
built. examples/02_mro_diamond.py triggers it on purpose so you see it once.
TypeError: unhashable type: 'Menu' (or cannot use 'Menu' as a set element)
Cause. You defined __eq__. Python then set __hash__ to None,
because objects that compare equal must hash equal and the interpreter will
not guess which fields to use. Your class became unhashable the moment you
added equality, and the crash appears later, in a set or dict, far from
the class you edited.
Fix. Define __hash__ over the same fields __eq__ compares:
def __hash__(self):
return hash(tuple(self.dishes))
If the object is genuinely mutable and should not live in a set, leaving it unhashable is a legitimate choice — but make it a decision, not an accident.
TypeError: '<' not supported between instances of 'Menu' and 'Menu'
Cause. sorted(), min() and max() order things with <, which means
__lt__. You have not defined it.
Fix. Define __lt__, or pass a key= function to sort by something else
entirely (sorted(menus, key=len) needs no __lt__ on Menu at all,
because it compares the integers len returns).
>= raises TypeError even though __lt__ works
Cause. @total_ordering is missing, or it is applied but __eq__ is not
defined. The decorator derives <=, > and >= from __lt__ plus
__eq__ — it needs both.
Fix. Decorate the class with @total_ordering and make sure __eq__ is
defined alongside __lt__.
RecursionError: maximum recursion depth exceeded inside __contains__
Cause. Your __contains__ used the in operator on the object itself —
return item in self — which calls __contains__ again, forever.
Fix. Search the underlying data, not the wrapper:
return any(dish.name == name for dish in self.dishes) # not "in self"
The same trap catches __len__ written as return len(self) and __iter__
written as return iter(self).
A second for loop over the same object finds nothing
Cause. __iter__ returned an iterator that had already been consumed,
or returned self on a class that also defines __next__ with exhausted
state. An iterator can only be walked once.
Fix. Return a fresh iterator every time. Delegating to the underlying
list does this correctly, because iter(list) builds a new iterator on each
call:
def __iter__(self):
return iter(self.dishes)
TypeError: iter() returned non-iterator of type 'list'
Cause. __iter__ returned self.dishes — the list itself — rather than
an iterator over it. A list is iterable but is not an iterator: it has no
__next__.
Fix. Wrap it: return iter(self.dishes). The test suite checks this
specifically, with assert iter(it) is it.
Comparing to an unrelated type gives a confidently wrong answer
Cause. Your __eq__ returns False for types it does not recognise.
That looks harmless, but it stops Python from asking the other operand,
which may well know how to do the comparison.
Fix. Return NotImplemented instead. Python then tries the reflected
call on the right-hand operand, and only falls back to identity comparison
(giving False) if that also declines:
def __eq__(self, other):
if not isinstance(other, Menu):
return NotImplemented # not False
return self.dishes == other.dishes
TypeError: Can't instantiate abstract class ... when you did not expect it
Cause. The class has an outstanding @abstractmethod. This is the ABC
working as designed.
Fix. Implement the method it names. To see exactly what is outstanding:
print(PastryStation.__abstractmethods__)
If the wording of the message on your machine differs from the capture in
expected-output/, that is a Python version difference and nothing is wrong
— see requirements/README.md.
An abstract method is not enforced
Cause. The class does not actually inherit from ABC (or its metaclass
is not ABCMeta). @abstractmethod on a plain class is only documentation —
it enforces nothing on its own.
Fix. class Station(ABC):, importing ABC from abc.
ModuleNotFoundError: No module named 'kitchen' from step 5
Cause. You ran examples/05_context_manager.py from a directory other
than the lab root, or copied it somewhere away from examples/kitchen.py.
Fix. Run every command from the lab directory, as the README shows:
cd labs/sections/programming-with-python/day-068-inheritance-composition-and-dunder-methods
python3 examples/05_context_manager.py
bash: tests/run_tests.sh: No such file or directory
Cause. You are not in the lab directory.
Fix. cd to it first. The suite itself is location-independent once
started — it resolves the lab root from its own path — but the command has to
find it.
The test suite says "testing structure only"
Not a problem. That message means starter/kitchen.py still contains
NotImplementedError, so the suite checked that the six methods exist rather
than that they behave. Complete the exercises and the full protocol checks
run against your version too, taking the suite from 36 checks to 39.
__exit__ runs but the exception disappears
Cause. __exit__ returned a truthy value. Returning True tells Python
the exception has been handled, and it is swallowed silently.
Fix. Return False (or nothing at all, since None is falsy) unless you
genuinely intend to suppress the error. Menu.__exit__ returns False
explicitly to make the choice visible.
Security notes
Security notes
This lab reads no files, writes no files, opens no sockets, and needs no credentials or privileges. Its security lessons are therefore not about this code — they are about the habits it teaches, because inheritance and dunder methods both quietly widen what your program will execute and what it will disclose.
Dunder methods run implicitly, where you will never see them in the source
This is the single most important idea on this page. When you write
__repr__, __eq__, __hash__ or __getitem__, you are installing code
that the interpreter calls on your behalf, in places that do not mention your
class at all:
| Dunder | Runs implicitly when |
|---|---|
__repr__ |
An exception traceback is formatted, an object is logged, or it appears inside any container being printed |
__str__ |
Anything is printed or interpolated into an f-string |
__eq__ |
Any in, ==, .index(), .remove(), or dict/set lookup that collides |
__hash__ |
Every single set or dict operation touching the object |
__getitem__ |
Any subscript, including ones written by library code |
Three rules follow, and they are real controls rather than style advice:
- Keep dunders free of I/O. No file access, no network calls, no
database queries, no logging. A
__repr__that hits the network turns every traceback into a request, and a__hash__that reads a file turns every dict insert into disk activity. - Keep dunders free of side effects. Code that runs at unpredictable times must not mutate state. A debugger printing your object, or a test framework rendering an assertion failure, must not change your program.
- Keep dunders cheap.
__hash__runs on every set and dict operation and__eq__on every collision. Hash a small tuple of identifying fields — never a large nested structure.
__repr__ is a disclosure surface
Because __repr__ is what appears in logs, error messages, crash reports and
tracebacks, whatever it prints will end up in places you did not choose:
aggregated logging systems, error-tracking services, terminal scrollback,
support tickets, and screenshots.
class Customer:
def __repr__(self):
return f"Customer({self.email!r}, card={self.card_number!r})" # leak
Every unhandled exception anywhere near that object now writes an email address and a card number into a log line. Print identifiers, not payloads:
class Customer:
def __repr__(self):
return f"Customer(id={self.id!r})" # safe
The same applies to __str__. Treat both as public output, because they are.
__eq__, __hash__, and the security of lookups
Two rules that matter beyond correctness:
- Equal objects must hash equal. If they do not, a
dictorsetwill fail to find entries it genuinely contains — a lookup that silently misses is an access-control bug waiting to happen when the structure is a cache, an allowlist, or a session store. - Hashing mutable state is a trap. If a field used by
__hash__changes after the object is placed in a set, the object is lost: it lands in the wrong bucket and can be neither found nor removed. Hash only fields that do not change for the object's lifetime.
Note that Python randomises string hashes per process by default, which is a
deliberate defence against hash-collision denial-of-service attacks. Never
persist a hash() value to a file or database and expect it to match on the
next run — use hashlib for anything that must be stable or cryptographic.
hash() is for in-memory lookup, nothing more.
Inheritance widens what you trust
Subclassing a third-party class inherits every method it has — including methods you have never read, and methods a future version may add or change. Your object's behaviour is then partly defined by code outside your control, and a library upgrade can alter it without a single line of your source changing. This is the fragile base class problem viewed as a supply-chain concern.
Composition trusts a much smaller surface: only the methods you actually call on the object you hold. When you are integrating a dependency you did not write, holding it as an attribute is the more conservative choice, and it also makes the dependency easy to replace or to fake in a test.
Abstract base classes are a guard rail, not a security boundary
abc.ABC catches an honest mistake — a subclass that forgot to implement
something — at construction time, which is genuinely valuable. It is not an
enforcement mechanism against hostile code: nothing stops a caller from
registering a virtual subclass, overriding __subclasshook__, or simply
ignoring the hierarchy. Use ABCs to help correct code stay correct, and use
real validation for untrusted input.
The same caution applies to isinstance. Duck typing means an object can
satisfy a protocol without your knowledge, which is exactly the flexibility
you want from collections.abc — and exactly why an isinstance check is
not proof that an object came from where you think it did.
Things this lab deliberately never does
- No
eval()orexec()on data. If you extend this lab to load menus from a file, parse them withjson, never by evaluating the text. Turning data into code is how data becomes an attacker. - No
pickle. Unpickling runs arbitrary code by design, via the__reduce__dunder. Never unpickle data you did not create yourself. It is worth knowing that__reduce__is a dunder too — the data model is powerful in both directions. - No
sudo, ever. Nothing in this lab needs elevated privileges. If a command in a Python tutorial asks forsudo, stop and find out why first. - No network.
requires_networkisfalseinmetadata.yml, and that is checked, not merely claimed.
Cleanup and blast radius
The test suite creates exactly one temporary directory with mktemp -d and
removes it through a trap ... EXIT, so it cleans up even if a check fails
partway through. It writes nowhere else, and neither does any example script.
The only files you should ever find modified after this lab are the two you
were invited to edit, starter/kitchen.py and starter/comparison.md.