1

Contract Mindset

Why this matters

Defensive Programming asks “how do I reject bad input clearly?” Design by Contract asks a sharper question first: “whose job was this?” That single shift in framing changes how the code is structured — sometimes radically.

Bertrand Meyer, who coined Design by Contract in the 1980s, argued explicitly against defensive programming. He called blanket input-checking a methodology that “can only … cause … a decrease in reliability” because redundant checks across a large system compound into “a monster of useless complexity.” His Non-Redundancy Principle: under no circumstances shall the body of a routine ever test for its precondition. The caller’s job is to satisfy preconditions; the supplier’s job is to satisfy postconditions, assuming the preconditions held.

That’s a strong claim — and the opposite of the DP tutorial you just finished. This step makes the contrast visible by putting two versions of the same function side by side, then introduces the pragmatic Python middle ground we’ll use for the rest of the tutorial.

🎯 You will learn to

  • Analyze the same function as a Defensive Programming artifact vs. a Design by Contract artifact.
  • Distinguish caller obligations (preconditions) from supplier guarantees (postconditions).
  • Apply the Non-Redundancy Principle to decide where a check belongs.

Two implementations, same behavior on valid inputs

Open discounts.py. It contains two versions of discounted_price(price, discount). Their visible behavior on valid inputs is identical. Their framing of responsibility is very different.

Version A

def discounted_price_A(price, discount):
    if not isinstance(price, (int, float)):
        raise TypeError("price must be a number")
    if not isinstance(discount, (int, float)):
        raise TypeError("discount must be a number")
    if price < 0:
        raise ValueError("price must be non-negative")
    if not (0 <= discount <= 1):
        raise ValueError("discount must be between 0 and 1")
    return price * (1 - discount)

Version B

def discounted_price_B(price, discount):
    """Return price after applying a fractional discount.

    Preconditions (caller's obligation):
        - price >= 0
        - 0 <= discount <= 1

    Postcondition (supplier's guarantee, given preconditions hold):
        - result == price * (1 - discount)
        - 0 <= result <= price
    """
    return price * (1 - discount)

✏️ Predict before you read on

Commit to a letter, then open the reveal.

  • (a) A is Defensive Programming; B is Design by Contract.
  • (b) B is Defensive Programming; A is Design by Contract.
  • (c) They are equivalent — labels are decoration.
  • (d) Both are Defensive — DbC requires a different language like Eiffel.
Reveal **(a) is correct.** Version A is **Defensive Programming**: every assumption is checked at the boundary of the routine, before any useful work happens. Version B is **Design by Contract** in the pure Meyer sense: the contract is *documented*; the body *assumes* the precondition; there are no defensive checks in the body. The other letters map to specific misconceptions worth naming: - **(b)** confuses "more code = more rigorous." DbC's claim is the opposite: contracts let you write *less* code by allocating responsibility. - **(c)** treats this as a style preference. It isn't. Labels matter because they change *who is responsible when something fails*. A failure in Version A is the function's fault for letting bad input through. A failure in Version B is the *caller's* fault for violating the documented precondition. - **(d)** DbC is a methodology, not a language. Eiffel makes it ergonomic; Python can do it with discipline and small helpers.

What each style optimizes for

Aspect Defensive Programming (A) Design by Contract (B)
Trust assumption Caller may be hostile or buggy Caller is a partner who reads the contract
Where checks live Inside the routine body Outside, in the caller’s context — or not at all
Scales well to Public APIs, untrusted boundaries Large libraries with many internal callers
Fails badly when Internal callers pay validation cost on every call Public APIs accept untrusted input
Performance cost Permanent Optional — Eiffel disables monitoring in release builds

The conclusion both Meyer and modern practice agree on: use DbC for internal module boundaries; use DP at the system boundary (user input, network, files, databases). They are complementary, not competing. The mistake is doing both for the same condition — that’s the redundancy Meyer warned about.

A pragmatic Python middle ground

Python doesn’t have a built-in contract language. For this tutorial we use two tiny helpers that make the contract visible and runtime-checked — useful for learning and for debugging, optional in production:

def require(condition: bool, message: str, *, kind: str = "value") -> None:
    """Caller obligation. kind='type' raises TypeError; default raises ValueError."""
    if not condition:
        raise (TypeError if kind == "type" else ValueError)(message)

