1

Behavior From Examples

Why this matters

Defensive programming starts before the first if: you decide which inputs belong to the function’s contract and which ones are caller mistakes. Good examples and non-examples keep validation focused instead of turning it into guesswork.

🎯 You will learn to

  • Analyze valid and invalid examples as partitions of the input space.
  • Convert examples into a small contract before writing code.
  • Distinguish wrong type, malformed value, out-of-range value, and valid edge case.

Predict the contract

Study this intended function:

def parse_percentage(text: str) -> float:
    ...

It should accept these strings:

  • "0%" returns 0.0
  • "25%" returns 25.0
  • "100%" returns 100.0
  • " 7% " returns 7.0

It should reject these values:

  • "", because there is no percentage text
  • "25", because the percent sign is missing
  • "-1%", because the value is below the valid range
  • "101%", because the value is above the valid range
  • "abc%", because the numeric part is not a number
  • 25, because the value has the wrong type

Before you continue, write one sentence that describes the contract. A strong version says something like: “The caller must pass a string that, after surrounding whitespace is ignored, is a numeric percentage with a percent sign and a value from 0 through 100.”

Why no code yet?

This first step is a classification checkpoint. If you cannot sort an input into the right failure category, the implementation usually becomes either too permissive ("25" becomes accepted) or too vague (ValueError for everything, including wrong types).

Starter files
percentages.py
def parse_percentage(text: str) -> float:
    """Return the numeric value in a percentage string.

    Contract draft:
      - caller passes a string
      - surrounding whitespace is ignored
      - the cleaned value ends with "%"
      - the number before "%" is in the inclusive range 0..100
    """
    raise NotImplementedError("You will implement validation in later steps.")
2

Precise Exceptions

Why this matters

Defensive code is more useful when the failure tells the caller what kind of mistake they made. Python already has a useful split: TypeError for the wrong kind of value, ValueError for the right kind of value with an unacceptable value.

🎯 You will learn to

  • Apply TypeError and ValueError to different validation failures.
  • Use pytest.raises to verify the exact exception type.
  • Reject bool where an integer count is required.

Your task

Open repeat_text.py. Implement:

def repeat(text: str, times: int) -> str:
    ...

Contract:

  • text must be a str; otherwise raise TypeError.
  • times must be an int, but not bool; otherwise raise TypeError.
  • times must be non-negative; otherwise raise ValueError.
  • Valid inputs return text * times.

The tests check exact exception classes. A subclass or a broad Exception is not enough because the exception choice is the learning target here.

Starter files
repeat_text.py
def repeat(text: str, times: int) -> str:
    """Return text repeated times times."""
    return text * times
test_repeat_text.py
import pytest
from repeat_text import repeat


def test_repeats_text_for_valid_count():
    assert repeat("ha", 3) == "hahaha"
    assert repeat("ha", 0) == ""


def test_wrong_text_type_is_type_error():
    with pytest.raises(TypeError) as excinfo:
        repeat(123, 2)
    assert excinfo.type is TypeError


def test_wrong_times_type_is_type_error():
    with pytest.raises(TypeError) as excinfo:
        repeat("ha", "3")
    assert excinfo.type is TypeError


def test_negative_times_is_value_error():
    with pytest.raises(ValueError) as excinfo:
        repeat("ha", -1)
    assert excinfo.type is ValueError
3

Rejecting Quiet Repairs

Why this matters

Defensive programming is not the same as making every bad call work somehow. Quietly repairing a caller bug can move the failure far away from the cause, which makes the next bug harder to debug.

🎯 You will learn to

  • Evaluate when an automatic repair hides a caller mistake.
  • Reject invalid relationships between arguments.
  • Preserve the contract of a small numeric helper.

Your task

clamp(value, lower, upper) should return value limited to the inclusive range [lower, upper].

Valid examples:

clamp(5, 0, 10)    # 5
clamp(-2, 0, 10)   # 0
clamp(12, 0, 10)   # 10

The starter code tries to be helpful by swapping the bounds when lower > upper. Delete that behavior. The contract should reject reversed bounds with ValueError; callers need to know they passed the arguments in the wrong order.

Starter files
ranges.py
def clamp(value: float, lower: float, upper: float) -> float:
    """Return value limited to the inclusive interval [lower, upper]."""
    if lower > upper:
        # This hides the caller's bug. Replace it with a clear failure.
        lower, upper = upper, lower
    if value < lower:
        return lower
    if value > upper:
        return upper
    return value
