Matheion

MA-PY1 · Module 4.1

Exceptions and robust boundaries

Topics: try/except · raise · finally · Custom exceptions

Overview

Module 4.1 — Exceptions and robust boundaries

Scroll Core ideas → worked snippets → Practice (quiz). Decide which failures you catch broadly vs narrowly before opening Discuss. Run examples in the REPL or small scripts.

Concepts

Core ideas — failures as first-class objects

Real programs hit illegal inputs, missing files, network drops, and None where data was promised. Python signals these with exceptions — objects carrying a type, message, and traceback. Your job is threefold:

  1. Catch recoverable faults near the boundary (user I/O), log or translate them, retry or abort cleanly.
  2. Raise purposeful errors upstream when preconditions are not met so callers stop early.
  3. Avoid blanket except that hides KeyboardInterrupt or makes bugs hard to find.

The try suite

Structured form:

try:
    fragile()
except ExcType as err:
    handle(err)
except Another:
    fallback()
else:          # optional — runs ONLY if NO exception propagated from try-body
    on_success()
finally:       # ALWAYS runs leaving the suite (unless process dies)
    cleanup()

finally shines for releasing external resources — even when return lives inside try, finally still executes before the frame unwinds. When you are learning, trace a small example on paper before combining return and finally in production code.


Narrow exceptions win

Prefer except ValueError or except OSError over catching everything at once.

  • except ValueError: — you expected a string that might not parse.
  • except OSError: — a file or path operation failed.
  • except Exception: — a wide net for libraries; still does not catch KeyboardInterrupt or SystemExit (they inherit from BaseException, not Exception).

Bare except: (no type at all) traps everything, including KeyboardInterrupt — almost never justified.


raise and re-raise

Validators at function boundaries often end with:

if age < 0:
    raise ValueError("age must be non-negative")

Inside except, raise alone rethrows the caught exception and preserves context. raise NewError(...) from e attaches a clearer message while keeping the original cause in the traceback.

Custom types subclass Exception (not BaseException — reserve BaseException for system-level events like KeyboardInterrupt).


Assertions vs raises

assert looks like a guard:

def deposit(amount):
    assert amount > 0, "amount must be positive"
    ...

In normal runs, a failed assert raises AssertionError. That feels similar to raise ValueError(...), but the two are not interchangeable.

Critical detail: when Python runs with optimisation (python -O), all assert statements are removed. They compile to nothing. Code that relied on an assert for safety silently stops checking.

# Development only — fine inside your own tests / internal checks
assert len(items) == len(labels), "parallel lists must match"

# Public API — never rely on assert for untrusted input
def parse_port(text):
    # BAD: stripped away under -O; caller could pass "abc" with no error
    assert text.isdigit()
    return int(text)

For arguments from users, files, networks, or any code you do not fully control, raise a normal exception:

def parse_port(text):
    try:
        port = int(text)
    except ValueError as e:
        raise ValueError(f"not a valid port number: {text!r}") from e
    if not 1 <= port <= 65535:
        raise ValueError(f"port out of range: {port}")
    return port

Rule of thumb:

| Situation | Use | |-----------|-----| | "This should be impossible if my code is correct" (internal invariant) | assert | | "This input might actually be wrong" (user / file / API) | raise ValueError (or a narrower type) |

The practice quiz asks about this distinction — the examples tab walks through runnable assert vs raise code.


Exception hierarchy — types you will see often

| Type | Typical cause | Example | |------|----------------|---------| | ValueError | Right type, illegal value | int("twelve") | | TypeError | Operation on incompatible types | 3 + [] | | KeyError | Missing dict key | d["missing"] | | IndexError | List index out of range | items[99] on a short list | | OSError | OS / I/O failure (family) | open("nope.txt") → often FileNotFoundError |


Self-study checklist

  • For each except block, decide: retry, remap to a domain error, log and abort?
  • Explain when else runs versus finally.
  • Write one function that uses raise ValueError for bad input, and one internal assert that documents an invariant — and say why each choice fits.

Examples

Worked snippets — exceptions in practice

Guard with raise (public API)

Validate before you compute. Callers get a clear error instead of a confusing crash deeper in the call stack.

def pct(part, whole):
    if whole == 0:
        raise ZeroDivisionError("whole must be non-zero")
    if part < 0 or whole < 0:
        raise ValueError("negative inputs disallowed")
    return 100.0 * part / whole