def ensure(condition: bool, message: str) -> None:
    """Supplier guarantee. Failure is a supplier bug → AssertionError."""
    if not condition:
        raise AssertionError(message)

Two intentional design choices worth naming:

  • require accepts a kind="type" flag. This keeps us consistent with the DP tutorial’s TypeError/ValueError discipline. In pure DbC, every precondition violation is the same thing — a caller bug — and the exception class is a Python-pragmatic refinement, not a core concept. We use kind="type" only when distinguishing the diagnostic adds clarity.
  • ensure raises AssertionError instead of ValueError. A postcondition failure is the supplier’s bug, not the caller’s. Distinct exception classes make blame visible in tracebacks. We don’t use Python’s built-in assert because python -O strips it — but conceptually it’s the same thing. In Eiffel, contract monitoring is a compile flag that turns checks on or off without altering the specification itself.

Version C below is the working version we’ll build on for the rest of the tutorial — DbC framing, runtime monitoring, no inline type defense:

def discounted_price(price: float, discount: float) -> float:
    require(price >= 0, "price must be non-negative")
    require(0 <= discount <= 1, "discount must be between 0 and 1")
    result = price * (1 - discount)
    ensure(0 <= result <= price, "result must stay between 0 and original price")
    return result

Notice what’s not there: type checks. In pure DbC you would document price: REAL and let the type system or the caller’s contract handle wrong types. We will sometimes add require(isinstance(...), ..., kind="type") later when it sharpens diagnostics — but every time we do, we’re paying the same redundancy cost Meyer warned about. It’s a trade-off, not a default.

Starter files
discounts.py
# ============================================================
# Two methodologies, one function.
# Read both versions, then study Version C — the DbC framing
# we will build on for the rest of the tutorial.
# ============================================================

# ---- Version A: Defensive Programming ----
def discounted_price_A(price, discount):
    if not isinstance(price, (int, float)):
        raise TypeError("price must be a number")
    if not isinstance(discount, (int, float)):
        raise TypeError("discount must be a number")
    if price < 0:
        raise ValueError("price must be non-negative")
    if not (0 <= discount <= 1):
        raise ValueError("discount must be between 0 and 1")
    return price * (1 - discount)


# ---- Version B: Design by Contract (pure Meyer) ----
def discounted_price_B(price, discount):
    """Return price after applying a fractional discount.

    Preconditions (caller's obligation):
        - price >= 0
        - 0 <= discount <= 1

    Postcondition (supplier's guarantee, given preconditions hold):
        - result == price * (1 - discount)
        - 0 <= result <= price
    """
    return price * (1 - discount)


# ---- Contract helpers (used from Version C onward) ----
def require(condition: bool, message: str, *, kind: str = "value") -> None:
    """Caller obligation. kind='type' raises TypeError; default raises ValueError."""
    if not condition:
        raise (TypeError if kind == "type" else ValueError)(message)


def ensure(condition: bool, message: str) -> None:
    """Supplier guarantee. Failure is the supplier's bug → AssertionError."""
    if not condition:
        raise AssertionError(message)


# ---- Version C: DbC with runtime monitoring (our working version) ----
def discounted_price(price: float, discount: float) -> float:
    """Return price after applying a fractional discount."""
    require(price >= 0, "price must be non-negative")
    require(0 <= discount <= 1, "discount must be between 0 and 1")
    result = price * (1 - discount)
    ensure(0 <= result <= price, "result must stay between 0 and original price")
    return result
2

Good Preconditions

Why this matters

A good precondition rejects calls that the function cannot honestly handle before the algorithm starts. That keeps the body simple and makes failures point to the caller’s contract violation, not to obscure runtime errors deep inside the math.

🔗 Bridging from Defensive Programming

In DP Step 2 (repeat) you used TypeError for wrong-kind inputs and ValueError for wrong-value inputs. In Design by Contract, both are just “the caller violated a precondition” — the responsibility label is the core concept, not the exception class. We keep kind="type" on require as a Python-pragmatic refinement (it sharpens diagnostics), but Meyer’s argument is that who matters more than what.