test_ranges.py
import pytest
from ranges import clamp


def test_value_inside_range_is_unchanged():
    assert clamp(5, 0, 10) == 5


def test_value_below_range_returns_lower_bound():
    assert clamp(-2, 0, 10) == 0


def test_value_above_range_returns_upper_bound():
    assert clamp(12, 0, 10) == 10


def test_reversed_bounds_are_rejected():
    with pytest.raises(ValueError) as excinfo:
        clamp(5, 10, 0)
    assert excinfo.type is ValueError
4

State Integrity

Why this matters

Defensive programming is not only about function inputs. Objects also need valid state, and the easiest time to protect that state is at construction.

🎯 You will learn to

  • Enforce a class invariant in __post_init__.
  • Validate construction-time state before methods rely on it.
  • Reject method inputs that do not belong to the public contract.

Your task

Open intervals.py. The class represents a closed interval, meaning both endpoints are included. Its invariant is:

start <= end

Implement two pieces:

  • __post_init__ rejects invalid construction with ValueError.
  • contains(value) returns whether an integer value is inside the interval, and raises TypeError for non-integers or bool.

The class is frozen=True, so valid instances cannot be edited after construction. That makes the construction check especially important.

Starter files
intervals.py
from dataclasses import dataclass


@dataclass(frozen=True, slots=True)
class ClosedInterval:
    start: int
    end: int

    def __post_init__(self) -> None:
        # Enforce the invariant here.
        pass

    def contains(self, value: int) -> bool:
        # Return True when value is between start and end inclusive.
        return False
test_intervals.py
import pytest
from intervals import ClosedInterval


def test_valid_interval_contains_endpoints_and_middle():
    interval = ClosedInterval(3, 7)
    assert interval.contains(3) is True
    assert interval.contains(5) is True
    assert interval.contains(7) is True


def test_valid_interval_rejects_outside_values():
    interval = ClosedInterval(3, 7)
    assert interval.contains(2) is False
    assert interval.contains(8) is False


def test_reversed_interval_cannot_be_constructed():
    with pytest.raises(ValueError) as excinfo:
        ClosedInterval(7, 3)
    assert excinfo.type is ValueError


def test_contains_rejects_non_integer_values():
    interval = ClosedInterval(3, 7)
    with pytest.raises(TypeError) as excinfo:
        interval.contains("5")
    assert excinfo.type is TypeError
5

Visible Error Causes

Why this matters

except Exception: return {} looks friendly until production starts using the empty default after a missing file or broken JSON document. Defensive code should translate low-level failures into domain language while preserving the original cause for debugging.

🎯 You will learn to

  • Replace broad exception swallowing with targeted exception handling.
  • Raise a domain-specific exception with raise ... from exc.
  • Preserve useful failure information without leaking low-level details into callers.

Your task

Open config_loader.py. Replace the broad fallback with a clear contract:

  • load_json_config(path) returns a JSON object as a dict.
  • Missing or unreadable files raise ConfigError(...) from exc.
  • Invalid JSON raises ConfigError(...) from exc.
  • Valid JSON that is not an object raises ConfigError without pretending it is a usable config.

Do not catch Exception or BaseException. Catch the failures you can translate.

🔍 How the hidden check works

A pytest assertion that simply looks at behavior could miss a sneaky except Exception: that happens to translate to the right exception in the test cases. To guard against that, the hidden check parses your code’s Abstract Syntax Tree (AST) and explicitly rejects any except handler whose type is Exception or BaseException. This is the same technique used in real-world code review tools (Bandit, Ruff’s BLE001) — and it’s worth knowing as a defensive-programming technique in its own right: when you can’t trust behavior alone, parse the structure.

Starter files
config_loader.py
import json
from pathlib import Path
from typing import Any


class ConfigError(RuntimeError):
    """Raised when an application config file cannot be loaded."""


def load_json_config(path: Path) -> dict[str, Any]:
    """Load a JSON config object from path."""
    try:
        with Path(path).open(encoding="utf-8") as config_file:
            return json.load(config_file)
    except Exception:
        return {}
test_config_loader.py
import json
import pytest
from pathlib import Path
from config_loader import ConfigError, load_json_config


