Intermediate Examples
Examples 29-60 cover comprehensions and generator expressions, functions (typed signatures, defaults,
keyword args, *args/**kwargs, multiple returns), lambdas and closures, scope, modules and imports,
exceptions, file I/O, JSON, and a first pass at classes. Run each example with python3 <file>.py
from inside its own directory unless a caption says otherwise.
Example 29: List Comprehension
ex-29 · exercises co-14
A list comprehension builds a list directly from an iterable and an expression, in one line -- no
append() build-up loop needed.
learning/code/ex-29-list-comprehension/example.py
"""Example 29: List Comprehension."""
# Builds a list directly -- no append() build-up loop needed.
squares: list[int] = [n * n for n in range(5)]
print(squares) # => Output: [0, 1, 4, 9, 16]Run: python3 example.py
Output:
[0, 1, 4, 9, 16]Key takeaway: [expr for name in iterable] is the comprehension shape -- read it as "expr, for
each name in iterable."
Why it matters: Comprehensions are the idiomatic Python replacement for the classic
result = []; for ...: result.append(...) pattern -- more compact, and (per DD-33's
abstraction-and-its-cost big idea) usually faster too, since the loop runs in C rather than
bytecode-by-bytecode.
Example 30: Comprehension Filter
ex-30 · exercises co-14, co-15
An optional trailing if filters elements before they're kept -- combining comprehension and
conditional in one expression.
learning/code/ex-30-comprehension-filter/example.py
"""Example 30: Comprehension Filter."""
# The `if` filters BEFORE an n is kept.
evens: list[int] = [n for n in range(6) if n % 2 == 0]
print(evens) # => Output: [0, 2, 4]Run: python3 example.py
Output:
[0, 2, 4]Key takeaway: the filter if comes right after the for clause, and only elements that pass it
reach the expression on the left.
Why it matters: This is the single most common comprehension shape in real code -- "give me the subset of X that matches condition Y" -- and it appears constantly in data-cleanup and filtering code, including the capstone's own input validation.
Example 31: Dict Comprehension
ex-31 · exercises co-14, co-11
A key: value pair inside {} builds a dict comprehension, not a set -- the colon is what
distinguishes the two forms.
learning/code/ex-31-dict-comprehension/example.py
"""Example 31: Dict Comprehension."""
# A `key: value` shape inside {} builds a dict, not a set.
squares: dict[int, int] = {n: n * n for n in range(3)}
print(squares) # => Output: {0: 0, 1: 1, 2: 4}Run: python3 example.py
Output:
{0: 0, 1: 1, 2: 4}Key takeaway: {key_expr: value_expr for name in iterable} -- the colon (not the braces) is what
makes this a dict comprehension.
Why it matters: Dict comprehensions are the idiomatic way to build a lookup table from an
iterable in one line -- transforming a list into an indexable structure without a manual
{}-then-loop-then-assign sequence.
Example 32: Set Comprehension
ex-32 · exercises co-14, co-12
{} with no colon builds a set comprehension -- duplicates collapse automatically, exactly like the
set() constructor in Example 20.
learning/code/ex-32-set-comprehension/example.py
"""Example 32: Set Comprehension."""
# {} with no colon builds a set -- duplicates collapse automatically.
lengths: set[int] = {len(w) for w in ["a", "bb", "cc"]}
print(sorted(lengths)) # => "bb" and "cc" both have length 2 -- [1, 2]Run: python3 example.py
Output:
[1, 2]Key takeaway: "a" has length 1; "bb" and "cc" both have length 2 -- the set collapses the
duplicate 2 down to one entry, leaving two unique lengths.
Why it matters: The four comprehension forms (list, dict, set, and Example 33's generator) all
share the identical for/if grammar -- learning one comprehension shape teaches you all four, only
the enclosing brackets (and the optional colon) change.
Example 33: Generator Expression
ex-33 · exercises co-14
A generator expression drops the brackets entirely -- values are produced lazily, one at a time, instead of building a full list in memory first.
learning/code/ex-33-generator-expression/example.py
"""Example 33: Generator Expression."""
# No brackets -- values are produced lazily, one at a time, for sum().
total: int = sum(n * n for n in range(4))
print(total) # => 0+1+4+9 -- Output: 14Run: python3 example.py
Output:
14Key takeaway: sum(n * n for n in range(4)) never builds an intermediate list -- sum() consumes
the generator's values one at a time as it produces them.
Why it matters: For large inputs, the memory difference between a list comprehension and a
generator expression is the entire point (DD-33's abstraction-and-its-cost idea again): a
list comprehension over a million elements allocates a million-element list; a generator expression
over the same range allocates almost nothing.
Example 34: Nested Comprehension
ex-34 · exercises co-14
Multiple for clauses inside one comprehension read left to right, outer loop first -- this one
flattens a list of lists into a single flat list.
learning/code/ex-34-nested-comprehension/example.py
"""Example 34: Nested Comprehension."""
matrix: list[list[int]] = [[1, 2], [3, 4]] # => matrix is [[1, 2], [3, 4]]
# Two `for` clauses, outer then inner -- reads left to right.
flat: list[int] = [n for row in matrix for n in row] # => flat is [1, 2, 3, 4]
print(flat) # => Output: [1, 2, 3, 4]Run: python3 example.py
Output:
[1, 2, 3, 4]Key takeaway: for row in matrix for n in row reads exactly like the equivalent nested for
loop written top to bottom -- row iterates the outer list, n iterates each inner list.
Why it matters: Nested comprehensions are readable up to about two for clauses; beyond that,
most style guides (including this repository's own conventions) recommend a plain nested loop instead
-- comprehensions optimize for the reader, and a three-level nested comprehension usually fails that
test.
Example 35: Define Typed Function
ex-35 · exercises co-17, co-06
def defines a reusable callable; -> int annotates the return type, checked statically by
pyright without changing what the function actually does at runtime.
learning/code/ex-35-define-typed-function/example.py
"""Example 35: Define Typed Function."""
# `-> int` annotates the RETURN type, checked statically by pyright.
def add(a: int, b: int) -> int: # => defines add, takes two ints, returns an int
return a + b # => returns the sum of a and b
print(add(2, 3)) # => Output: 5Run: python3 example.py
Output:
5Key takeaway: every parameter and the return value are annotated -- pyright verifies this
signature is honored at every call site, though python3 itself never checks it (Example 84 proves
that gap directly).
Why it matters: This is the canonical shape every function in this primer follows from here on: typed parameters, a typed return, and a docstring-worthy name. It is also the DD-39 baseline every other example's functions are held to.
Example 36: Default Args
ex-36 · exercises co-17
A parameter with = value in its signature becomes optional -- the caller may omit it, and the
default is used instead.
learning/code/ex-36-default-args/example.py
"""Example 36: Default Args."""
# name is optional -- falls back to "world" when the caller omits it.
# Defines greet with a default parameter value.
def greet(name: str = "world") -> str:
return f"Hello, {name}" # => builds and returns the greeting string
print(greet()) # => no argument -- uses the default -- Output: Hello, world
print(greet("Ada")) # => argument supplied -- Output: Hello, AdaRun: python3 example.py
Output:
Hello, world
Hello, AdaKey takeaway: name: str = "world" both types the parameter and gives it a default -- the two
annotations compose without conflict.
Why it matters: Default arguments are how a huge share of real-world Python APIs stay usable with zero configuration while still allowing full customization -- most standard-library and third-party functions lean on this pattern extensively.
Example 37: Keyword Args
ex-37 · exercises co-17
Any parameter can be passed by name at the call site (b=3, a=10), in any order -- Python matches
keyword arguments by name, not position.
learning/code/ex-37-keyword-args/example.py
"""Example 37: Keyword Args."""
# Defines subtract, which takes two ints and returns an int.
def subtract(a: int, b: int) -> int:
return a - b # => returns a minus b
# Keyword arguments can be passed in any order -- the names, not position, bind them.
by_position: int = subtract(10, 3) # => positional order: a=10, b=3
by_keyword: int = subtract(b=3, a=10) # => named, REVERSED order -- still a=10, b=3
print(by_position, by_keyword, by_position == by_keyword) # => Output: 7 7 TrueRun: python3 example.py
Output:
7 7 TrueKey takeaway: subtract(b=3, a=10) binds by name, not position -- writing the keywords in
reverse order produces the identical result as subtract(10, 3).
Why it matters: Keyword arguments make call sites self-documenting for functions with several parameters of the same type (where positional order is easy to get wrong) -- a real habit worth adopting whenever a function takes more than two or three parameters.
Example 38: *args and **kwargs
ex-38 · exercises co-18
*args collects any extra positional arguments into a tuple; **kwargs collects any extra keyword
arguments into a dict -- together they let a function accept an arbitrary, flexible set of arguments.
learning/code/ex-38-args-kwargs/example.py
"""Example 38: *args and **kwargs."""
def describe(*args: int, **kwargs: str) -> None:
# args is a tuple, kwargs is a dict -- both are countable with len().
print(len(args), len(kwargs))
describe(1, 2, 3, name="Ada", role="engineer") # => 3 positional, 2 keyword
# => Output: 3 2Run: python3 example.py
Output:
3 2Key takeaway: *args: int types every element inside the tuple as int, not the tuple
itself; **kwargs: str likewise types every dict value.
Why it matters: *args/**kwargs is how Python functions accept a genuinely variable number of
arguments -- the same mechanism print() itself uses to accept any number of values, and the pattern
behind most "wrapper" or "pass-through" functions.
Example 39: Return Tuple
ex-39 · exercises co-17, co-10
A bare comma-separated expression after return builds a tuple implicitly -- the standard way to
return more than one value from a single function.
learning/code/ex-39-return-tuple/example.py
"""Example 39: Return Tuple."""
# Returns a 2-tuple of (quotient, remainder).
def divide(a: int, b: int) -> tuple[int, int]:
return a // b, a % b # => a bare comma builds a tuple -- two values, one return
# Tuple unpacking assigns each returned value to a name, in order.
quotient, remainder = divide(10, 3) # => unpacks the returned tuple into two names
print(quotient, remainder) # => 10 // 3 = 3, 10 % 3 = 1 -- Output: 3 1Run: python3 example.py
Output:
3 1Key takeaway: -> tuple[int, int] documents the exact two-value shape being returned; the caller
unpacks it with Example 16's x, y = ... pattern.
Why it matters: This is precisely how the built-in divmod(a, b) function works, and it is the
idiomatic Python alternative to an out-parameter or a wrapper object when a function has exactly two
or three naturally-related results to return.
Example 40: Lambda Sort
ex-40 · exercises co-19
A lambda is a small, anonymous, single-expression function -- most often used inline as a key=
argument, here to sort by each tuple's first element.
learning/code/ex-40-lambda-sort/example.py
"""Example 40: Lambda Sort."""
pairs: list[tuple[str, int]] = [("b", 2), ("a", 1)] # => pairs is [("b", 2), ("a", 1)]
# A lambda is a small anonymous function -- here, key=lambda p: p[0] extracts a sort key.
pairs.sort(key=lambda p: p[0]) # => key extracts the first element to sort by
print(pairs) # => sorted by letter, not original order -- Output: [('a', 1), ('b', 2)]Run: python3 example.py
Output:
[('a', 1), ('b', 2)]Key takeaway: .sort(key=...) calls the key function once per element and sorts by its return
value -- lambda p: p[0] here means "sort by each tuple's first element."
Why it matters: A lambda is not a full function -- it can only hold a single expression, no
statements, no assignments. Anything more complex than a one-liner belongs in a named def function
instead; this scope limit is deliberate, not a workaround.
Example 41: Closure Counter
ex-41 · exercises co-19
A closure is a nested function that remembers variables from its enclosing scope even after that
scope has returned -- nonlocal is required to mutate (not just read) an enclosing variable.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
flowchart LR
A["make_counter() call<br/>creates count = 0"]:::blue
B["increment (closure)<br/>captures count BY REFERENCE"]:::orange
C["counter() call 1<br/>count becomes 1"]:::teal
D["counter() call 2<br/>count becomes 2"]:::purple
E["counter() call 3<br/>count becomes 3"]:::brown
A --> B --> C --> D --> E
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef purple fill:#CC78BC,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef brown fill:#CA9161,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-41-closure-counter/example.py
"""Example 41: Closure Counter."""
# Imports Callable for typing the returned function.
from collections.abc import Callable
# Returns a function that takes no args and returns an int.
def make_counter() -> Callable[[], int]:
count = 0 # => count is an upvalue -- captured by the nested function below
# Defines the closure that reads and writes the outer count.
def increment() -> int:
nonlocal count # => without this, `count += 1` would create a NEW local count
count += 1 # => mutates the captured count, not a fresh local variable
return count # => returns the updated count after incrementing
return increment # => returns the closure itself, not a call to it
# Each call to make_counter() creates a FRESH count -- closures don't share state.
counter = make_counter() # => counter is now bound to one increment closure, count=0
print(counter()) # => Output: 1
print(counter()) # => Output: 2
print(counter()) # => Output: 3Run: python3 example.py
Output:
1
2
3Key takeaway: make_counter() runs once and returns the increment function itself (not its
result) -- each subsequent call to counter() reuses and mutates the same captured count.
Why it matters: Without nonlocal count, count += 1 inside increment would raise
UnboundLocalError -- assignment inside a nested function creates a new local variable by default,
and nonlocal is the explicit opt-in to mutate the enclosing one instead.
Example 42: Scope, global and local
ex-42 · exercises co-19
global is nonlocal's module-level counterpart -- required to mutate a module-level variable from
inside a function, for the same underlying reason.
learning/code/ex-42-scope-global-local/example.py
"""Example 42: Scope, global and local."""
# Without `global`, assigning to a name inside a function creates a LOCAL shadow.
total: int = 0 # => a module-level (global) variable
# Defines a function that mutates the global total.
def add_to_total(amount: int) -> None:
global total # => without this, `total += amount` would raise UnboundLocalError
total += amount # => mutates the GLOBAL total, not a local shadow of it
add_to_total(5) # => total is now 5
add_to_total(7) # => total is now 12
print(total) # => 0+5+7 -- Output: 12Run: python3 example.py
Output:
12Key takeaway: global total inside add_to_total is what lets total += amount mutate the
module-level total rather than raising UnboundLocalError or creating a shadow local.
Why it matters: Mutating module-level state from inside functions is a pattern to reach for sparingly -- it makes a function's behavior depend on hidden external state, which is harder to test and reason about than a pure function (this repo's own functional-core convention prefers the latter wherever practical).
Example 43: map + filter
ex-43 · exercises co-19, co-16
filter() and map() are the functional-style counterparts to a comprehension's if and expression
clauses -- both are lazy, and both compose naturally with a lambda.
learning/code/ex-43-map-filter/example.py
"""Example 43: map + filter."""
# map() applies a function to every element; filter() keeps only elements where
# the predicate returns True. Both are lazy -- list() forces full evaluation.
result: list[int] = list(
map( # => map() lazily applies the lambda below to each filtered element
lambda n: n * 2, # => doubles each surviving element
filter(lambda n: n % 2 == 0, range(5)), # => keeps only 0, 2, 4 first
) # => closes map(...)
) # => closes list(...), forcing evaluation of the whole pipeline
print(result) # => [0, 2, 4] doubled -- Output: [0, 4, 8]Run: python3 example.py
Output:
[0, 4, 8]Key takeaway: filter(pred, iterable) keeps only elements where pred is truthy; map(func, iterable) applies func to every remaining element -- both need list(...) to materialize their
lazy result.
Why it matters: The equivalent comprehension, [n * 2 for n in range(5) if n % 2 == 0], is
considered more idiomatic Python for this exact case -- most style guides (including PEP 8's spirit)
prefer comprehensions over map/filter chains when a lambda is involved, precisely because the
comprehension reads left to right instead of inside-out.
Example 44: Import Stdlib math
ex-44 · exercises co-20
import math loads the standard library's math module -- no pip install needed, since it ships
with every CPython installation.
learning/code/ex-44-import-stdlib-math/example.py
"""Example 44: Import Stdlib math."""
import math # => the standard library's math module -- no pip install needed
print(math.sqrt(16)) # => math.sqrt ALWAYS returns a float -- Output: 4.0Run: python3 example.py
Output:
4.0Key takeaway: math.sqrt() always returns a float, even for a perfect square like 16 -- the
result is 4.0, not the int 4.
Why it matters: The standard library is enormous and ships with every Python install --
math, statistics (Example 45), json (Examples 55-58), and argparse (Example 61) are exactly
the modules this primer leans on, all with zero extra install step.
Example 45: from ... import
ex-45 · exercises co-20
from module import name imports just one specific name, used unqualified afterward -- as opposed to
import module, which requires the module. prefix at every use.
learning/code/ex-45-from-import/example.py
"""Example 45: from ... import."""
from statistics import mean # => imports just the one name, used unqualified below
print(mean([1, 2, 3])) # => (1+2+3)/3 = 2 -- Output: 2Run: python3 example.py
Output:
2Key takeaway: statistics.mean([1, 2, 3]) returns the plain int 2, not 2.0 -- the standard
library's mean() returns an int when the arithmetic mean is itself a whole number.
Why it matters: from module import name trades explicitness (you can no longer tell at the call
site which module mean came from, without checking the imports) for brevity -- a real style
trade-off, not a strictly better choice than import statistics; statistics.mean(...).
Example 46: __name__ == "__main__" Guard
ex-46 · exercises co-20
__name__ is "__main__" only when a file is run directly -- never when it's imported by another
module -- which is what makes this guard the standard way to separate library code from script entry
points.
learning/code/ex-46-name-main-guard/mod.py
"""Example 46: __name__ == "__main__" Guard."""
def main() -> None: # => defines the entry-point function
print("running as a script") # => Output (only on direct run)
# __name__ is "__main__" only when run directly, never when imported.
if __name__ == "__main__": # => True only when this file is executed directly
main() # => calls main(), which prints the line aboveRun: python3 mod.py (direct run -- prints); versus python3 -c "import mod" (import -- silent)
Output (direct run):
running as a scriptOutput (import, no direct run):
(No output at all -- main() is never called on import, because __name__ is "mod", not
"__main__", in that context.)
Key takeaway: the exact same file behaves as a runnable script when executed directly and as a
silent, importable library when imported -- the if __name__ == "__main__": guard is what makes both
possible from one file.
Why it matters: Example 47's sibling-module import relies on exactly this guard's absence of
side effects -- if greeting.py printed something at import time instead of only defining functions,
importing it would have unwanted output. Every reusable module in this primer follows this same
"define, don't execute" discipline at import time.
Example 47: Custom Module Import
ex-47 · exercises co-20
Importing a sibling file works exactly like importing a standard-library module -- Python resolves
greeting to greeting.py in the same directory, with no special syntax required.
learning/code/ex-47-custom-module-import/greeting.py
"""Example 47: sibling module -- greeting.py, imported by example.py."""
def shout(name: str) -> str: # => a typed, importable function -- no name guard needed
return f"HELLO, {name.upper()}!" # => uppercases name and wraps it in HELLO, ...!learning/code/ex-47-custom-module-import/example.py
"""Example 47: Custom Module Import."""
# Python resolves sibling-directory imports via the current working directory.
from greeting import shout # => imports the sibling file greeting.py's shout() function
print(shout("Ada")) # => Output: HELLO, ADA!Run: python3 example.py
Output:
HELLO, ADA!Key takeaway: from greeting import shout works because greeting.py sits in the same
directory as example.py -- Python's default import path includes the running script's own
directory.
Why it matters: This is the simplest possible multi-file Python program -- one file defining
reusable functions, one file consuming them. Example 64's package (multiple files under one directory
with an __init__.py) builds directly on this same mechanism at a larger scale.
Example 48: try/except
ex-48 · exercises co-21
try/except catches a specific exception type and runs an alternate code path instead of crashing
the whole program.
learning/code/ex-48-try-except/example.py
"""Example 48: try/except."""
# try/except lets the program continue after an exception instead of crashing.
try: # => wraps the risky operation so we can catch its exception
1 / 0 # => integer division by zero raises ZeroDivisionError
except ZeroDivisionError: # => catches exactly that exception type, nothing else
print("cannot divide") # => Output: cannot divideRun: python3 example.py
Output:
cannot divideKey takeaway: except ZeroDivisionError: catches only that exact exception type (and its
subclasses) -- an unrelated exception raised in the same try block would propagate uncaught.
Why it matters: Catching the narrowest exception type that applies (rather than a bare
except:) is the idiomatic Python style -- a bare except: would also silently swallow genuine bugs
like KeyboardInterrupt or a typo'd variable name's NameError.
Example 49: try/except/else/finally
ex-49 · exercises co-21
All four clauses can appear together: try (the risky code), except (error handling), else (runs
only if try raised nothing), and finally (always runs, no matter what).
learning/code/ex-49-try-except-else-finally/example.py
"""Example 49: try/except/else/finally."""
# else runs only when try raises nothing; finally always runs, success or failure.
try: # => runs the block below; no exception occurs in this example
print("try") # => runs first -- Output line 1: try
except ValueError: # => would run only if try raised a ValueError
print("except") # => skipped -- no exception was raised
else: # => runs only when try completes with NO exception
print("else") # => runs ONLY if try raised nothing -- Output line 2: else
finally: # => runs unconditionally, whether try succeeded or an exception fired
print("finally") # => ALWAYS runs, exception or not -- Output line 3: finallyRun: python3 example.py
Output:
try
else
finallyKey takeaway: else runs only on the success path (no exception), and finally runs
unconditionally -- on success, on a caught exception, and even on an uncaught one propagating past
this block.
Why it matters: finally is where cleanup code belongs -- closing a resource, releasing a lock --
because it is the one clause guaranteed to run regardless of how the try block exits. with
blocks (Examples 52-54) build this same guarantee into the context-manager protocol directly.
Example 50: raise ValueError
ex-50 · exercises co-21
raise signals an error explicitly. With no surrounding try/except, the exception propagates all
the way up and crashes the process with a non-zero exit code.
learning/code/ex-50-raise-valueerror/example.py
"""Example 50: raise ValueError."""
# raise stops execution immediately and propagates the exception up the call stack.
def parse_age(raw: str) -> int: # => defines parse_age, converts and validates a string
# int(raw) itself raises ValueError first if raw isn't numeric at all.
age = int(raw) # => converts raw to int
if age < 0: # => an extra validation check beyond what int() already does
raise ValueError("bad input") # => no except catches this here
return age # => only reached when age is >= 0
parse_age("-1") # => uncaught -- crashes with a traceback, non-zero exitRun: python3 example.py
Output (genuinely captured -- an uncaught exception prints a full traceback to stderr):
Traceback (most recent call last):
File ".../ex-50-raise-valueerror/example.py", line 13, in <module>
parse_age("-1") # => uncaught -- crashes with a traceback, non-zero exit
~~~~~~~~~^^^^^^
File ".../ex-50-raise-valueerror/example.py", line 9, in parse_age
raise ValueError("bad input") # => no except catches this here
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
ValueError: bad inputExit code: non-zero ($? is 1).
Key takeaway: an unhandled raise prints a full traceback (the call stack at the moment of the
error, innermost frame last) and exits non-zero -- there is no silent failure mode in Python.
Why it matters: A traceback's final line, ValueError: bad input, is usually the fastest way to
diagnose a crash -- exception type plus message. The frames above it show exactly how execution got
there, which is why "read the last line first, then walk up if you need more context" is a genuinely
useful debugging habit.
Example 51: Catch Specific Exceptions
ex-51 · exercises co-21
Multiple except clauses on the same try block let one function handle different failure modes
differently -- the first matching except (top to bottom) runs.
learning/code/ex-51-catch-specific-exceptions/example.py
"""Example 51: Catch Specific Exceptions."""
# Each except clause catches only its own exception type -- order matters when
# exception types overlap via inheritance (not the case here).
def handle(trigger: str) -> str: # => defines handle, dispatches on the trigger string
data: dict[str, int] = {"a": 1} # => data has only key "a" -- "missing" is absent
try: # => wraps both risky operations so either exception type can be caught below
if trigger == "value": # => branch that deliberately raises ValueError
int("not a number") # => raises ValueError
else: # => branch that deliberately raises KeyError
data["missing"] # => raises KeyError
except ValueError: # => catches ONLY ValueError, not KeyError
return "caught ValueError" # => runs only for the "value" trigger
except KeyError: # => catches ONLY KeyError, not ValueError
return "caught KeyError" # => runs only for the other trigger
return "unreachable" # => never runs -- one except always returns first
print(handle("value")) # => Output: caught ValueError
print(handle("key")) # => Output: caught KeyErrorRun: python3 example.py
Output:
caught ValueError
caught KeyErrorKey takeaway: each except clause only matches its own exception type -- handle("value") never
touches the KeyError branch, and vice versa.
Why it matters: Stacking narrow except clauses (rather than one broad except Exception:)
means each failure mode gets its own explicit, intentional handling -- exactly the discipline that
keeps error-handling code from silently masking bugs it wasn't actually designed to catch.
Example 52: Read Text File
ex-52 · exercises co-22
open() inside a with block reads a file and guarantees it closes afterward, even if an exception
is raised partway through -- the context-manager protocol Example 71 later implements from scratch.
learning/code/ex-52-read-text-file/data.txt
line1
line2learning/code/ex-52-read-text-file/example.py
"""Example 52: Read Text File."""
with open("data.txt") as f: # => `with` guarantees the file closes, even on error
print(f.read(), end="") # => .read() returns the WHOLE file as one string
# => Output: line1<newline>line2<newline>Run: python3 example.py
Output:
line1
line2Key takeaway: f.read() returns the entire file as one str, newlines included -- print(..., end="") avoids adding an extra blank line, since the file's own trailing newline is already part of
that string.
Why it matters: The with block is doing real work here, not just tidiness: if f.read() raised
an exception partway through, the file handle would still close automatically -- the same guarantee
finally gives you in Example 49, built directly into the open() object itself.
Example 53: Write Text File
ex-53 · exercises co-22
open(path, "w") opens a file for writing, truncating any existing content first -- .write() does
not add a newline automatically, unlike print().
learning/code/ex-53-write-text-file/example.py
"""Example 53: Write Text File."""
with open("out.txt", "w") as f: # => "w" truncates the file, then opens it for writing
f.write("line1\n") # => .write() does NOT add a newline automatically
f.write("line2\n")
with open("out.txt") as f: # => a fresh `with`, reading back what was just written
print(repr(f.read())) # => repr() shows the embedded \n characters explicitly
# => Output: 'line1\nline2\n'Run: python3 example.py
Output:
'line1\nline2\n'Key takeaway: f.write("line1\n") writes exactly those five characters plus a newline --
.write() never inserts a newline on your behalf, so every line needs its own explicit \n.
Why it matters: repr(f.read()) (rather than plain print(f.read())) is a genuinely useful
debugging trick -- it shows escape sequences like \n literally, instead of rendering them as actual
line breaks, which makes it obvious exactly what characters a string contains.
Example 54: Append File
ex-54 · exercises co-22
open(path, "a") opens a file for appending -- unlike "w", it preserves whatever content was
already there and adds new content after it.
learning/code/ex-54-append-file/example.py
"""Example 54: Append File."""
# Each `with` block closes its file automatically when the block exits.
with open("log.txt", "w") as f: # => "w" starts the file fresh, discarding old content
f.write("first\n") # => writes the first line to log.txt
with open("log.txt", "a") as f: # => "a" appends -- prior content is kept
f.write("second\n") # => appends a second line after "first\n"
with open("log.txt") as f: # => default mode "r" opens the file for reading
print(f.read(), end="") # => Output: first<newline>second<newline>Run: python3 example.py
Output:
first
secondKey takeaway: "w" (write) truncates; "a" (append) preserves and adds after -- picking the
wrong mode is a classic way to accidentally lose file content.
Why it matters: Log files, in particular, almost always want "a" mode -- each new run of a
program should add to the log's history, not erase everything a previous run wrote.
Example 55: json.dumps
ex-55 · exercises co-23
json.dumps() serializes a Python object into a JSON-formatted string -- the in-memory counterpart to
Example 57's json.dump(), which writes directly to a file.
learning/code/ex-55-json-dumps/example.py
"""Example 55: json.dumps."""
import json
# Serializes a dict to a JSON-formatted str.
print(json.dumps({"a": 1})) # => Output: {"a": 1}Run: python3 example.py
Output:
{"a": 1}Key takeaway: json.dumps() returns a str you can print, log, or send over a network --
json.dump() (Example 57, no trailing s) writes to a file object instead of returning a string.
Why it matters: JSON is the default interchange format for this primer's file-I/O examples
(57-58) and the capstone -- json.dumps/json.loads (in-memory) and json.dump/json.load
(file-based) are the entire API surface needed to work with it.
Example 56: json.loads
ex-56 · exercises co-23
json.loads() parses a JSON string into native Python objects -- a JSON object becomes a dict, a
JSON array becomes a list, and so on.
learning/code/ex-56-json-loads/example.py
"""Example 56: json.loads."""
import json # => imports the standard-library json module
# json.loads() parses a JSON string into Python objects (dict, list, str, int, ...).
data = json.loads('{"a": 1}') # => parses a JSON str into a Python dict
print(data["a"]) # => Output: 1Run: python3 example.py
Output:
1Key takeaway: json.loads() (parse a string) is json.dumps()'s exact inverse -- together
they round-trip any JSON-compatible data structure through Python.
Why it matters: This is the same operation Example 69's people.json-reading example and the
capstone's own input pipeline both rely on, just applied to a file's contents (via json.load, no
s) instead of a string literal.
Example 57: json.dump to a File
ex-57 · exercises co-23, co-22
json.dump() (no trailing s) writes directly to an open file object, combining Example 53's file
writing with Example 55's serialization in one call.
learning/code/ex-57-json-dump-file/example.py
"""Example 57: json.dump to a File."""
import json # => imports the standard-library json module
# json.dump()/json.load() write/read directly to/from a file object -- no
# intermediate string round-trip needed, unlike dumps()/loads().
original: dict[str, int] = {"a": 1, "b": 2} # => original is {"a": 1, "b": 2}
# "w" opens out.json for writing, truncating it if it already exists.
with open("out.json", "w") as f:
json.dump(original, f) # => writes directly to a file object
with open("out.json") as f: # => reopens out.json for reading (default mode "r")
restored = json.load(f) # => reads directly from a file object
print(restored == original) # => roundtrip preserves the data -- Output: TrueRun: python3 example.py
Output:
TrueKey takeaway: json.dump(obj, f) and json.load(f) take a file object, not a path string --
you're responsible for opening (and, via with, closing) the file yourself.
Why it matters: restored == original demonstrates dict equality compares contents, not
identity -- two separately-built dicts with the same keys and values are equal, exactly the property
the capstone's own tests (Example 74's pytest pattern) lean on to verify JSON roundtrips.
Example 58: json.load from a File
ex-58 · exercises co-23, co-22
json.load() is json.loads()'s file-based counterpart -- reads and parses JSON directly from an
open file object in one call.
learning/code/ex-58-json-load-file/example.py
"""Example 58: json.load from a File."""
import json # => imports the standard-library json module
# json.load() reads and parses JSON directly from an open file object.
with open("config.json", "w") as f: # => opens config.json for writing
json.dump({"theme": "dark"}, f) # => writes a one-key config file
with open("config.json") as f: # => reopens config.json for reading
config = json.load(f) # => reads it back into a plain dict
print(config["theme"]) # => Output: darkRun: python3 example.py
Output:
darkKey takeaway: json.load(f) returns whatever the JSON's top-level shape was -- a dict for a
JSON object, a list for a JSON array -- ready to index or iterate immediately.
Why it matters: Config files, API responses cached to disk, and structured log output are all
common real-world uses of exactly this json.dump/json.load file round-trip -- the same shape the
capstone's in.json input file follows.
Example 59: Class Basics
ex-59 · exercises co-24
A class bundles typed attributes and methods together; __init__ runs automatically when an
instance is created, and self refers to that specific instance inside every method.
learning/code/ex-59-class-basics/example.py
"""Example 59: Class Basics."""
class Point: # => defines a new class named Point
# Runs automatically on Point(...) -- the constructor.
def __init__(self, x: int, y: int) -> None:
self.x = x # => stores x as an instance attribute
self.y = y # => stores y as an instance attribute
# self is the instance the method was called on; this mutates it in place.
def move(self, dx: int, dy: int) -> None:
self.x += dx # => adds dx to the instance's current x
self.y += dy # => adds dy to the instance's current y
p = Point(1, 2) # => calls __init__(p, 1, 2) under the hood
p.move(3, 4) # => mutates p.x and p.y in place
print(p.x, p.y) # => 1+3=4, 2+4=6 -- Output: 4 6Run: python3 example.py
Output:
4 6Key takeaway: p = Point(1, 2) implicitly calls __init__(p, 1, 2) -- self is always the
first parameter of an instance method, supplied automatically by Python, never written at the call
site.
Why it matters: This is the minimal shape of a Python class -- state (self.x, self.y) plus
behavior (.move()) bundled together. Example 60 adds a __repr__, and Example 71's context manager
and Example 65's custom exception both build on this exact same __init__/self foundation.
Example 60: Class __repr__
ex-60 · exercises co-24, co-06
__repr__ is a "dunder" (double-underscore) method that print() and the interactive REPL both call
automatically to get a human-readable string for an object -- without it, printing an instance shows
an unhelpful default like <__main__.Point object at 0x...>.
learning/code/ex-60-class-repr/example.py
"""Example 60: Class __repr__."""
class Point: # => defines a new class named Point
# The constructor, called automatically by Point(x, y).
def __init__(self, x: int, y: int) -> None:
self.x = x # => stores x as an instance attribute
self.y = y # => stores y as an instance attribute
# print() and the REPL both call __repr__ automatically.
def __repr__(self) -> str: # => defines the string shown by print() and the REPL
return f"Point(x={self.x}, y={self.y})" # => builds a readable representation
print(Point(1, 2)) # => Output: Point(x=1, y=2)Run: python3 example.py
Output:
Point(x=1, y=2)Key takeaway: defining __repr__ -> str is enough to make print(some_instance) show
meaningful output -- Python calls it automatically, no explicit .repr() call needed anywhere.
Why it matters: A good __repr__ (ideally one that looks like valid Python that could recreate
the object, exactly as this one does) turns debugging sessions and test failure output from opaque
memory addresses into readable, actionable information -- Example 67's dataclass gets this for free,
without writing __repr__ by hand at all.
← Previous: Beginner Examples · Next: Advanced Examples →
Last updated July 13, 2026