🎯 You will learn to

  • Apply require to reject invalid inputs before computation.
  • Validate emptiness, numeric type, NaN, and infinity as separate precondition concerns.
  • Keep the actual mean calculation small after preconditions pass.

Your task

Implement mean_nonempty(xs).

Preconditions:

  • xs is non-empty.
  • Every element is a real number, but not bool.
  • Every element is finite: not NaN, not positive infinity, and not negative infinity.

Postcondition:

  • The result equals sum(xs) / len(xs) for the validated values.

Use require(...) for precondition checks and ensure(...) for the postcondition.

Starter files
means.py
import math
from collections.abc import Sequence


def require(condition: bool, message: str, *, kind: str = "value") -> None:
    """Caller obligation. kind='type' raises TypeError; default raises ValueError."""
    if not condition:
        raise (TypeError if kind == "type" else ValueError)(message)


def ensure(condition: bool, message: str) -> None:
    """Supplier guarantee. Failure raises AssertionError."""
    if not condition:
        raise AssertionError(message)


def mean_nonempty(xs: Sequence[float]) -> float:
    """Return the arithmetic mean of a non-empty finite numeric sequence."""
    # Add preconditions before computing.
    result = sum(xs) / len(xs)
    return result
test_means.py
import math
import pytest
from means import mean_nonempty


def test_valid_values_return_mean():
    assert mean_nonempty([2, 4, 6]) == pytest.approx(4)
    assert mean_nonempty([1.5, 2.5]) == pytest.approx(2.0)


def test_empty_sequence_violates_precondition():
    with pytest.raises(ValueError):
        mean_nonempty([])


def test_nonnumeric_value_violates_precondition():
    # Type predicate violated → TypeError (require with kind="type").
    with pytest.raises(TypeError):
        mean_nonempty([1, "2", 3])


def test_nan_and_infinity_violate_precondition():
    for value in [math.nan, math.inf, -math.inf]:
        with pytest.raises(ValueError):
            mean_nonempty([1, value])
3

Strong Postconditions

Why this matters

Weak postconditions let broken code pass. For a sequence operation, “returns a list” is not enough; the contract should say exactly what changed and what stayed untouched.

🔗 Bridging from Defensive Programming

In DP Step 4 (ClosedInterval.__post_init__) you enforced an invariant at construction: a one-time check at the start of the object’s life. Postconditions are a related but different idea — instead of “what must always be true of this object,” they say “what must be true after this call returns, given the inputs and the prior state.” The old_xs snapshot is the new tool that lets a postcondition reference the input as it was at entry, even after the function has done work.

🎯 You will learn to

  • Use an old-state snapshot to compare before and after behavior.
  • Specify a sequence transformation without mutating the input.
  • Apply ensure to check a strong postcondition.

Your task

Implement:

def remove_at(xs: Sequence[int], index: int) -> list[int]:
    ...

Contract:

  • Precondition: 0 <= index < len(xs).
  • The function returns a new list.
  • The input sequence is not mutated.
  • The returned list is exactly the old sequence with the element at index removed.

Create old_xs = list(xs) before making the result. Use ensure(...) to check the returned list against old_xs[:index] + old_xs[index + 1:].

Starter files
sequences.py
from collections.abc import Sequence


def require(condition: bool, message: str, *, kind: str = "value") -> None:
    """Caller obligation. kind='type' raises TypeError; default raises ValueError."""
    if not condition:
        raise (TypeError if kind == "type" else ValueError)(message)


def ensure(condition: bool, message: str) -> None:
    """Supplier guarantee. Failure raises AssertionError."""
    if not condition:
        raise AssertionError(message)


def remove_at(xs: Sequence[int], index: int) -> list[int]:
    """Return a new list with the item at index removed."""
    require(0 <= index < len(xs), "index must be in range")
    # Snapshot old state before building the result.
    old_xs = ...
    result = list(xs)
    result.pop(index)
    return result
test_sequences.py
import pytest
from sequences import remove_at


def test_removes_first_middle_and_last_items():
    assert remove_at([10, 20, 30], 0) == [20, 30]
    assert remove_at([10, 20, 30], 1) == [10, 30]
    assert remove_at([10, 20, 30], 2) == [10, 20]