print(pct(1, 4))   # 25.0
# pct(-1, 4)       # ValueError
# pct(1, 0)        # ZeroDivisionError

assert vs raise

assert is for internal checks you believe are always true if the program is correct. raise is for external input that might genuinely be wrong.

def naive_with_assert(text):
    """Do not ship this pattern for user input."""
    assert text.isdigit(), "expected digits only"
    return int(text)


def safe_port(text):
    """Validate untrusted text with raise — survives python -O."""
    try:
        port = int(text)
    except ValueError as e:
        raise ValueError(f"not an integer: {text!r}") from e
    if not 1 <= port <= 65535:
        raise ValueError(f"port out of range: {port}")
    return port


print(safe_port("443"))     # 443
# safe_port("abc")          # ValueError: not an integer: 'abc'
# safe_port("99999")        # ValueError: port out of range: 99999

Run the assert version under normal Python, then imagine python -O: the assert line disappears, so int("abc") would run and raise ValueError anyway — but int("12abc") or other cases might behave differently depending on your guard. The point is: never let optimisation remove your only safety check.

Internal invariant — assert is appropriate:

def normalize_weights(weights):
    total = sum(weights)
    assert total > 0, "caller must pass non-empty positive weights"
    return [w / total for w in weights]

Try / except / finally

def read_number(path):
    f = None
    try:
        f = open(path, encoding="utf-8")
        return int(f.read().strip())
    except (OSError, ValueError) as e:
        print(f"recoverable trouble: {e}")
        return None
    finally:
        if f is not None:
            f.close()

finally runs whether the try succeeded, raised, or **return**ed. Prefer with open(...) in real code; this version shows finally intent explicitly.

Modern short form with separate handlers:

from pathlib import Path

def read_number(path):
    try:
        return int(Path(path).read_text(encoding="utf-8").strip())
    except OSError:
        print("filesystem issue")
        return None
    except ValueError:
        print("contents not an integer")
        return None

Catch the narrow type

Handle the failure you can recover from; let unexpected errors propagate.

def parse_positive_int(text):
    try:
        n = int(text)
    except ValueError:
        print(f"not an integer: {text!r}")
        return None
    if n <= 0:
        raise ValueError(f"expected positive, got {n}")
    return n


print(parse_positive_int("42"))   # 42
print(parse_positive_int("x"))    # None (handled ValueError from int)
# parse_positive_int("-3")       # ValueError: expected positive, got -3

int(text) raises ValueError for "x" — we catch that and return None. A negative number parses successfully, so we raise ourselves: that is a business-rule failure, not a parse failure.


Re-raise with context

Translate a low-level error into a message your caller understands, but keep the original cause:

def load_config(path):
    try:
        raw = open(path, encoding="utf-8").read()
    except OSError as e:
        raise RuntimeError(f"could not read config {path!r}") from e
    return raw

from e chains the OSError into the traceback — invaluable when debugging.


Custom exception type

Subclass Exception when callers should catch a domain-specific failure:

class BoundsError(Exception):
    """Raised when no samples fall inside the requested window."""


def clipped_mean(xs, low, high):
    if low >= high:
        raise ValueError("low must be < high")
    filt = [x for x in xs if low <= x <= high]
    if not filt:
        raise BoundsError(f"no samples in [{low}, {high}]")
    return sum(filt) / len(filt)


print(clipped_mean([0.2, 0.5, 0.9], 0.0, 1.0))  # 0.533...
try:
    clipped_mean([5.0, 6.0], 0.0, 1.0)
except BoundsError as e:
    print("caught:", e)

Callers can except BoundsError without catching every ValueError in the program.


else runs only on a clean try

def divides(a, b):
    try:
        q = a // b
    except ZeroDivisionError:
        print("cannot divide by zero")
        return None
    else:
        print("quotient", q)
        return q


print(divides(10, 2))   # quotient 5, returns 5
print(divides(10, 0))   # cannot divide by zero, returns None

else is for code that should run only when no exception occurred — here, printing and returning the quotient after a successful division.

Practice check — exceptions

Multiple-choice check from the Matheion app lesson. Pick an answer for each question, then submit.

  1. 1.In a `try`/`finally`, if the `try` block executes `return 1`, does the `finally` block still run?

  2. 2.Which exception does `int('twelve')` raise?

  3. 3.When does the `else` clause of a `try` statement run?

  4. 4.Why is a bare `except:` (catching everything) discouraged?

  5. 5.For validating an untrusted argument in a public function, should you use `assert` or `raise`?