Programming with PythonFiles, Errors, and Object-Oriented Python › Day 67

Hands-on lab — Day 67: 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-objects when 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 self is, who supplies it, and how obj.method(x) becomes Class.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 @property to validate an attribute on assignment, and catch the exception it raises.
  • Use @classmethod as an alternative constructor and @staticmethod for 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_row parses.
  • Day 66: exceptions — raise, try/except, and choosing ValueError.
  • Days 53, 57, 58: dictionaries in depth, functions, and closures (the from-scratch object system in examples/closure_object.py uses 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 python for python3 if 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).
  • bash for 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 direct account["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 @property setter.
  • 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 reads examples/accounts.csv and builds four accounts with Account.from_csv_row.
  • python3 examples/shared_bug.py — creates two BuggyAccount instances whose history is 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 (no class keyword anywhere), then the same account with class, and prints where each keeps its state and its functions.
  • python3 examples/machinery.py — prints type() 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 unsugared Account.deposit(ada, 10) call, and how __pin becomes _Card__pin.
  • python3 starter/account.py — runs your work in progress. Until you finish the exercises it raises NotImplementedError, 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

  1. python3 examples/account_dict.py ends with after a direct edit: ada: -500.00 (2 entries).
  2. python3 examples/account.py ends with repr: Account(owner='ada', balance=120.00) and never shows a negative balance.
  3. python3 examples/compare.py prints descriptions identical: True, histories identical: True, and loaded 4 accounts, total 2225.75 USD.
  4. python3 examples/shared_bug.py prints same list object? True for the buggy class and same list object? False for the fixed one.
  5. python3 examples/closure_object.py prints outputs identical: True.
  6. python3 examples/machinery.py prints ada.deposit.__self__ is ada: True and hasattr(card, '_Card__pin'): True.
  7. Your starter/account.py runs without NotImplementedError and prints the same description, repr, rejection, and CSV-built account as the reference.
  8. starter/inspection-notes.md has every "What I saw" cell filled in from your own runs.
  9. bash tests/run_tests.sh ends with 0 failure(s) and exits 0.

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

  1. Add a transfer(self, other, amount) method that moves money from this account to another Account. Decide first what the invariant is when two objects are involved, and make sure a failed transfer leaves both balances unchanged.
  2. Add a read-only @property called movements that returns len(self.history) and has no setter, then confirm that assigning to it raises AttributeError. That is how you express "computed, and not yours to set."
  3. Add a class attribute accounts_opened and increment it in __init__ (the reference class in examples/account.py does this — read how). Then explain in one sentence why self.accounts_opened += 1 would silently create a per-instance attribute instead of updating the shared counter.
  4. Write to_csv_row(self) as the mirror of from_csv_row, and prove the round trip: Account.from_csv_row(a.to_csv_row()) produces an account with the same owner and balance.
  5. Rewrite examples/closure_object.py's make_account so the returned dict also carries a history accessor, then write down which parts of the class machinery you had to hand-build — and which ones you got for free from class.
  • 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_row classmethod 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.py lets any caller write account["balance"] = -500 and walk straight past every rule the functions were meant to enforce. The class version routes that same assignment through a @property setter 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 to self._Card__pin, which prevents accidental collisions, not determined access — examples/machinery.py shows _Card__pin sitting in plain sight in vars(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 reference Account.__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 in deposit — are converted with float() and checked before they touch the state. float() can only produce a number or raise ValueError; never reach for eval() to "read" a number from a file or an input, because eval() 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, and tests/run_tests.sh before 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.