def test_invalid_index_violates_precondition():
    with pytest.raises(ValueError):
        remove_at([10, 20, 30], -1)
    with pytest.raises(ValueError):
        remove_at([10, 20, 30], 3)


def test_input_list_is_not_mutated():
    xs = [10, 20, 30]
    result = remove_at(xs, 1)
    assert result == [10, 30]
    assert xs == [10, 20, 30]
    assert result is not xs
4

Class Invariants

Why this matters

An invariant is a fact that should be true after construction and after every public method call. Once an object promises an invariant, every method can rely on it instead of re-validating the whole object from scratch.

🔗 Bridging from Defensive Programming

In DP Step 4 (ClosedInterval.__post_init__) you protected the object by validating at construction — one stable time, at the start of the object’s life. DbC’s invariant idea extends this: there are many stable times — after construction, and before and after every public method call. The invariant must hold at every one of them. Meyer’s diagram (an object lifecycle as S1 → S2 → S3 → … with INV true at each S_i) makes this visible. The constructor check is the first guardrail in a longer chain.

🎯 You will learn to

  • Define a _check_invariant method for object state.
  • Use preconditions for invalid caller requests.
  • Use postconditions and invariant checks after mutation.

Your task

Implement BankAccount.

Invariant:

  • owner is a non-empty string after stripping whitespace.
  • balance is a non-negative integer.

Method contracts:

  • deposit(amount) requires a positive integer amount and increases balance by exactly that amount.
  • withdraw(amount) requires a positive integer amount and sufficient funds, then decreases balance by exactly that amount.
  • Both methods preserve the invariant.
Starter files
accounts.py
def require(condition: bool, message: str, *, kind: str = "value") -> None:
    """Caller obligation. kind='type' raises TypeError; default raises ValueError."""
    if not condition:
        raise (TypeError if kind == "type" else ValueError)(message)


def ensure(condition: bool, message: str) -> None:
    """Supplier guarantee. Failure raises AssertionError."""
    if not condition:
        raise AssertionError(message)


class BankAccount:
    def __init__(self, owner: str, balance: int = 0) -> None:
        self.owner = owner
        self.balance = balance
        # Check the invariant after construction.

    def _check_invariant(self) -> None:
        # owner must be a non-empty string; balance must be a non-negative int.
        pass

    def deposit(self, amount: int) -> None:
        # Increase balance by exactly amount.
        pass

    def withdraw(self, amount: int) -> None:
        # Decrease balance by exactly amount without overdrawing.
        pass
test_accounts.py
import pytest
from accounts import BankAccount


def test_valid_construction_sets_owner_and_balance():
    account = BankAccount("Maya", 25)
    assert account.owner == "Maya"
    assert account.balance == 25


def test_construction_requires_valid_invariant_values():
    with pytest.raises(ValueError):
        BankAccount("", 0)
    with pytest.raises(ValueError):
        BankAccount("Maya", -1)


def test_deposit_increases_balance_exactly():
    account = BankAccount("Maya", 25)
    account.deposit(10)
    assert account.balance == 35


def test_withdraw_decreases_balance_exactly():
    account = BankAccount("Maya", 25)
    account.withdraw(10)
    assert account.balance == 15


def test_overdraft_is_rejected():
    account = BankAccount("Maya", 25)
    with pytest.raises(ValueError):
        account.withdraw(26)
    assert account.balance == 25
5

Multi-Object Contracts

Why this matters

Contracts become more useful when an operation coordinates multiple objects. The preconditions say when the operation is allowed; the postconditions say what changed across the whole collaboration.

🔗 Bridging from Defensive Programming

In DP Step 6 (the port-range parser) you combined input validation, postcondition reasoning, and exact exception types in a single function. transfer does the same combination, but now across two objects: preconditions guard caller mistakes, postconditions specify the exact change, and each object’s invariant must still hold afterward. The mechanics are familiar; the framing is responsibility allocation across multiple parties — Meyer’s metaphor is a contract between a client and two suppliers cooperating.

🎯 You will learn to

  • Write preconditions that involve more than one object.
  • Use old-state snapshots for two-object postconditions.
  • Preserve local invariants and a global total in one operation.

Your task

Implement:

def transfer(src: BankAccount, dst: BankAccount, amount: int) -> None:
    ...