def test_valid_config_file_returns_dict():
    path = Path("/tutorial/app-config.json")
    path.write_text(json.dumps({"debug": True, "port": 8080}), encoding="utf-8")
    assert load_json_config(path) == {"debug": True, "port": 8080}


def test_missing_file_raises_config_error_with_cause():
    path = Path("/tutorial/does-not-exist.json")
    with pytest.raises(ConfigError) as excinfo:
        load_json_config(path)
    assert isinstance(excinfo.value.__cause__, OSError)


def test_invalid_json_raises_config_error_with_cause():
    path = Path("/tutorial/broken-config.json")
    path.write_text("{not valid json", encoding="utf-8")
    with pytest.raises(ConfigError) as excinfo:
        load_json_config(path)
    assert isinstance(excinfo.value.__cause__, json.JSONDecodeError)
6

Port Range Parser

Why this matters

Real defensive programming combines the pieces: classify input, reject invalid forms, choose precise exceptions, and guarantee that valid results obey a postcondition. A small parser is enough to practice the full loop without adding domain clutter.

🎯 You will learn to

  • Create a parser that rejects wrong types, malformed strings, out-of-range values, and reversed ranges.
  • Apply postcondition reasoning to returned tuples.
  • Test boundary values around a finite numeric domain.

Your task

Implement:

def parse_port_range(spec: str) -> tuple[int, int]:
    ...

Accepted forms:

  • "8080" returns (8080, 8080).
  • "8000-8080" returns (8000, 8080).
  • Surrounding whitespace around the whole spec is okay.

Rejected forms:

  • wrong type: raise TypeError
  • malformed string: raise ValueError
  • port outside 0..65535: raise ValueError
  • reversed range such as "9000-8000": raise ValueError

Postcondition for every successful call: the result is a pair of integers where 0 <= start <= end <= 65535.


🎓 After this capstone — should you always program defensively?

Across this tutorial you’ve built a complete defensive vocabulary: classify inputs into partitions, raise the right exception class, reject silent repair, protect object invariants, translate boundary failures into domain errors, and combine all of it into a parser whose contract is verifiable from the outside.

That’s the right toolkit at a system boundary — anywhere your code receives data from a user, a network call, a file, a database, or another team’s API. The further you go from that boundary, between trusted modules you wrote yourself, the more the same checks start to feel like noise that compounds across a large codebase.

Bertrand Meyer (who coined the term in the 1980s) argued against applying defensive programming uniformly. His alternative — Design by Contract — asks a sharper question: instead of “how do I reject bad input?”, whose job was this? That single shift changes what code looks like in the interior of a system.

Next: the Design by Contract in Python tutorial picks up where this one ends. It uses the same Python and the same techniques you just practiced, but reframes them around responsibility allocation — and gives you a criterion for deciding where defensive programming earns its keep and where it costs more than it gives.

Starter files
ports.py
def parse_port_range(spec: str) -> tuple[int, int]:
    """Parse a single port or inclusive port range."""
    raise NotImplementedError("parse the port range")
test_ports.py
import pytest
from ports import parse_port_range


def assert_valid_port_range(result):
    assert isinstance(result, tuple)
    assert len(result) == 2
    start, end = result
    assert isinstance(start, int)
    assert isinstance(end, int)
    assert 0 <= start <= end <= 65535


def test_single_port_becomes_one_point_range():
    result = parse_port_range("8080")
    assert result == (8080, 8080)
    assert_valid_port_range(result)


def test_range_preserves_start_and_end():
    result = parse_port_range("8000-8080")
    assert result == (8000, 8080)
    assert_valid_port_range(result)


def test_boundary_ports_are_valid():
    assert parse_port_range("0") == (0, 0)
    assert parse_port_range("65535") == (65535, 65535)
    assert parse_port_range("0-65535") == (0, 65535)


def test_wrong_type_is_type_error():
    with pytest.raises(TypeError) as excinfo:
        parse_port_range(8080)
    assert excinfo.type is TypeError


def test_malformed_specs_are_value_error():
    for spec in ["", "abc", "80/http", "80-", "-90", "80-90-100"]:
        with pytest.raises(ValueError):
            parse_port_range(spec)


def test_out_of_range_ports_are_value_error():
    for spec in ["-1", "65536", "80-70000"]:
        with pytest.raises(ValueError):
            parse_port_range(spec)


def test_reversed_range_is_value_error():
    with pytest.raises(ValueError):
        parse_port_range("9000-8000")