Programming with Python › Files, Errors, and Object-Oriented Python › Day 67
Hands-on lab — Day 67: Classes and Objects
- ← Back to the Day 67 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-067-classes-and-objects/
Commands
Setup
cd labs/sections/programming-with-python/day-067-classes-and-objects
python3 --version Run
python3 examples/account_dict.py
python3 examples/account.py
python3 examples/compare.py
python3 examples/shared_bug.py
python3 examples/closure_object.py
python3 examples/machinery.py
python3 starter/account.py Test
bash tests/run_tests.sh File tree
examples/account_dict.py examples/account.py examples/accounts.csv examples/closure_object.py examples/compare.py examples/machinery.py examples/shared_bug.py expected-output/FIELDS.md expected-output/sample-run.txt expected-output/test-run.txt metadata.yml README.md requirements/README.md security.md starter/account.py starter/inspection-notes.md tests/run_tests.sh troubleshooting.md
Lab README
Day 067 lab — Building Your First Classes
Lesson
- Lesson title: Classes and Objects
- Day number: 67 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-067-classes-and-objects
- 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-067-classes-and-objectswhen the site is running.
Purpose
Day 67 is where a dict stops being enough. You have been modelling things
as a dict plus a pile of functions that all take that dict as their first
argument, and that works right up until someone reaches in and writes
account["balance"] = -500, walking past every rule those functions were
written to enforce.
In this lab you take exactly that program — examples/account_dict.py, an
account as a dict with make_account, deposit, withdraw, and
describe — and convert it into a class, then prove with a test that the
behaviour did not change. Along the way you reproduce the classic shared
mutable class attribute bug and fix it, add a __repr__ and see the
before-and-after, add a @property that rejects an invalid value, add a
@classmethod alternative constructor that builds an instance from a CSV
row (straight from Day 65), and finally open the hood with vars(),
type(), and __dict__ to see that a class really is a dict of state plus
a dict of functions with syntax on top.
Learning objectives
- Convert a dict-plus-functions program into a class, and demonstrate with a test that the behaviour is identical.
- Explain what
selfis, who supplies it, and howobj.method(x)becomesClass.method(obj, x). - Distinguish an instance attribute from a class attribute, reproduce the
shared mutable class attribute bug, and fix it in
__init__. - Write a
__repr__that makes an object useful in the REPL, a traceback, and a debugger. - Use
@propertyto validate an attribute on assignment, and catch the exception it raises. - Use
@classmethodas an alternative constructor and@staticmethodfor a rule that needs neither the instance nor the class. - Inspect the machinery with
type(),vars(), and__dict__and say where state lives and where behaviour lives.
Prerequisites
- The Day 67 lesson (read it first — it walks this exact class end to end).
- Day 65: CSV as text, which is what
from_csv_rowparses. - Day 66: exceptions —
raise,try/except, and choosingValueError. - Days 53, 57, 58: dictionaries in depth, functions, and closures (the
from-scratch object system in
examples/closure_object.pyuses closures). - A text editor and a terminal. Nothing beyond this course is assumed.
Supported operating systems
- macOS — fully supported (authored and executed on macOS, Apple Silicon, Python 3.14.0).
- Linux — fully supported (any distribution with Python 3 and bash).
- Windows — use WSL and follow the Linux path, or substitute
pythonforpython3if that is how Python is exposed. Everything here is pure standard-library Python and behaves identically everywhere.
Hardware requirements
Any computer that runs Python 3. The scripts build a handful of small objects in memory and print a few dozen lines; no special memory, disk, or GPU is needed.
Required software
python3(3.8 or newer; tested on 3.14.0).bashfor the test runner (preinstalled on macOS and Linux).- Standard library only — no packages to install. See
requirements/README.md.
Free and open-source options
Everything here is free and open source: Python, bash, and the standard library. Classes are part of the language itself, so there is nothing to buy, install, or sign up for. No account, API key, or network access is needed at any point.
Installation
None beyond Python itself. Move into this directory and you are ready:
cd labs/sections/programming-with-python/day-067-classes-and-objects
python3 --version # confirm Python 3.8+ is available
File structure
day-067-classes-and-objects/
├── README.md ← you are here
├── metadata.yml ← machine-readable lab metadata
├── starter/
│ ├── account.py ← YOUR working file (5 numbered exercises)
│ └── inspection-notes.md ← exercise 6: record what the machinery shows
├── examples/
│ ├── account_dict.py ← the "before": a dict + loose functions
│ ├── account.py ← the "after": the reference Account class
│ ├── shared_bug.py ← the shared mutable class attribute, and its fix
│ ├── compare.py ← proves the two versions behave identically
│ ├── closure_object.py ← an object built from dicts + closures, no `class`
│ ├── machinery.py ← type(), vars(), __dict__, bound methods, mangling
│ └── accounts.csv ← four rows for the classmethod constructor
├── tests/
│ └── run_tests.sh ← behaviour checks for the reference and your starter
├── expected-output/
│ ├── sample-run.txt ← real captured run of every example script
│ ├── test-run.txt ← real captured run of the test suite
│ └── FIELDS.md ← required behaviour on every platform
├── requirements/
│ └── README.md ← dependency statement (Python 3 only)
├── troubleshooting.md
└── security.md
How to run
From this directory, in order:
## 1. See the "before": a dict plus functions. Watch the last line —
## a direct edit walks straight past every rule the functions enforce.
python3 examples/account_dict.py
## 2. See the "after": the same program as a class, refusing that same edit.
python3 examples/account.py
## 3. Prove the conversion changed nothing: same moves, same results,
## then a whole CSV file loaded through the classmethod constructor.
python3 examples/compare.py
## 4. Reproduce the classic bug: one list on the class, shared by everybody.
python3 examples/shared_bug.py
## 5. Build an object from scratch with dicts and closures, and compare it
## to the same thing written with `class`.
python3 examples/closure_object.py
## 6. Open the hood: type(), vars(), __dict__, bound methods, name mangling.
python3 examples/machinery.py
## 7. Your task: work through the five numbered exercises in
## starter/account.py, running it as you go.
python3 starter/account.py
## 8. Exercise 6: run the one-liners in starter/inspection-notes.md against
## your own class and fill in what you saw.
## 9. Check your work.
bash tests/run_tests.sh
What the commands do
python3 examples/account_dict.py— runs the dict version through a deposit, a withdrawal, a refused overdraft, and then a directaccount["balance"] = -500, which succeeds. That success is the problem this lab solves.python3 examples/account.py— runs the class version through the same session. The overdraft is refused for the same reason as before, but the negative-balance assignment is now refused too, by the@propertysetter.python3 examples/compare.py— drives both versions through an identical list of moves and compares the resulting descriptions and histories, which is what "the refactor changed nothing" actually means. It then readsexamples/accounts.csvand builds four accounts withAccount.from_csv_row.python3 examples/shared_bug.py— creates twoBuggyAccountinstances whosehistoryis a class-level list, shows one instance's deposit appearing in the other's history, and then shows the fixed class where the list is created inside__init__.python3 examples/closure_object.py— builds an account with nothing but a dict and closures (noclasskeyword anywhere), then the same account withclass, and prints where each keeps its state and its functions.python3 examples/machinery.py— printstype()of an instance and of the class,vars()of two instances so you can see their separate state, the class dictionary so you can see where the methods live, the bound method's__self__and__func__, an unsugaredAccount.deposit(ada, 10)call, and how__pinbecomes_Card__pin.python3 starter/account.py— runs your work in progress. Until you finish the exercises it raisesNotImplementedError, which is deliberate.bash tests/run_tests.sh— checks real behaviour: separate per-instance state, a defended invariant, a rejecting property, an exact__repr__string, a working classmethod and staticmethod, the bound-method mechanism, and an assertion that the class and dict versions produce identical results. Exits 0 only if every check passes.
Expected output
See expected-output/sample-run.txt — a
real captured run of every example script. The heart of it is these two
sessions, side by side:
$ python3 examples/account_dict.py
ada: 120.00 (2 entries)
rejected: insufficient funds
after a direct edit: ada: -500.00 (2 entries)
$ python3 examples/account.py
ada: 120.00 (2 entries)
rejected: insufficient funds
rejected: balance cannot be negative (got -500.00)
after the refused edit: ada: 120.00 (2 entries)
repr: Account(owner='ada', balance=120.00)
and this, from the shared-attribute bug:
$ python3 examples/shared_bug.py
--- buggy: one list shared by every instance ---
ada.history: [('ada', 10), ('bob', 20)]
bob.history: [('ada', 10), ('bob', 20)]
same list object? True
Everything is deterministic, so your output will match — with one honest
exception documented in
expected-output/FIELDS.md: an object printed
without a custom __repr__ shows a memory address that differs on every
run. No captured output and no test in this lab depends on one.
Validation steps
python3 examples/account_dict.pyends withafter a direct edit: ada: -500.00 (2 entries).python3 examples/account.pyends withrepr: Account(owner='ada', balance=120.00)and never shows a negative balance.python3 examples/compare.pyprintsdescriptions identical: True,histories identical: True, andloaded 4 accounts, total 2225.75 USD.python3 examples/shared_bug.pyprintssame list object? Truefor the buggy class andsame list object? Falsefor the fixed one.python3 examples/closure_object.pyprintsoutputs identical: True.python3 examples/machinery.pyprintsada.deposit.__self__ is ada: Trueandhasattr(card, '_Card__pin'): True.- Your
starter/account.pyruns withoutNotImplementedErrorand prints the same description, repr, rejection, and CSV-built account as the reference. starter/inspection-notes.mdhas every "What I saw" cell filled in from your own runs.bash tests/run_tests.shends with0 failure(s)and exits0.
Tests
bash tests/run_tests.sh
Expected final line while the starter is unfinished: 28 checks, 0 failure(s). Once all five starter exercises are complete, the suite stops
checking structure and runs your class through the same twelve behaviour
checks as the reference, giving 34 checks, 0 failure(s). The command exits
0 on success and non-zero on any failure, so it can run unattended. A full
captured run is in
expected-output/test-run.txt.
Cleanup
Nothing to clean up: the scripts write no files and create no temporary directories. To reset your work, restore the starter from git:
git checkout -- starter/account.py starter/inspection-notes.md
Troubleshooting
See troubleshooting.md for the full list: python vs
python3, the __init__ argument-count error, AttributeError for an
attribute you never assigned, the infinite recursion you get when a property
setter assigns to its own name, a setter that never runs, one account's
deposit landing in another's history, ModuleNotFoundError for account,
the default <... object at 0x...> repr, and the classmethod signature.
Security notes
See security.md. Short version: no network, no privileges,
no installs, no files written. The lab's security-relevant idea is that a
class is an enforcement point — the rules that keep data valid live with the
data, in one auditable place — plus two cautions: Python's underscore
conventions are conventions, not walls (name mangling turns __pin into a
visible _Card__pin), and __repr__ output ends up in logs and tracebacks,
so keep secrets out of it.
Extension exercises
- Add a
transfer(self, other, amount)method that moves money from this account to anotherAccount. Decide first what the invariant is when two objects are involved, and make sure a failed transfer leaves both balances unchanged. - Add a read-only
@propertycalledmovementsthat returnslen(self.history)and has no setter, then confirm that assigning to it raisesAttributeError. That is how you express "computed, and not yours to set." - Add a class attribute
accounts_openedand increment it in__init__(the reference class inexamples/account.pydoes this — read how). Then explain in one sentence whyself.accounts_opened += 1would silently create a per-instance attribute instead of updating the shared counter. - Write
to_csv_row(self)as the mirror offrom_csv_row, and prove the round trip:Account.from_csv_row(a.to_csv_row())produces an account with the same owner and balance. - Rewrite
examples/closure_object.py'smake_accountso the returned dict also carries ahistoryaccessor, then write down which parts of the class machinery you had to hand-build — and which ones you got for free fromclass.
Navigation
- Previous day: Day 66 — Exceptions and Error Handling Strategy
(
labs/sections/programming-with-python/day-066-exceptions-and-error-handling-strategy/). - Next day: Day 68 — Inheritance, Composition, and Dunder Methods
(
labs/sections/programming-with-python/day-068-inheritance-composition-and-dunder-methods/), which takes the class you built here and teaches it to cooperate with other classes and with Python's own operators. - Week 10 project: the Expense Tracker — CSV import and export, category
classes, and monthly summary reports. The
from_csv_rowclassmethod and the validating property you write today are exactly the pieces that project is built from.
Expected output
FIELDS.md
# Expected output — Day 067 lab
These are real captured runs from the authoring machine (macOS on Apple
Silicon, Python 3.14.0, bash 3.2, 2026-07-19). Everything in this lab is
deterministic: given the same input it produces the same output and the same
exit code on every platform Python 3 runs on.
## A note about memory addresses
An object printed without a custom `__repr__` shows something like
`<account.Account object at 0x104f3a2d0>`. That hex number is the object's
address in memory, and it is **different on every run and every machine**.
Nothing in this lab's captured output or its tests depends on it:
- every class you build here defines its own `__repr__`, so the tested
representation is `Account(owner='ada', balance=120.00)`;
- where identity matters, the scripts print comparisons (`a is b`,
`id(a) == id(b)`) rather than the addresses themselves.
If you print a default repr yourself while exploring, expect the hex digits
to differ from any example — that is correct, not a failure.
## Files
- `sample-run.txt` — every example script driven in order:
`account_dict.py` (the dict version, including the direct edit that breaks
its rules), `account.py` (the class version refusing the same edit),
`shared_bug.py` (the shared mutable class attribute and its fix),
`compare.py` (the two versions proved equivalent, plus a CSV file loaded
through the classmethod), `closure_object.py` (an object built by hand
from dicts and closures next to the same thing written with `class`), and
`machinery.py` (`type`, `vars`, `__dict__`, bound methods, name mangling).
- `test-run.txt` — a full run of `bash tests/run_tests.sh` with the starter
still unfinished: 28 checks, 0 failures, exit 0. The lab directory path is
shown as `<lab>`; on your machine it is your real path.
## Required behaviour on every platform
The reference class (`examples/account.py`) must satisfy exactly:
| Call | Result |
| --- | --- |
| `Account('ada', 100).deposit(50)` | `150.0` |
| `Account('ada', 100).describe()` after `deposit(50)`, `withdraw(30)` | `'ada: 120.00 (2 entries)'` |
| `repr(Account('ada', 120))` | `"Account(owner='ada', balance=120.00)"` |
| `Account('ada', 100).withdraw(1000)` | raises `ValueError` mentioning `insufficient funds` |
| `acct.balance = -500` | raises `ValueError` mentioning `negative`; the balance is unchanged |
| `Account.from_csv_row('cleo, 250.00')` | an `Account` with `owner == 'cleo'` and `balance == 250.0` |
| `Account.from_csv_row('nonsense')` | raises `ValueError` |
| `Account.is_valid_amount(5)` / `(-5)` / `('5')` | `True` / `False` / `False` |
| `a.history is b.history` for two accounts | `False` (each instance owns its list) |
| `a.deposit.__self__ is a` | `True` |
| `a.deposit.__func__ is Account.deposit` | `True` |
| `'currency' in vars(a)` | `False` — it is found on the class |
| `isinstance(Account.__dict__['balance'], property)` | `True` |
The buggy demonstration class (`examples/shared_bug.py`) must satisfy:
| Call | Result |
| --- | --- |
| `BuggyAccount('ada').history is BuggyAccount('bob').history` | `True` — the bug |
| `FixedAccount('cleo').history is FixedAccount('dev').history` | `False` — the fix |
The equivalence proof (`examples/compare.py`) must print
`descriptions identical: True`, `histories identical: True`, and
`loaded 4 accounts, total 2225.75 USD`.
## Platform notes
- The only visible difference between platforms is the shell prompt (`$`)
shown before each command in `sample-run.txt`; the programs' own output is
identical.
- Dictionary display order in `vars(...)` output follows insertion order,
which is guaranteed by the language since Python 3.7, so those lines match
everywhere.
- Balances are formatted with `:.2f`, which rounds to two decimals
identically across platforms.
- The test runner prints its own lab directory path in the first line; that
path is your machine's, and is shown as `<lab>` in the captured file.
sample-run.txt
$ python3 examples/account_dict.py
ada: 120.00 (2 entries)
rejected: insufficient funds
after a direct edit: ada: -500.00 (2 entries)
$ python3 examples/account.py
ada: 120.00 (2 entries)
rejected: insufficient funds
rejected: balance cannot be negative (got -500.00)
after the refused edit: ada: 120.00 (2 entries)
repr: Account(owner='ada', balance=120.00)
$ python3 examples/shared_bug.py
--- buggy: one list shared by every instance ---
ada.history: [('ada', 10), ('bob', 20)]
bob.history: [('ada', 10), ('bob', 20)]
same list object? True
it lives on the class: [('ada', 10), ('bob', 20)]
instance __dict__ of ada: {'owner': 'ada'}
--- fixed: one list per instance ---
cleo.history: [('cleo', 10)]
dev.history: [('dev', 20)]
same list object? False
instance __dict__ of cleo: {'owner': 'cleo', 'history': [('cleo', 10)]}
$ python3 examples/compare.py
--- same behaviour, two designs ---
dict version : ada: 130.00 (4 entries)
class version: ada: 130.00 (4 entries)
descriptions identical: True
histories identical: True
--- what changed is what happens when a rule is broken ---
dict version accepted a negative balance: -500
class version refused it: balance cannot be negative (got -500.00)
class balance unchanged: 10.0
--- loading a CSV file through the alternative constructor ---
Account(owner='ada', balance=100.00)
Account(owner='bob', balance=250.50)
Account(owner='cleo', balance=0.00)
Account(owner='dev', balance=1875.25)
loaded 4 accounts, total 2225.75 USD
$ python3 examples/closure_object.py
--- hand-built object (dicts + closures, no class keyword) ---
describe(): ada: 120.00 (2 entries)
method table keys: ['deposit', 'describe', 'state', 'withdraw']
hidden state: {'owner': 'ada', 'balance': 120.0, 'history': [('deposit', 50.0), ('withdraw', 30.0)]}
each call needs its dict: hand['deposit'](50)
--- the same thing with `class` ---
describe(): ada: 120.00 (2 entries)
method table keys: ['deposit', 'describe', 'withdraw']
hidden state: {'owner': 'ada', 'balance': 120.0, 'history': [('deposit', 50.0), ('withdraw', 30.0)]}
each call finds its object: sugar.deposit(50)
--- same answers, different amount of sugar ---
outputs identical: True
hand-built keeps state in a captured dict; the class keeps it in the instance __dict__
hand-built copies every function per account; the class stores one function per method, shared
two hand-built accounts share no functions: False
two class instances share one function: True
$ python3 examples/machinery.py
--- two instances, one class ---
type(ada): Account
type(Account): type
ada is bob: False
id(ada) == id(bob): False
type(ada) is type(bob): True
ada == bob (no __eq__ defined, so identity is used): False
--- state lives on the instance ---
vars(ada): {'owner': 'ada', '_balance': 150.0, 'history': [('deposit', 50.0)]}
vars(bob): {'owner': 'bob', '_balance': 100.0, 'history': []}
ada.__dict__ is vars(ada): True
'history' in ada.__dict__: True
'deposit' in ada.__dict__: False
--- behaviour lives on the class ---
class attributes and methods:
accounts_opened -> int
balance -> property
currency -> str
deposit -> function
describe -> function
from_csv_row -> classmethod
is_valid_amount -> staticmethod
withdraw -> function
Account.currency: USD
ada.currency (found on the class): USD
'currency' in ada.__dict__: False
--- a method call is a function call with self supplied ---
type(Account.deposit): function
type(ada.deposit): method
ada.deposit.__self__ is ada: True
ada.deposit.__func__ is Account.deposit: True
Account.deposit(ada, 10) moved the balance 150.00 -> 160.00
--- name mangling and the property ---
'_balance' in ada.__dict__: True
type(Account.balance): property
Account.__dict__['is_valid_amount'] type: staticmethod
Account.__dict__['from_csv_row'] type: classmethod
vars(card): {'_holder': 'ada', '_Card__pin': '1234'}
card.check('1234'): True
hasattr(card, '__pin'): False
hasattr(card, '_Card__pin'): True
--- the alternative constructor ---
from_csv_row -> Account(owner='cleo', balance=250.00)
built by the same class: True
test-run.txt
Testing the Account class in <lab>/examples ...
ok: two instances hold separate state
ok: history is per instance, not shared on the class
ok: deposit and withdraw maintain the balance
ok: the invariant is defended: overdraft is refused
ok: the property rejects a negative balance
ok: balance is a property on the class, backed by _balance
ok: __repr__ is custom and shows the state
ok: from_csv_row is a classmethod that builds an instance
ok: is_valid_amount is a staticmethod
ok: a method call is Class.method(instance, ...)
ok: currency is a class attribute seen by every instance
ok: the class reproduces the dict version's behaviour exactly
Testing the supporting example scripts ...
ok: account_dict.py runs and shows the unguarded edit
ok: account.py runs and refuses the same edit
ok: shared_bug.py reproduces the shared list
ok: shared_bug.py shows the per-instance fix
ok: compare.py proves the two versions agree
ok: compare.py loads accounts.csv through the classmethod
ok: closure_object.py builds an object without the class keyword
ok: machinery.py shows the bound-method mechanism
ok: machinery.py shows name mangling
Testing starter/account.py ...
ok: starter is valid Python
Note: starter/account.py still has unfinished exercises — testing structure only.
ok: starter defines class Account
ok: starter defines __init__
ok: starter defines deposit
ok: starter defines withdraw
ok: starter defines __repr__
ok: starter defines from_csv_row
28 checks, 0 failure(s).
Source files
examples/account_dict.py (2295 bytes)
"""The 'before' version: a dict of state plus a pile of loose functions.
This is how you have modelled a bank account with everything you knew up to
Day 66. It works, but notice the friction:
* every function must be handed the same dict as its first argument;
* the rules ("balance is never negative", "every movement is recorded")
live in the functions, not with the data, so nothing stops a caller from
writing account["balance"] = -500 and breaking them;
* the state and the behaviour are only related by convention and by you
remembering which functions belong to which dict.
Day 67 turns exactly this file into a class. Run it directly to see it work:
python3 examples/account_dict.py
"""
def make_account(owner, balance=0.0):
"""owner, starting balance -> a new account dict with its own history."""
if float(balance) < 0:
raise ValueError("balance cannot be negative")
return {"owner": owner, "balance": float(balance), "history": []}
def deposit(account, amount):
"""Add a positive amount, record it, and return the new balance."""
if amount <= 0:
raise ValueError("deposit amount must be positive")
account["balance"] += float(amount)
account["history"].append(("deposit", float(amount)))
return account["balance"]
def withdraw(account, amount):
"""Remove a positive amount that the balance can cover."""
if amount <= 0:
raise ValueError("withdrawal amount must be positive")
if amount > account["balance"]:
raise ValueError("insufficient funds")
account["balance"] -= float(amount)
account["history"].append(("withdraw", float(amount)))
return account["balance"]
def describe(account):
"""A one-line human summary of the account dict."""
return (
f"{account['owner']}: {account['balance']:.2f} "
f"({len(account['history'])} entries)"
)
def main():
ada = make_account("ada", 100)
deposit(ada, 50)
withdraw(ada, 30)
print(describe(ada))
try:
withdraw(ada, 1000)
except ValueError as err:
print(f"rejected: {err}")
# Nothing protects the invariant when a caller edits the dict directly.
ada["balance"] = -500
print("after a direct edit:", describe(ada))
if __name__ == "__main__":
main()
examples/account.py (4032 bytes)
"""The 'after' version: one class binding the state to the behaviour.
Same program as account_dict.py, expressed as a class. The state (owner,
balance, history) and the rules that keep it valid now live in one named
thing, and the balance can no longer be set to a negative number by anyone,
inside the class or outside it.
Run it directly to see the same session as the dict version:
python3 examples/account.py
"""
class Account:
"""A bank account: an owner, a balance, and a history of movements.
Invariant (the rule this class exists to keep): the balance is never
negative, and every movement is recorded in *this instance's own*
history list.
"""
currency = "USD" # class attribute: one value shared by every account
accounts_opened = 0 # class attribute: a counter, rebound (not mutated)
def __init__(self, owner, balance=0.0):
"""Initialize an account that Python has already created for us."""
self.owner = owner
self.balance = balance # goes through the property setter below
self.history = [] # a FRESH list for every instance
Account.accounts_opened += 1
# --- the property: a computed, validated attribute -------------------
@property
def balance(self):
"""The current balance. Assigning a negative value is rejected."""
return self._balance
@balance.setter
def balance(self, value):
amount = float(value)
if amount < 0:
raise ValueError(f"balance cannot be negative (got {amount:.2f})")
self._balance = amount
# --- a staticmethod: a rule that needs neither instance nor class ----
@staticmethod
def is_valid_amount(amount):
"""True when amount is a positive number. Takes no self and no cls."""
return isinstance(amount, (int, float)) and amount > 0
# --- a classmethod: an alternative constructor -----------------------
@classmethod
def from_csv_row(cls, row):
"""Build an account from one CSV line: 'ada,120.50' (ties to Day 65)."""
parts = [field.strip() for field in row.split(",")]
if len(parts) != 2:
raise ValueError(f"expected 'owner,balance', got {row!r}")
owner, balance = parts
return cls(owner, float(balance))
# --- ordinary methods: behaviour that maintains the invariant --------
def deposit(self, amount):
"""Add a positive amount, record it, and return the new balance."""
if not self.is_valid_amount(amount):
raise ValueError("deposit amount must be positive")
self.balance = self.balance + amount
self.history.append(("deposit", float(amount)))
return self.balance
def withdraw(self, amount):
"""Remove a positive amount the balance can cover."""
if not self.is_valid_amount(amount):
raise ValueError("withdrawal amount must be positive")
if amount > self.balance:
raise ValueError("insufficient funds")
self.balance = self.balance - amount
self.history.append(("withdraw", float(amount)))
return self.balance
def describe(self):
"""A one-line human summary, the same text the dict version prints."""
return f"{self.owner}: {self.balance:.2f} ({len(self.history)} entries)"
def __repr__(self):
"""What you see in the debugger and the REPL. Every class deserves one."""
return f"Account(owner={self.owner!r}, balance={self.balance:.2f})"
def main():
ada = Account("ada", 100)
ada.deposit(50)
ada.withdraw(30)
print(ada.describe())
try:
ada.withdraw(1000)
except ValueError as err:
print(f"rejected: {err}")
# The direct edit that broke the dict version is refused here.
try:
ada.balance = -500
except ValueError as err:
print(f"rejected: {err}")
print("after the refused edit:", ada.describe())
print("repr:", repr(ada))
if __name__ == "__main__":
main()
examples/accounts.csv (44 bytes)
ada,100.00
bob,250.50
cleo,0.00
dev,1875.25
examples/closure_object.py (3976 bytes)
"""Build a minimal object system from scratch, with dicts and closures.
A class is not a new kind of thing in the language; it is state plus
functions-that-share-that-state, with syntax on top. Here is the same
account built twice: once by hand (a dict of closures over a captured
variable, using only Day 58 knowledge) and once with `class`.
Run it:
python3 examples/closure_object.py
"""
def make_account(owner, balance=0.0):
"""Return a dict of closures that all share one hidden `state` dict.
This IS an object: `state` is the instance's private attribute storage,
and the returned dict is the method table. There is no `class` keyword
anywhere, and no `self` -- the closure captures the state instead.
"""
state = {"owner": owner, "balance": float(balance), "history": []}
def deposit(amount):
if amount <= 0:
raise ValueError("deposit amount must be positive")
state["balance"] += float(amount)
state["history"].append(("deposit", float(amount)))
return state["balance"]
def withdraw(amount):
if amount <= 0:
raise ValueError("withdrawal amount must be positive")
if amount > state["balance"]:
raise ValueError("insufficient funds")
state["balance"] -= float(amount)
state["history"].append(("withdraw", float(amount)))
return state["balance"]
def describe():
return f"{state['owner']}: {state['balance']:.2f} ({len(state['history'])} entries)"
return {"deposit": deposit, "withdraw": withdraw, "describe": describe,
"state": state}
class ClassAccount:
"""The same thing, with the language doing the wiring for you."""
def __init__(self, owner, balance=0.0):
self.owner = owner
self.balance = float(balance)
self.history = []
def deposit(self, amount):
if amount <= 0:
raise ValueError("deposit amount must be positive")
self.balance += float(amount)
self.history.append(("deposit", float(amount)))
return self.balance
def withdraw(self, amount):
if amount <= 0:
raise ValueError("withdrawal amount must be positive")
if amount > self.balance:
raise ValueError("insufficient funds")
self.balance -= float(amount)
self.history.append(("withdraw", float(amount)))
return self.balance
def describe(self):
return f"{self.owner}: {self.balance:.2f} ({len(self.history)} entries)"
def main():
print("--- hand-built object (dicts + closures, no class keyword) ---")
hand = make_account("ada", 100)
hand["deposit"](50)
hand["withdraw"](30)
print("describe():", hand["describe"]())
print("method table keys:", sorted(hand))
print("hidden state:", hand["state"])
print("each call needs its dict: hand['deposit'](50)")
print()
print("--- the same thing with `class` ---")
sugar = ClassAccount("ada", 100)
sugar.deposit(50)
sugar.withdraw(30)
print("describe():", sugar.describe())
print("method table keys:",
sorted(n for n in vars(ClassAccount) if not n.startswith("__")))
print("hidden state:", vars(sugar))
print("each call finds its object: sugar.deposit(50)")
print()
print("--- same answers, different amount of sugar ---")
print("outputs identical:", hand["describe"]() == sugar.describe())
print("hand-built keeps state in a captured dict; the class keeps it in "
"the instance __dict__")
print("hand-built copies every function per account; the class stores "
"one function per method, shared")
print("two hand-built accounts share no functions:",
make_account('x')['deposit'] is make_account('y')['deposit'])
print("two class instances share one function:",
ClassAccount('x').deposit.__func__ is ClassAccount('y').deposit.__func__)
if __name__ == "__main__":
main()
examples/compare.py (2466 bytes)
"""Prove the class version behaves exactly like the dict version.
Refactoring is only safe when you can show the behaviour did not change.
This script drives account_dict.py and account.py through the same script of
moves and compares what each produced -- then loads a whole CSV file through
the classmethod constructor. Run it:
python3 examples/compare.py
"""
import os
import account_dict as old
from account import Account
MOVES = [("deposit", 50), ("withdraw", 30), ("deposit", 12.5), ("withdraw", 2.5)]
HERE = os.path.dirname(os.path.abspath(__file__))
def run_dict_version():
"""Drive the dict + functions version and return (description, history)."""
acct = old.make_account("ada", 100)
for move, amount in MOVES:
if move == "deposit":
old.deposit(acct, amount)
else:
old.withdraw(acct, amount)
return old.describe(acct), acct["history"]
def run_class_version():
"""Drive the class version through the identical script of moves."""
acct = Account("ada", 100)
for move, amount in MOVES:
getattr(acct, move)(amount)
return acct.describe(), acct.history
def main():
dict_text, dict_history = run_dict_version()
class_text, class_history = run_class_version()
print("--- same behaviour, two designs ---")
print("dict version :", dict_text)
print("class version:", class_text)
print("descriptions identical:", dict_text == class_text)
print("histories identical: ", dict_history == class_history)
print()
print("--- what changed is what happens when a rule is broken ---")
broken = old.make_account("bob", 10)
broken["balance"] = -500
print("dict version accepted a negative balance:", broken["balance"])
guarded = Account("bob", 10)
try:
guarded.balance = -500
except ValueError as err:
print("class version refused it:", err)
print("class balance unchanged:", guarded.balance)
print()
print("--- loading a CSV file through the alternative constructor ---")
path = os.path.join(HERE, "accounts.csv")
with open(path, "r", encoding="utf-8") as handle:
accounts = [Account.from_csv_row(line) for line in handle if line.strip()]
for acct in accounts:
print(" ", repr(acct))
total = sum(acct.balance for acct in accounts)
print(f"loaded {len(accounts)} accounts, total {total:.2f} {Account.currency}")
if __name__ == "__main__":
main()
examples/machinery.py (3622 bytes)
"""Open the hood: type(), vars(), __dict__, dir(), and bound methods.
Nothing here is magic. A class is an object holding functions; an instance
is an object holding its own attribute dict; attribute lookup checks the
instance first and the class second. Run it:
python3 examples/machinery.py
Note: this script never prints id() values or a default repr, because those
contain a memory address that changes on every run. It prints comparisons
instead, so the output is identical every time.
"""
from account import Account
class Card:
"""A tiny class showing the two encapsulation conventions side by side."""
def __init__(self, holder, pin):
self._holder = holder # single underscore: "internal, please leave alone"
self.__pin = pin # double underscore: renamed by the compiler
def check(self, guess):
return guess == self.__pin
def main():
ada = Account("ada", 100)
bob = Account("bob", 100)
print("--- two instances, one class ---")
print("type(ada):", type(ada).__name__)
print("type(Account):", type(Account).__name__)
print("ada is bob:", ada is bob)
print("id(ada) == id(bob):", id(ada) == id(bob))
print("type(ada) is type(bob):", type(ada) is type(bob))
print("ada == bob (no __eq__ defined, so identity is used):", ada == bob)
print()
print("--- state lives on the instance ---")
ada.deposit(50)
print("vars(ada):", vars(ada))
print("vars(bob):", vars(bob))
print("ada.__dict__ is vars(ada):", ada.__dict__ is vars(ada))
print("'history' in ada.__dict__:", "history" in ada.__dict__)
print("'deposit' in ada.__dict__:", "deposit" in ada.__dict__)
print()
print("--- behaviour lives on the class ---")
print("class attributes and methods:")
for name in sorted(vars(Account)):
if not name.startswith("__"):
print(" ", name, "->", type(vars(Account)[name]).__name__)
print("Account.currency:", Account.currency)
print("ada.currency (found on the class):", ada.currency)
print("'currency' in ada.__dict__:", "currency" in ada.__dict__)
print()
print("--- a method call is a function call with self supplied ---")
print("type(Account.deposit):", type(Account.deposit).__name__)
print("type(ada.deposit):", type(ada.deposit).__name__)
print("ada.deposit.__self__ is ada:", ada.deposit.__self__ is ada)
print("ada.deposit.__func__ is Account.deposit:",
ada.deposit.__func__ is Account.deposit)
before = ada.balance
Account.deposit(ada, 10) # the unsugared form
print(f"Account.deposit(ada, 10) moved the balance {before:.2f} -> {ada.balance:.2f}")
print()
print("--- name mangling and the property ---")
print("'_balance' in ada.__dict__:", "_balance" in ada.__dict__)
print("type(Account.balance):", type(Account.balance).__name__)
print("Account.__dict__['is_valid_amount'] type:",
type(Account.__dict__["is_valid_amount"]).__name__)
print("Account.__dict__['from_csv_row'] type:",
type(Account.__dict__["from_csv_row"]).__name__)
card = Card("ada", "1234")
print("vars(card):", vars(card))
print("card.check('1234'):", card.check("1234"))
print("hasattr(card, '__pin'):", hasattr(card, "__pin"))
print("hasattr(card, '_Card__pin'):", hasattr(card, "_Card__pin"))
print()
print("--- the alternative constructor ---")
cleo = Account.from_csv_row("cleo, 250.00")
print("from_csv_row ->", repr(cleo))
print("built by the same class:", type(cleo) is Account)
if __name__ == "__main__":
main()
examples/shared_bug.py (1813 bytes)
"""The classic mutable-class-attribute bug, reproduced and then fixed.
A list written at class level belongs to the CLASS, not to any instance, so
every instance mutates the same list. Run it:
python3 examples/shared_bug.py
"""
class BuggyAccount:
"""BUG ON PURPOSE: history is created once, at class-definition time."""
history = [] # one list, shared by every instance ever created
def __init__(self, owner):
self.owner = owner
def deposit(self, amount):
# self.history finds no instance attribute, so it falls back to the
# CLASS attribute -- and .append() mutates that one shared list.
self.history.append((self.owner, amount))
class FixedAccount:
"""FIXED: the list is created inside __init__, once per instance."""
def __init__(self, owner):
self.owner = owner
self.history = [] # a fresh list, bound to THIS instance
def deposit(self, amount):
self.history.append((self.owner, amount))
def main():
print("--- buggy: one list shared by every instance ---")
ada = BuggyAccount("ada")
bob = BuggyAccount("bob")
ada.deposit(10)
bob.deposit(20)
print("ada.history:", ada.history)
print("bob.history:", bob.history)
print("same list object?", ada.history is bob.history)
print("it lives on the class:", BuggyAccount.history)
print("instance __dict__ of ada:", vars(ada))
print()
print("--- fixed: one list per instance ---")
cleo = FixedAccount("cleo")
dev = FixedAccount("dev")
cleo.deposit(10)
dev.deposit(20)
print("cleo.history:", cleo.history)
print("dev.history:", dev.history)
print("same list object?", cleo.history is dev.history)
print("instance __dict__ of cleo:", vars(cleo))
if __name__ == "__main__":
main()
metadata.yml (873 bytes)
lesson_id: D067
day: 67
kind: coding
languages: [python, bash]
setup_commands:
- cd labs/sections/programming-with-python/day-067-classes-and-objects
- python3 --version
run_commands:
- python3 examples/account_dict.py
- python3 examples/account.py
- python3 examples/compare.py
- python3 examples/shared_bug.py
- python3 examples/closure_object.py
- python3 examples/machinery.py
- python3 starter/account.py
test_commands:
- bash tests/run_tests.sh
cleanup_commands:
- 'git checkout -- starter/account.py starter/inspection-notes.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 3.2 — bash tests/run_tests.sh -> 28 checks, 0 failure(s), exit 0 (34 checks, 0 failure(s) with the starter exercises completed)'
requirements/README.md (1023 bytes)
# Dependencies — Day 067 lab
**Python 3 only. No third-party packages.**
- `python3` (3.8 or newer; tested on 3.14.0). You installed this on Day 43.
- `bash` for the test runner (preinstalled on macOS and Linux).
- Standard library only — in fact only `os` (in `examples/compare.py`, to
find `accounts.csv` next to the script). The classes themselves import
nothing at all.
There is deliberately no `requirements.txt` and no virtual environment step.
Classes are part of the Python language, not a package: `class`, `self`,
`@property`, `@staticmethod`, `@classmethod`, `vars()`, and `type()` are all
built in, which is why every framework you meet later can assume them.
Check your Python is present and new enough:
```bash
python3 --version
```
If that prints `Python 3.8` or higher, you are ready. Windows users: run the
commands inside WSL, or use `python` in place of `python3` if that is how
Python is exposed on your system. The code is pure standard-library Python
and behaves identically everywhere.
starter/account.py (7349 bytes)
"""YOUR WORKING FILE — Day 67: turn a dict + functions into a class.
The 'before' version you are converting is `examples/account_dict.py`: a
dict holding owner/balance/history plus loose functions that all take that
dict as their first argument. Your job is to bind that state and that
behaviour into one named thing.
Work through the five numbered exercises below in order. Each one names the
exact command to run when you finish it. When every exercise is done, no
`NotImplementedError` remains and the test suite holds your class to the
same standard as the reference.
Run this file at any time to see how far you have got:
python3 starter/account.py
Run the full suite when you think you are finished:
bash tests/run_tests.sh
"""
class Account:
"""A bank account: an owner, a balance, and a history of movements.
Invariant (the rule this class exists to keep): the balance is never
negative, and every movement is recorded in this instance's own history.
"""
currency = "USD" # class attribute: one value shared by every account
# ---------------------------------------------------------------
# EXERCISE 2 (do this one AFTER exercise 1)
#
# The line below is a deliberate bug: a list written at class level is
# created ONCE, at class-definition time, and every instance shares it.
# First see the bug for yourself:
#
# python3 examples/shared_bug.py
#
# Then prove it in your own class:
#
# python3 -c "import sys; sys.path.insert(0, 'starter'); from account import Account; a = Account('ada', 10); b = Account('bob', 10); a.deposit(5); print(b.history)"
#
# If b.history is not empty, ada's deposit landed in bob's history.
# FIX: delete the line below and create `self.history = []` inside
# __init__ instead, so each instance gets a fresh list.
# ---------------------------------------------------------------
history = []
def __init__(self, owner, balance=0.0):
"""EXERCISE 1 — initialize the object Python has already created.
Set three attributes on `self`:
* self.owner -> the owner argument
* self.balance -> the balance argument (this goes through the
property setter you write in exercise 4; until
then it just stores the value)
* self.history -> a NEW empty list (this is also the fix for
exercise 2)
Then run: python3 starter/account.py
"""
raise NotImplementedError("exercise 1: set owner, balance, and history on self")
def deposit(self, amount):
"""EXERCISE 1 (continued) — add a positive amount and record it.
Mirror examples/account_dict.py's deposit(): reject amounts that are
not positive with ValueError("deposit amount must be positive"), add
the amount to self.balance, append ("deposit", float(amount)) to
self.history, and return the new balance.
"""
raise NotImplementedError("exercise 1: implement deposit")
def withdraw(self, amount):
"""EXERCISE 1 (continued) — remove an amount the balance can cover.
Reject a non-positive amount with
ValueError("withdrawal amount must be positive"), reject an amount
larger than the balance with ValueError("insufficient funds"),
otherwise subtract it, append ("withdraw", float(amount)) to
self.history, and return the new balance.
"""
raise NotImplementedError("exercise 1: implement withdraw")
def describe(self):
"""A one-line summary — provided, so you can compare with the dict version.
This must print exactly what examples/account_dict.py's describe()
prints for the same moves. That is how you prove the conversion did
not change the behaviour:
python3 examples/compare.py
"""
return f"{self.owner}: {self.balance:.2f} ({len(self.history)} entries)"
def __repr__(self):
"""EXERCISE 3 — give the class a useful debugging representation.
Return the string Account(owner='ada', balance=120.00) for an
account owned by 'ada' holding 120. Use !r on the owner so the
quotes appear, and :.2f on the balance.
Before and after, compare what the REPL shows:
python3 -c "import sys; sys.path.insert(0, 'starter'); from account import Account; print(repr(Account('ada', 120)))"
Without a __repr__ you get a default like <account.Account object at
0x...>, whose hex number is a memory address that changes every run
and tells you nothing about the account.
"""
raise NotImplementedError("exercise 3: return a useful repr string")
# ---------------------------------------------------------------
# EXERCISE 4 — a property that validates.
#
# Turn `balance` into a computed attribute backed by self._balance:
#
# @property
# def balance(self):
# return self._balance
#
# @balance.setter
# def balance(self, value):
# amount = float(value)
# if amount < 0:
# raise ValueError(f"balance cannot be negative (got {amount:.2f})")
# self._balance = amount
#
# Write those two blocks here (delete this comment when you do), then
# confirm a bad value is refused and the exception is catchable:
#
# python3 -c "import sys; sys.path.insert(0, 'starter'); from account import Account; a = Account('ada', 10)
# try:
# a.balance = -500
# except ValueError as err:
# print('rejected:', err)
# print('balance still', a.balance)"
# ---------------------------------------------------------------
@staticmethod
def is_valid_amount(amount):
"""Provided: a rule that needs neither the instance nor the class."""
return isinstance(amount, (int, float)) and amount > 0
@classmethod
def from_csv_row(cls, row):
"""EXERCISE 5 — an alternative constructor (ties to Day 65).
Turn one CSV line, 'ada, 120.50', into an Account:
* split the row on ',' and strip whitespace from each field;
* if there are not exactly two fields, raise
ValueError(f"expected 'owner,balance', got {row!r}");
* otherwise return cls(owner, float(balance)).
Use `cls(...)`, not `Account(...)` — that is what makes it an
alternative constructor rather than a hard-coded factory.
Then load a whole file with it:
python3 -c "import sys; sys.path.insert(0, 'starter'); from account import Account; print([Account.from_csv_row(line) for line in open('examples/accounts.csv') if line.strip()])"
"""
raise NotImplementedError("exercise 5: build an instance from a CSV row")
def main():
"""A short session you can run as you go: python3 starter/account.py"""
ada = Account("ada", 100)
ada.deposit(50)
ada.withdraw(30)
print(ada.describe())
print("repr:", repr(ada))
try:
ada.balance = -500
except ValueError as err:
print("rejected:", err)
print("from_csv_row:", repr(Account.from_csv_row("cleo, 250.00")))
if __name__ == "__main__":
main()
starter/inspection-notes.md (3063 bytes)
# Exercise 6 — inspect the machinery and write down what you see
A class is not magic. It is an object that holds functions; an instance is
an object that holds its own attribute dictionary; and attribute lookup
checks the instance first, then the class. This exercise makes you *look* at
that instead of taking it on faith.
Run the reference inspector first, from the lab directory:
```bash
python3 examples/machinery.py
```
Then run each command below against **your own** class and fill in the
"What I saw" column with the literal output. Every command is one line; copy
it whole.
## 6.1 — Two instances are two different objects
```bash
python3 -c "import sys; sys.path.insert(0, 'starter'); from account import Account; a = Account('ada', 100); b = Account('bob', 100); print(type(a).__name__, a is b, id(a) == id(b), type(a) is type(b))"
```
| Question | What I saw |
| --- | --- |
| What does `type(a).__name__` print? | _fill in_ |
| Is `a is b` true or false, and why? | _fill in_ |
| Is `type(a) is type(b)` true, and what does that tell you? | _fill in_ |
## 6.2 — State lives on the instance
```bash
python3 -c "import sys; sys.path.insert(0, 'starter'); from account import Account; a = Account('ada', 100); b = Account('bob', 100); a.deposit(50); print(vars(a)); print(vars(b))"
```
| Question | What I saw |
| --- | --- |
| Which keys are in `vars(a)`? | _fill in_ |
| Do the two instances hold different values for the same keys? | _fill in_ |
| Is `deposit` one of the keys? Where does it live instead? | _fill in_ |
## 6.3 — Behaviour lives on the class
```bash
python3 -c "import sys; sys.path.insert(0, 'starter'); from account import Account; print(sorted(n for n in vars(Account) if not n.startswith('__')))"
```
| Question | What I saw |
| --- | --- |
| Which names does the class dictionary hold? | _fill in_ |
| Which of them are functions, and which are plain values? | _fill in_ |
| `Account.currency` is not in `vars(a)` — so how does `a.currency` work? | _fill in_ |
## 6.4 — A method call is a function call with `self` supplied
```bash
python3 -c "import sys; sys.path.insert(0, 'starter'); from account import Account; a = Account('ada', 100); print(type(Account.deposit).__name__, type(a.deposit).__name__, a.deposit.__self__ is a, a.deposit.__func__ is Account.deposit)"
```
| Question | What I saw |
| --- | --- |
| What is `type(Account.deposit)`? What is `type(a.deposit)`? | _fill in_ |
| What is `a.deposit.__self__`? | _fill in_ |
| Write out what `a.deposit(50)` becomes in unsugared form. | _fill in_ |
## 6.5 — Write it in your own words
Answer in two or three sentences each, from what you saw above:
1. Where does an instance's state live, and where does its behaviour live?
2. What exactly does `self` refer to, and who passes it?
3. Why did the class version refuse `a.balance = -500` when the dict
version happily accepted `account["balance"] = -500`?
4. Name one thing in this lab that convinced you a class is "a dict of
state plus a dict of functions, with syntax on top."
tests/run_tests.sh (7846 bytes)
#!/usr/bin/env bash
# Tests for the Day 067 lab. Run from the lab directory:
# bash tests/run_tests.sh
#
# These checks exercise real behaviour, not file existence: a class is
# built, its invariant is attacked, its property rejects a bad value, its
# __repr__ is compared to an exact string, and its classmethod builds an
# instance from a CSV row. The dict version and the class version are then
# driven through the same moves and their results compared, which is what
# "the refactor changed nothing" actually means.
#
# Nothing here prints a default object repr, because that contains a memory
# address which differs on every run; the suite compares custom reprs and
# boolean facts instead, so the output is deterministic.
#
# No network, no pip, no sudo, 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
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_py <label> <import_dir> <python-body>
# Runs an assertion body with `account` importable from import_dir.
# A clean exit (every assert holds) is a pass.
check_py() {
local label="$1" import_dir="$2" body="$3"
if PYTHONPATH="${import_dir}:${lab_dir}/examples" python3 -c "
import sys
sys.path.insert(0, '${import_dir}')
from account import Account
${body}
" 2>/dev/null; then
check "${label}" "yes"
else
check "${label}" "no"
fi
}
# check_script <label> <script> <needle>
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}; output: ${out})"
fi
}
run_class_checks() {
local import_dir="$1"
echo "Testing the Account class in ${import_dir} ..."
check_py "two instances hold separate state" "${import_dir}" \
"a = Account('ada', 100)
b = Account('bob', 100)
a.deposit(50)
assert a is not b
assert type(a) is type(b)
assert a.balance == 150.0 and b.balance == 100.0"
check_py "history is per instance, not shared on the class" "${import_dir}" \
"a = Account('ada', 100)
b = Account('bob', 100)
a.deposit(50)
assert a.history is not b.history
assert b.history == [], f'shared list bug: {b.history}'
assert a.history == [('deposit', 50.0)]"
check_py "deposit and withdraw maintain the balance" "${import_dir}" \
"a = Account('ada', 100)
assert a.deposit(50) == 150.0
assert a.withdraw(30) == 120.0
assert a.describe() == 'ada: 120.00 (2 entries)'"
check_py "the invariant is defended: overdraft is refused" "${import_dir}" \
"a = Account('ada', 100)
try:
a.withdraw(1000)
raise SystemExit(1)
except ValueError as err:
assert 'insufficient funds' in str(err)
assert a.balance == 100.0"
check_py "the property rejects a negative balance" "${import_dir}" \
"a = Account('ada', 100)
try:
a.balance = -500
raise SystemExit(1)
except ValueError as err:
assert 'negative' in str(err)
assert a.balance == 100.0"
check_py "balance is a property on the class, backed by _balance" "${import_dir}" \
"a = Account('ada', 100)
assert isinstance(Account.__dict__['balance'], property)
assert '_balance' in vars(a)
assert 'balance' not in vars(a)"
check_py "__repr__ is custom and shows the state" "${import_dir}" \
"a = Account('ada', 120)
assert repr(a) == \"Account(owner='ada', balance=120.00)\", repr(a)
assert 'object at 0x' not in repr(a)"
check_py "from_csv_row is a classmethod that builds an instance" "${import_dir}" \
"a = Account.from_csv_row('cleo, 250.00')
assert type(a) is Account
assert a.owner == 'cleo' and a.balance == 250.0
assert isinstance(Account.__dict__['from_csv_row'], classmethod)
try:
Account.from_csv_row('nonsense')
raise SystemExit(1)
except ValueError:
pass"
check_py "is_valid_amount is a staticmethod" "${import_dir}" \
"assert isinstance(Account.__dict__['is_valid_amount'], staticmethod)
assert Account.is_valid_amount(5) is True
assert Account.is_valid_amount(-5) is False
assert Account.is_valid_amount('5') is False"
check_py "a method call is Class.method(instance, ...)" "${import_dir}" \
"a = Account('ada', 100)
assert a.deposit.__self__ is a
assert a.deposit.__func__ is Account.deposit
Account.deposit(a, 10)
assert a.balance == 110.0"
check_py "currency is a class attribute seen by every instance" "${import_dir}" \
"a = Account('ada', 100)
assert 'currency' not in vars(a)
assert a.currency == Account.currency == 'USD'"
check_py "the class reproduces the dict version's behaviour exactly" "${import_dir}" \
"import account_dict as old
moves = [('deposit', 50), ('withdraw', 30), ('deposit', 12.5), ('withdraw', 2.5)]
d = old.make_account('ada', 100)
c = Account('ada', 100)
for move, amount in moves:
(old.deposit if move == 'deposit' else old.withdraw)(d, amount)
getattr(c, move)(amount)
assert old.describe(d) == c.describe()
assert d['history'] == c.history"
}
# --- Reference implementation: always tested strictly ---
run_class_checks "${lab_dir}/examples"
echo "Testing the supporting example scripts ..."
check_script "account_dict.py runs and shows the unguarded edit" \
"examples/account_dict.py" "after a direct edit: ada: -500.00"
check_script "account.py runs and refuses the same edit" \
"examples/account.py" "after the refused edit: ada: 120.00"
check_script "shared_bug.py reproduces the shared list" \
"examples/shared_bug.py" "same list object? True"
check_script "shared_bug.py shows the per-instance fix" \
"examples/shared_bug.py" "cleo.history: [('cleo', 10)]"
check_script "compare.py proves the two versions agree" \
"examples/compare.py" "descriptions identical: True"
check_script "compare.py loads accounts.csv through the classmethod" \
"examples/compare.py" "loaded 4 accounts, total 2225.75 USD"
check_script "closure_object.py builds an object without the class keyword" \
"examples/closure_object.py" "outputs identical: True"
check_script "machinery.py shows the bound-method mechanism" \
"examples/machinery.py" "ada.deposit.__self__ is ada: True"
check_script "machinery.py shows name mangling" \
"examples/machinery.py" "hasattr(card, '_Card__pin'): True"
# --- Learner starter ---
echo "Testing starter/account.py ..."
starter_file="${lab_dir}/starter/account.py"
if python3 -c "compile(open('${starter_file}').read(), '${starter_file}', 'exec')" 2>/dev/null; then
check "starter is valid Python" "yes"
else
check "starter is valid Python" "no"
fi
if grep -q 'NotImplementedError' "${starter_file}"; then
echo "Note: starter/account.py still has unfinished exercises — testing structure only."
grep -q 'class Account' "${starter_file}" && check "starter defines class Account" "yes" || check "starter defines class Account" "no"
grep -q 'def __init__' "${starter_file}" && check "starter defines __init__" "yes" || check "starter defines __init__" "no"
grep -q 'def deposit' "${starter_file}" && check "starter defines deposit" "yes" || check "starter defines deposit" "no"
grep -q 'def withdraw' "${starter_file}" && check "starter defines withdraw" "yes" || check "starter defines withdraw" "no"
grep -q 'def __repr__' "${starter_file}" && check "starter defines __repr__" "yes" || check "starter defines __repr__" "no"
grep -q 'def from_csv_row' "${starter_file}" && check "starter defines from_csv_row" "yes" || check "starter defines from_csv_row" "no"
else
run_class_checks "${lab_dir}/starter"
fi
echo
echo "${checks} checks, ${failures} failure(s)."
[ "${failures}" -eq 0 ]
Troubleshooting
Troubleshooting — Day 067 lab
python: command not found
Use python3 explicitly, as every command in this lab does. On macOS and
most Linux systems, bare python may be missing or point to an old version.
Check with python3 --version.
The starter raises NotImplementedError when I run it
That is expected until you finish the five numbered exercises in
starter/account.py. Each unfinished method raises NotImplementedError on
purpose so you cannot mistake an empty method for a working one. Replace
each raise NotImplementedError(...) line with the body described in the
docstring above it. Once all of them are done, the test suite stops checking
structure and holds your class to the same standard as the reference.
TypeError: __init__() takes 2 positional arguments but 3 were given
You wrote def __init__(self, owner) but called Account('ada', 100), or
you forgot self entirely. Every method defined inside a class takes the
instance as its first parameter, conventionally named self, and Python
supplies it for you. Account('ada', 100) calls
Account.__init__(new_object, 'ada', 100) — three arguments, so the
signature needs three parameters.
NameError: name 'self' is not defined
self only exists inside a method body, because it is that method's first
parameter. If you are seeing this at class level (between the methods), you
have written code that runs when the class is defined, not when an
instance is created. Move it inside __init__.
AttributeError: 'Account' object has no attribute 'history'
You referenced self.history before anything assigned it. Instance
attributes come into existence when they are assigned, which is what
__init__ is for: self.history = []. If you deleted the class-level
history = [] (exercise 2) without adding the line in __init__, this is
exactly the error you get.
RecursionError: maximum recursion depth exceeded after adding the property
Inside a property getter or setter, never touch the property's own name. This loops forever, because the assignment calls the setter again:
@balance.setter
def balance(self, value):
self.balance = value # calls this same setter, forever
Store the value under a different, underscore-prefixed name, and have the getter return that:
@balance.setter
def balance(self, value):
self._balance = value # correct: a plain instance attribute
My property setter never runs
Two common causes. First, the @property getter must be defined before
the @name.setter block, and the setter must be decorated with the
property's own name (@balance.setter, not @property). Second, if you
assign to self._balance inside __init__ instead of self.balance, you
bypass the setter and its validation — assign to the public name so the
validation runs at construction time too.
One account's deposit shows up in another account's history
That is the shared mutable class attribute bug, and finding it is exercise 2.
A list written at class level (history = [] between the methods) is created
once, when the class is defined, and every instance reaches the same object.
Run python3 examples/shared_bug.py to see it isolated, then create the list
inside __init__ (self.history = []) so each instance gets its own.
ModuleNotFoundError: No module named 'account'
The one-line commands in the starter and in starter/inspection-notes.md
begin with sys.path.insert(0, 'starter'), which only works when you run
them from the lab directory (the one containing starter/ and
examples/). Check with pwd. Running a script by its path
(python3 examples/machinery.py) works from here too, because Python adds
the script's own directory to the import path automatically.
Printing an object shows <account.Account object at 0x104f3a2d0>
That is Python's default representation, and the hex number is a memory
address that changes on every run. It means the class has no __repr__ yet —
which is exercise 3. Add one that shows the state you actually care about,
and the same object prints as Account(owner='ada', balance=120.00).
TypeError: from_csv_row() missing 1 required positional argument
You defined from_csv_row with @staticmethod instead of @classmethod,
or you wrote def from_csv_row(row) without cls. A classmethod receives
the class itself as its first argument, named cls by convention, and calls
cls(...) to build the instance.
bash: tests/run_tests.sh: Permission denied
Run it through bash explicitly, as the README shows: bash tests/run_tests.sh.
You do not need to chmod +x anything.
Security notes
Security notes — Day 067 lab
-
What this lab does: defines a few small classes, runs them in memory, and reads one small CSV file (
examples/accounts.csv) that ships with the lab. It makes no network connections, needs no privileges, installs nothing, and writes no files. The test runner creates no temporary files either. -
A class is an enforcement point, and that is a security property. The dict version in
examples/account_dict.pylets any caller writeaccount["balance"] = -500and walk straight past every rule the functions were meant to enforce. The class version routes that same assignment through a@propertysetter that refuses it. When the rules that keep your data valid live with the data, there is one place to audit them and no way around them — instead of a rule scattered across every function that happens to remember it. -
Encapsulation in Python is a convention, not a wall. A single leading underscore (
self._balance) means "internal, please leave this alone" and nothing more; a double leading underscore (self.__pin) is name mangled toself._Card__pin, which prevents accidental collisions, not determined access —examples/machinery.pyshows_Card__pinsitting in plain sight invars(card). Never treat a double underscore as a secret store. Real secrets do not belong in object attributes you print, log, or serialize; keep them out of__repr__in particular, because a repr is exactly what ends up in log files and tracebacks. -
__repr__is a disclosure surface. A helpful repr is a debugging gift, but whatever you put in it will be printed by tracebacks, loggers, and debuggers. The referenceAccount.__repr__shows an owner and a balance because this is a teaching lab; in a real system, think before putting an API key, a token, a password, or personal data in a repr. -
Validate at the boundary, and fail loudly. Values entering the class from outside — a CSV row in
from_csv_row, an amount indeposit— are converted withfloat()and checked before they touch the state.float()can only produce a number or raiseValueError; never reach foreval()to "read" a number from a file or an input, becauseeval()executes the text as Python and would run whatever an attacker wrote there. Every rejection in this lab raises an exception rather than quietly clamping the value, so a wrong input is noticed instead of silently absorbed. -
Reading before running: every file in this lab is short and commented. Read
examples/account.py,examples/machinery.py, andtests/run_tests.shbefore running them. Running unread scripts is one of the most common ways developers get compromised; the course's rule is that every lab script is small enough to read and understand first.