Preconditions:

  • src and dst are distinct BankAccount objects.
  • amount is a positive integer.
  • src.balance >= amount.

Postconditions:

  • source balance decreases by exactly amount
  • destination balance increases by exactly amount
  • total money across the two accounts is preserved
  • both account invariants still hold
Starter files
accounts.py
def require(condition: bool, message: str, *, kind: str = "value") -> None:
    """Caller obligation. kind='type' raises TypeError; default raises ValueError."""
    if not condition:
        raise (TypeError if kind == "type" else ValueError)(message)


def ensure(condition: bool, message: str) -> None:
    """Supplier guarantee. Failure raises AssertionError."""
    if not condition:
        raise AssertionError(message)


def _ensure_positive_int(value: object, name: str = "amount") -> None:
    """Combined precondition: a strictly positive integer (not bool)."""
    require(
        isinstance(value, int) and not isinstance(value, bool),
        f"{name} must be an integer",
        kind="type",
    )
    require(value > 0, f"{name} must be positive")


class BankAccount:
    def __init__(self, owner: str, balance: int = 0) -> None:
        require(isinstance(owner, str), "owner must be a string", kind="type")
        require(owner.strip() != "", "owner must be non-empty")
        require(
            isinstance(balance, int) and not isinstance(balance, bool),
            "balance must be an integer",
            kind="type",
        )
        require(balance >= 0, "balance must be non-negative")
        self.owner = owner
        self.balance = balance
        self._check_invariant()

    def _check_invariant(self) -> None:
        ensure(isinstance(self.owner, str) and self.owner.strip() != "", "owner invariant failed")
        ensure(isinstance(self.balance, int) and not isinstance(self.balance, bool), "balance type invariant failed")
        ensure(self.balance >= 0, "balance invariant failed")

    def deposit(self, amount: int) -> None:
        _ensure_positive_int(amount)
        old_balance = self.balance
        self.balance += amount
        self._check_invariant()
        ensure(self.balance == old_balance + amount, "deposit must increase balance exactly")

    def withdraw(self, amount: int) -> None:
        _ensure_positive_int(amount)
        require(amount <= self.balance, "insufficient funds")
        old_balance = self.balance
        self.balance -= amount
        self._check_invariant()
        ensure(self.balance == old_balance - amount, "withdraw must decrease balance exactly")


def transfer(src: BankAccount, dst: BankAccount, amount: int) -> None:
    """Move amount from src to dst."""
    # Add preconditions, old-state snapshots, the transfer, and postconditions.
    pass
test_transfer.py
import pytest
from accounts import BankAccount, transfer


def test_valid_transfer_moves_money_exactly():
    src = BankAccount("Maya", 100)
    dst = BankAccount("Noor", 25)
    transfer(src, dst, 40)
    assert src.balance == 60
    assert dst.balance == 65


def test_transfer_preserves_total_money():
    src = BankAccount("Maya", 100)
    dst = BankAccount("Noor", 25)
    before = src.balance + dst.balance
    transfer(src, dst, 40)
    assert src.balance + dst.balance == before


def test_self_transfer_is_rejected():
    src = BankAccount("Maya", 100)
    with pytest.raises(ValueError):
        transfer(src, src, 10)
    assert src.balance == 100


def test_overspend_is_rejected_without_mutation():
    src = BankAccount("Maya", 30)
    dst = BankAccount("Noor", 25)
    with pytest.raises(ValueError):
        transfer(src, dst, 40)
    assert src.balance == 30
    assert dst.balance == 25
6

Substitution: Subclass Contracts

Why this matters

Contracts matter most when code is reused through an interface or subclass. The Liskov Substitution Principle says a subclass must be usable anywhere the base class was expected — which means it must accept everything the base accepted, and guarantee everything the base guaranteed. Violating substitutability silently breaks every caller written against the base.

The rules sound simple but trap students every time: most learners’ intuition is “a subclass is more specific, so it can demand more.” The opposite is true. This step makes the rules executable — you’ll implement a subclass, and a test harness will run every base-class test against your subclass to verify substitutability holds. Then property-based testing will explore the substitution space with hundreds of generated operation sequences.

🔗 Bridging from Defensive Programming

In DP Step 3 (clamp) you saw why silent repair is dangerous: swapping reversed bounds hid the caller’s mistake. The Liskov rule is the inheritance-time version of the same idea — a subclass that demands more (stronger precondition) or promises less (weaker postcondition) silently breaks every caller written against the base. Same harm, different layer. Substitutability protects callers from invisible contract changes the way clamp’s explicit rejection protected callers from invisible argument swaps.

🎯 You will learn to

  • Apply the weaker-precondition / stronger-postcondition rule to subclass contracts.
  • Create a subclass that legally strengthens a postcondition.
  • Evaluate substitutability with both example-based and property-based tests.

The rule

A safe replacement may:

  • Weaken preconditions — accept everything the base accepted, and maybe more.
  • Strengthen postconditions — guarantee everything the base guaranteed, and maybe more.

It may not:

  • Strengthen preconditions — old callers might now be rejected.
  • Weaken postconditions — old callers might no longer get what they relied on.

Mnemonic: a subclass can be more accepting on the way in and more generous on the way out — never the reverse.

Your task

Implement SavingsAccount(BankAccount) with a strengthened postcondition: every successful deposit and withdraw is recorded in a transactions list. The list contains tuples of (kind, amount, balance_after), where kind is "deposit" or "withdraw".

Concretely, SavingsAccount must:

  • Inherit from BankAccount.
  • Maintain a transactions attribute (a list[tuple[str, int, int]]).
  • Override deposit and withdraw to record the transaction after the base operation succeeds.
  • Not strengthen any precondition — every call that worked on a BankAccount must still work on a SavingsAccount.
  • Not weaken any postcondition — balance changes must still be exactly as the base specified.

This is a legal contract change: postconditions strengthen (the new transactions guarantee), preconditions stay equal, and the existing balance invariant is preserved.

How substitutability is tested

Two layers, both visible in test_savings.py:

  1. Parametrized fixture (AccountCls): every base-class test runs against both BankAccount and SavingsAccount. If the subclass narrowed the contract in any way, at least one base test fails on it.
  2. Hypothesis property test: generates random valid sequences of deposit/withdraw operations and asserts the two classes agree on final balance across the whole sequence. This catches contract narrowing that example tests miss.

Property-based testing is evidence, not proof — it samples many inputs, not all. But for substitutability it’s an excellent fit because the property (“base and subclass agree”) is straightforward to state and surprisingly hard to game.

Starter files
accounts.py
def require(condition: bool, message: str, *, kind: str = "value") -> None:
    """Caller obligation. kind='type' raises TypeError; default raises ValueError."""
    if not condition:
        raise (TypeError if kind == "type" else ValueError)(message)


def ensure(condition: bool, message: str) -> None:
    """Supplier guarantee. Failure raises AssertionError."""
    if not condition:
        raise AssertionError(message)


def _ensure_positive_int(value: object, name: str = "amount") -> None:
    """Combined precondition: a strictly positive integer (not bool)."""
    require(
        isinstance(value, int) and not isinstance(value, bool),
        f"{name} must be an integer",
        kind="type",
    )
    require(value > 0, f"{name} must be positive")


class BankAccount:
    def __init__(self, owner: str, balance: int = 0) -> None:
        require(isinstance(owner, str), "owner must be a string", kind="type")
        require(owner.strip() != "", "owner must be non-empty")
        require(
            isinstance(balance, int) and not isinstance(balance, bool),
            "balance must be an integer",
            kind="type",
        )
        require(balance >= 0, "balance must be non-negative")
        self.owner = owner
        self.balance = balance
        self._check_invariant()

    def _check_invariant(self) -> None:
        ensure(isinstance(self.owner, str) and self.owner.strip() != "", "owner invariant failed")
        ensure(isinstance(self.balance, int) and not isinstance(self.balance, bool), "balance type invariant failed")
        ensure(self.balance >= 0, "balance invariant failed")

    def deposit(self, amount: int) -> None:
        _ensure_positive_int(amount)
        old_balance = self.balance
        self.balance += amount
        self._check_invariant()
        ensure(self.balance == old_balance + amount, "deposit must increase balance exactly")

    def withdraw(self, amount: int) -> None:
        _ensure_positive_int(amount)
        require(amount <= self.balance, "insufficient funds")
        old_balance = self.balance
        self.balance -= amount
        self._check_invariant()
        ensure(self.balance == old_balance - amount, "withdraw must decrease balance exactly")
savings.py
from accounts import BankAccount, ensure


class SavingsAccount(BankAccount):
    """A BankAccount that additionally records every transaction.

    Contract strengthening:
      - Same preconditions as BankAccount.deposit / withdraw (do not narrow).
      - Same balance-change postconditions as BankAccount (do not weaken).
      - ADDITIONAL postcondition: after a successful deposit/withdraw,
        the last element of self.transactions describes that operation.
    """

    def __init__(self, owner: str, balance: int = 0) -> None:
        # Initialize subclass state BEFORE super().__init__ so that any
        # invariant check that touches transactions sees a valid list.
        self.transactions: list[tuple[str, int, int]] = []
        # TODO: delegate to BankAccount.__init__.

    def deposit(self, amount: int) -> None:
        # TODO: call the base deposit, then record ("deposit", amount, self.balance).
        pass

    def withdraw(self, amount: int) -> None:
        # TODO: call the base withdraw, then record ("withdraw", amount, self.balance).
        pass
test_savings.py
import pytest
from accounts import BankAccount
from savings import SavingsAccount


# =====================================================================
# Substitutability layer 1 — every BankAccount test runs against both
# BankAccount and SavingsAccount via a parametrized fixture.
# =====================================================================

@pytest.fixture(params=[BankAccount, SavingsAccount])
def AccountCls(request):
    return request.param


def test_valid_construction(AccountCls):
    account = AccountCls("Maya", 25)
    assert account.owner == "Maya"
    assert account.balance == 25


def test_construction_rejects_invalid_invariants(AccountCls):
    with pytest.raises(ValueError):
        AccountCls("", 0)
    with pytest.raises(ValueError):
        AccountCls("Maya", -1)


def test_deposit_changes_balance_exactly(AccountCls):
    account = AccountCls("Maya", 25)
    account.deposit(10)
    assert account.balance == 35


def test_withdraw_changes_balance_exactly(AccountCls):
    account = AccountCls("Maya", 25)
    account.withdraw(10)
    assert account.balance == 15


def test_overdraft_rejected(AccountCls):
    account = AccountCls("Maya", 25)
    with pytest.raises(ValueError):
        account.withdraw(26)
    assert account.balance == 25


def test_subclass_does_not_narrow_amount(AccountCls):
    # A single-cent deposit is valid for BankAccount; SavingsAccount must accept it too.
    account = AccountCls("Maya", 0)
    account.deposit(1)
    assert account.balance == 1


# =====================================================================
# Strengthened postcondition — unique to SavingsAccount.
# =====================================================================

def test_savings_records_each_transaction():
    account = SavingsAccount("Maya", 100)
    account.deposit(20)
    account.withdraw(50)
    assert account.transactions == [
        ("deposit", 20, 120),
        ("withdraw", 50, 70),
    ]


def test_savings_does_not_record_failed_operations():
    account = SavingsAccount("Maya", 10)
    with pytest.raises(ValueError):
        account.withdraw(99)
    # The failed withdraw must not appear in the ledger.
    assert account.transactions == []


# =====================================================================
# Substitutability layer 2 — Hypothesis explores hundreds of valid
# operation sequences and asserts the two classes agree on balance.
# =====================================================================

from hypothesis import given, settings, strategies as st


operation = st.tuples(
    st.sampled_from(["deposit", "withdraw"]),
    st.integers(min_value=1, max_value=1_000),
)


@settings(max_examples=100, deadline=None)
@given(start=st.integers(min_value=0, max_value=10_000),
       ops=st.lists(operation, max_size=20))
def test_savings_substitutes_for_bank_account(start, ops):
    base = BankAccount("base", start)
    sub = SavingsAccount("sub", start)

    for kind, amount in ops:
        try:
            getattr(base, kind)(amount)
        except ValueError:
            # If base rejects, subclass MUST reject too (no narrowing).
            with pytest.raises(ValueError):
                getattr(sub, kind)(amount)
            continue
        # Base accepted — subclass must also accept and reach the same balance.
        getattr(sub, kind)(amount)
        assert base.balance == sub.balance