Two professional finishers. The first part covers context managers: the with statement, __enter__ and __exit__, cleanup that is guaranteed even when an error is raised, writing a class-based context manager, and the shorter form built from contextlib.contextmanager and yield. The second part covers type hints: annotating parameters and returns, as in def f(x: int) -> str, variable annotations, list[int] and dict[str, int], Optional and None with the X | None shorthand, and the key surprise that hints are NOT enforced at runtime, so a mis-typed call still runs. The traps are assuming that type hints validate types at runtime, and cleanup being skipped when you manage a resource by hand and an error strikes part-way through. Every snippet and every traceback was executed and copied verbatim from CPython 3.12.
Subject: Python Fundamentals · 101 slides · code lesson
Open the interactive version of this deck · Homework for this lesson
Title
Python Fundamentals - Session 32
Two finishers: guaranteed cleanup, and code that documents itself
Objectives
You can write functions, loops, and error handling. This session adds two touches that make code look professional. By the end you can:
with so cleanup happens automatically, even when an error is raised.__enter__/__exit__, and @contextmanager + yield.def f(x: int) -> str.list[int], dict[str, int]) and for maybe-None values.Warm-up
Discussion prompt
Before we open Session 32 - Context Managers & Type Hints: without looking back, what was the main idea of Session 31 - Decorators, and what could you do by the end of it that you could not do before?
Hint: One sentence for the idea, one for the skill. If the second one is blank, that is the part to revisit.
Answer:
Session 31 of the Python Fundamentals series, in depth. A decorator is a function that wraps another function to add behavior around it.
Section
Part 1
Concept
Open a file, and you must remember to close it. Grab a lock, and you must release it. The 'undo' step is easy to skip - especially when an error jumps out first.
A context manager makes that undo step automatic. You describe the cleanup once; Python guarantees it runs.
Counterexample
Discussion prompt
Open a file, and you must remember to close it. Grab a lock, and you must release it. The 'undo' step is easy to skip - especially when an error jumps out first.
That is stated as though it always holds. Do one of two things: produce a case where it fails, or say precisely what rules such a case out. "It just does" is not on the menu.
Hint: Hunt at the extremes first — zero, one, negative, empty, equal. If every extreme survives, the reason they survive is the proof.
Answer:
A context manager makes that undo step automatic. You describe the cleanup once; Python guarantees it runs.
Concept
with resource as name: opens the resource, runs your indented block, then closes the resource - whether the block finished normally or blew up.
context manager — An object you use in a with statement. It sets something up when the block starts and tears it down when the block ends - guaranteed.
Analogy
Discussion prompt
Explain with runs setup and cleanup for you by analogy to something with no Python Fundamentals in it at all — a queue, a recipe, a map, a bank balance, whatever fits. Then say where your analogy breaks.
Hint: An analogy that never breaks is not an analogy, it is the same idea wearing a hat. Find the seam — that is the part that is actually new.
Answer:
with resource as name: opens the resource, runs your indented block, then closes the resource - whether the block finished normally or blew up.
Ranking
Put in order
Put the moves of Open a file with with into the order they have to happen.
Why: These are the moves of the worked example in the order it makes them, and each one is set up by the one before it. open(...) returns a file object that is also a context manager.
Worked example
with open("notes.txt", "w") as f:
f.write("hello")
print(f.closed)with opens the file and binds it to f
Why: open(...) returns a file object that is also a context manager. as f names it for the block.
The block writes, then with closes the file
Why: You never call f.close(). When the block ends, with closes it for you.
Read the output
Why: Verified by execution: prints True. By the time line 3 runs, the file is already closed.
| line | what happens | output |
|---|---|---|
| with open(...) | file opens, f bound | - |
| f.write("hello") | text written | - |
| print(f.closed) | file already closed | True |
Comparison
Comparison matrix
From Open a file with with: refill the what happens column from what you know. The rest of the table is as it appeared.
| line | what happens | output |
|---|---|---|
| with open(...) | file opens, f bound | - |
| f.write("hello") | text written | - |
| print(f.closed) | file already closed | True |
Intuition
Think of borrowing a library book. with is a librarian standing beside you: the moment you finish (or storm off), they take the book back and check it in.
You focus on the reading. The return is handled for you - you cannot forget it, because it is not your job anymore.
Explain it
Discussion prompt
Explain with is borrow-and-return to a student a year behind you. No notation, no jargon they have not met — and it still has to be true.
Hint: If your explanation needs a symbol they have never seen, you are describing the notation rather than the idea.
Answer:
Think of borrowing a library book. with is a librarian standing beside you: the moment you finish (or storm off), they take the book back and check it in.
Concept
The as name part binds whatever the context manager hands out at the start. For open, that is the file object; you use name inside the block.
as is optional. If you do not need the object itself - only its setup and cleanup - you can write with thing: with no as.
Section
Part 2
Concept
The real payoff: if an error is raised inside the block, with still runs the cleanup on the way out, and then lets the error continue.
This is what makes with safer than closing by hand - an exception cannot skip past the cleanup.
Step zero
Discussion prompt
The file closes even when the block errors — before any calculation: what is the plan? Name the moves in order, in plain English, without doing the arithmetic.
Hint: It starts with: The block raises before it finishes normally
Answer:
Worked example
try:
with open("out.txt", "w") as f:
f.write("partial")
raise ValueError("boom")
except ValueError:
print("f.closed is", f.closed)The block raises before it finishes normally
Why: Line 4 raises ValueError while the file is still open.
with closes the file on the way out anyway
Why: The error unwinds through with, which closes the file, then the except catches it.
Read the output
Why: Verified by execution: prints f.closed is True. The cleanup happened despite the error.
| step | state of f | output |
|---|---|---|
| open(...) as f | open | - |
| raise ValueError | with unwinds | - |
| with closes f | closed | - |
| except prints | closed | f.closed is True |
Discrimination
Sort into buckets
Sort these by state of f, from memory, without looking back at The file closes even when the block errors. Telling them apart on the spot is the skill; the table is only where the answer happens to be written down.
Anomaly
Predict first
A student writes this, and it looks reasonable:
You open and close by hand - and an error strikes between the two.
It is wrong. Say what breaks — and say it before you turn the page.
Correct: Verified by execution: prints opened, then the ValueError traceback.
Make it a context manager and let with own the cleanup.
Why: Verified by execution: prints opened, then the ValueError traceback. closed never prints - the resource was left open.
Trap
You open and close by hand - and an error strikes between the two.
class Conn:
def open(self):
print("opened")
def close(self):
print("closed")
c = Conn()
c.open()
raise ValueError("boom")
c.close()raise skips straight past c.close()
Why: Verified by execution: prints opened, then the ValueError traceback. closed never prints - the resource was left open.
| line | output |
|---|---|
| c.open() | opened |
| raise ValueError("boom") | ValueError: boom (traceback) |
| c.close() | never runs |
Make it a context manager and let with own the cleanup.
class Conn:
def __enter__(self):
print("opened")
return self
def __exit__(self, exc_type, exc_value, tb):
print("closed")
with Conn():
raise ValueError("boom")with runs __exit__ before the error escapes
Why: Verified by execution: prints opened, then closed, then the ValueError traceback. Cleanup happens even though the block raised.
| event | output |
|---|---|
| __enter__ | opened |
| raise (block errors) | - |
| __exit__ runs anyway | closed |
| error continues | ValueError: boom (traceback) |
Trade off
Comparison matrix
From Trap: managing a resource by hand: every row here is a choice with a cost. Fill the output column, then say which row you would actually pick and what you give up for it.
| line | output |
|---|---|
| c.open() | opened |
| raise ValueError("boom") | ValueError: boom (traceback) |
| c.close() | never runs |
Section
Part 3
Concept
Any object with two special methods can go in a with. Python calls __enter__ at the start of the block and __exit__ at the end.
__enter__ / __exit__ — The setup and teardown methods of a context manager. __enter__ runs when the with block begins; __exit__ runs when it ends, no matter how.
Definition probe
Sort into buckets
Every line below is part of the definition of context manager or of __enter__ / __exit__ — one or the other, never both. Put each where it belongs.
Pattern
Predict first
The table runs: 1 | __enter__ | enter · 2 | block body | inside
In Watch the enter/exit order, given the rows so far: what is the next one — the row where order is 3?
Correct: 3 | __exit__ | exit
| order | what runs | output |
|---|---|---|
| 1 | __enter__ | enter |
| 2 | block body | inside |
| 3 | __exit__ | exit |
Why: The relationship between the columns, not the individual numbers, is what generates the next row. Before your indented code runs, Python calls __enter__, which prints enter.
Worked example
class Timer:
def __enter__(self):
print("enter")
return self
def __exit__(self, exc_type, exc_value, tb):
print("exit")
with Timer():
print("inside")Entering the block calls __enter__
Why: Before your indented code runs, Python calls __enter__, which prints enter.
Your block body runs next
Why: print("inside") runs after __enter__ and before __exit__.
Leaving the block calls __exit__
Why: Verified by execution: the three lines print enter, inside, exit - in that exact order.
| order | what runs | output |
|---|---|---|
| 1 | __enter__ | enter |
| 2 | block body | inside |
| 3 | __exit__ | exit |
Error analysis
Annotate
Walk the callouts on Watch the enter/exit order. Each one is a place this is easy to get subtly wrong.
Concept
The value you return from __enter__ is exactly what as name receives. Returning self is common, but you can hand back anything.
Sorting
Sort into buckets
These are the pieces of Session 32 - Context Managers & Type Hints, out of order. Put each one back under the part of the lesson it belongs to.
Worked example
class Box:
def __enter__(self):
return "the value"
def __exit__(self, exc_type, exc_value, tb):
pass
with Box() as x:
print(x)__enter__ returns the string, not the Box
Why: as x gets whatever __enter__ returns - here, the string "the value", not the Box object.
Read the output
Why: Verified by execution: prints the value. x is the returned string.
| expression | value |
|---|---|
| Box().__enter__() | "the value" |
| x | "the value" |
| print(x) | the value |
Blank canvas
Draw it
Draw what __enter__ can return any value just did — the shape of it, not the line-by-line working. One picture, labels only where you need them. Then check it against the steps: anything you could not draw is a step you followed rather than understood.
Concept
No matter how the block ends - a normal finish, a return, or an exception - __exit__ runs. That guarantee is the whole point.
Put your teardown in __exit__ and you never have to remember it at the call site again.
Section
Part 4
Concept
If the block raises, Python calls __exit__ first, then re-raises the error. The cleanup is never skipped.
Step zero
Discussion prompt
Enter, error, exit - then the traceback — before any calculation: what is the plan? Name the moves in order, in plain English, without doing the arithmetic.
Hint: It starts with: The block prints, then raises
Answer:
Worked example
class Guard:
def __enter__(self):
print("enter")
return self
def __exit__(self, exc_type, exc_value, tb):
print("exit runs anyway")
with Guard():
print("before error")
raise ValueError("boom")
print("after")The block prints, then raises
Why: enter prints, then before error, then line 10 raises ValueError.
__exit__ runs before the error escapes
Why: exit runs anyway prints even though the block raised - then the error continues upward.
Read the output
Why: Verified by execution - exact CPython 3.12 output and traceback:
enter
before error
exit runs anyway
Traceback (most recent call last):
File "guard.py", line 10, in <module>
raise ValueError("boom")
ValueError: boom
| event | output |
|---|---|
| __enter__ | enter |
| block | before error |
| __exit__ | exit runs anyway |
| error re-raised | ValueError: boom |
| print("after") | never runs |
Concept
__exit__(self, exc_type, exc_value, tb) receives the error's type, value, and traceback - or three Nones if the block finished cleanly.
So __exit__ can check: did we leave normally, or because of an error? Return True from it to swallow the error; return None (the default) to let it continue.
Hypothesis
Predict first
__exit__ inspects how the block ended is about to be worked. State your hypothesis first: which rule or definition decides this one, and what is the first move it forces? Then watch whether the example agrees with you.
Correct: The block finishes normally
Why: print("ok") runs with no error, so the block leaves cleanly.
A hypothesis you wrote down is falsifiable; a vague sense of how it will go is not. If the example opens somewhere else, that gap is the thing worth chasing.
Worked example
class Logger:
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, tb):
if exc_type is None:
print("clean exit")
else:
print("error:", exc_value)
with Logger():
print("ok")The block finishes normally
Why: print("ok") runs with no error, so the block leaves cleanly.
__exit__ sees exc_type is None
Why: Because nothing was raised, exc_type is None, so the clean-exit branch runs.
Read the output
Why: Verified by execution: prints ok, then clean exit. Had the block raised, exc_type would hold the error class instead.
| how block ended | exc_type | output |
|---|---|---|
| normally | None | clean exit |
| raised an error | the error class | error: ... |
Intuition
You could wrap every resource in try/finally and put the cleanup in finally. A context manager packages that once, inside the object.
Write the try/finally a single time in __exit__, and every with on that object gets the guarantee for free.
Section
Part 5
Concept
Writing a whole class for two small steps is heavy. contextlib.contextmanager lets you write one function with a single yield instead.
Everything before yield is the setup (the __enter__ part). Everything after yield is the cleanup (the __exit__ part).
Ranking
Put in order
Put the moves of A tag context manager in one function into the order they have to happen.
Why: These are the moves of the worked example in the order it makes them, and each one is set up by the one before it. Entering the with runs up to the yield, printing the opening tag.
Worked example
from contextlib import contextmanager
@contextmanager
def tag(name):
print("<" + name + ">")
yield
print("</" + name + ">")
with tag("p"):
print("hello")Code before yield runs as setup
Why: Entering the with runs up to the yield, printing the opening tag.
The block body runs where yield paused
Why: print("hello") runs while the function is paused at yield.
Code after yield runs as cleanup
Why: Verified by execution: prints the open tag, hello, then the close tag - in order.
| phase | code runs | output |
|---|---|---|
| setup (before yield) | print open tag | <p> |
| block body | print("hello") | hello |
| cleanup (after yield) | print close tag | </p> |
Concept
yield value gives that value to as name, exactly like __enter__'s return. A bare yield hands out None.
Worked example
from contextlib import contextmanager
@contextmanager
def bracket():
print("start")
yield 42
print("end")
with bracket() as val:
print(val)yield 42 becomes val
Why: The value after yield is what as val receives - here, 42.
Read the output
Why: Verified by execution: prints start, 42, then end. Setup, then body (using val), then cleanup.
| phase | output |
|---|---|
| before yield | start |
| body prints val | 42 |
| after yield | end |
Section
Part 6
Concept
A type hint is a note, written in the code, about what kind of value a name is meant to hold. It does not change what the code does.
type hint — An annotation of the expected type of a parameter, return value, or variable. It is documentation Python stores but does not enforce.
Step zero
Discussion prompt
Annotate a parameter and the return — before any calculation: what is the plan? Name the moves in order, in plain English, without doing the arithmetic.
Hint: It starts with: name: str says the input should be a string
Answer:
Worked example
def greet(name: str) -> str:
return "Hi, " + name
print(greet("Ana"))name: str says the input should be a string
Why: The : str after the parameter is the hint for that input.
-> str says the function returns a string
Why: The arrow before the colon annotates the return type.
Read the output
Why: Verified by execution: prints Hi, Ana. The hints changed nothing about how it runs - the function behaves exactly as it would without them.
| part | hint | meaning |
|---|---|---|
| name | str | input should be a string |
| -> str | str | return is a string |
| greet("Ana") | - | Hi, Ana |
Concept
-> Type sits between the parameter list and the colon. It tells a reader (and a checker) what the function hands back.
A function that returns nothing meaningful is annotated -> None.
Worked example
def is_adult(age: int) -> bool:
return age >= 18
print(is_adult(20))The hints read like a sentence
Why: Given an int age, return a bool. Anyone can see the shape without reading the body.
Read the output
Why: Verified by execution: prints True. 20 >= 18 is True.
| age | age >= 18 | returns |
|---|---|---|
| 20 | True | True |
| 15 | False | False |
Concept
You can annotate a plain variable: count: int = 0. The : int is the hint; the = 0 is the ordinary assignment.
Fill the middle
Fill in the blanks
From A variable annotation — one line has had its right-hand side removed. Put it back.
count: int = 0
count = count + 5
print(count)
Why: count is what everything below it consumes, so the wrong expression here fails later and somewhere else. The hint says count holds an int; the value starts at 0.
Worked example
count: int = 0
count = count + 5
print(count)count: int = 0 annotates and assigns
Why: The hint says count holds an int; the value starts at 0.
The variable works like any other
Why: Verified by execution: prints 5. The annotation did not change the arithmetic.
| line | count |
|---|---|
| count: int = 0 | 0 |
| count = count + 5 | 5 |
| print(count) | 5 |
Comparison
Comparison matrix
From A variable annotation: refill the count column from what you know. The rest of the table is as it appeared.
| line | count |
|---|---|
| count: int = 0 | 0 |
| count = count + 5 | 5 |
| print(count) | 5 |
Section
Part 7
Concept
list[int] means a list of ints. dict[str, int] means a dict whose keys are strings and values are ints. The brackets say what is inside.
This is far more useful than a bare list - the reader learns what the elements are, not just that it is a list.
Worked example
def total(scores: list[int]) -> int:
return sum(scores)
print(total([10, 20, 30]))scores: list[int] documents the elements
Why: The hint promises a list whose items are ints, and the function returns their sum as an int.
Read the output
Why: Verified by execution: prints 60. 10 + 20 + 30 = 60.
| scores | sum | returns |
|---|---|---|
| [10, 20, 30] | 60 | 60 |
Worked example
def lookup(ages: dict[str, int], name: str) -> int:
return ages[name]
print(lookup({"Ana": 30, "Bo": 25}, "Bo"))dict[str, int] names key and value types
Why: Keys are strings (names), values are ints (ages). The reader knows the shape at a glance.
Read the output
Why: Verified by execution: prints 25. ages["Bo"] is 25.
| name | ages[name] | returns |
|---|---|---|
| "Bo" | 25 | 25 |
| "Ana" | 30 | 30 |
Concept
Sometimes a function returns a real value or None (nothing found). Optional[int] from the typing module means 'an int, or None'.
Optional[T] — A hint meaning the value is either type T or None. Import it from typing: from typing import Optional.
Matching
Match the pairs
Match each term to the definition this lesson gave it — not the one you would guess from the word.
Why: These are the working definitions of context manager, __enter__ / __exit__, type hint, Optional[T] as Session 32 - Context Managers & Type Hints uses them. Pairing them correctly is the test of whether you could state each one with the slide switched off.
Worked example
from typing import Optional
def find(names: list[str], target: str) -> Optional[int]:
if target in names:
return names.index(target)
return None
print(find(["a", "b"], "b"))
print(find(["a", "b"], "z"))Optional[int] warns the caller about None
Why: The hint says: expect an int position, or None if the target is missing.
Two paths, two kinds of result
Why: Found returns an index; not found returns None.
Read the output
Why: Verified by execution: prints 1, then None.
| target | in names? | returns |
|---|---|---|
| "b" | yes | 1 |
| "z" | no | None |
Concept
Modern Python (3.10+) writes the same idea as int | None - no import needed. It reads 'int or None' and means exactly what Optional[int] does.
Step zero
Discussion prompt
int | None, no import — before any calculation: what is the plan? Name the moves in order, in plain English, without doing the arithmetic.
Hint: It starts with: int | None is the built-in way to say Optional
Answer:
Worked example
def first(items: list[int]) -> int | None:
if items:
return items[0]
return None
print(first([7, 8]))
print(first([]))int | None is the built-in way to say Optional
Why: No typing import; the pipe means 'int or None'.
Empty list gives None
Why: if items is False for an empty list, so it returns None.
Read the output
Why: Verified by execution: prints 7, then None.
| items | truthy? | returns |
|---|---|---|
| [7, 8] | yes | 7 |
| [] | no | None |
Section
Part 8
Concept
This is the surprise that trips people up: at runtime, Python ignores type hints. Pass the 'wrong' type and the code runs anyway.
Hints are stored as data and read by tools and humans - but the interpreter never checks them while your program runs.
Explain it
Discussion prompt
Explain Python does not enforce hints at runtime to a student a year behind you. No notation, no jargon they have not met — and it still has to be true.
Hint: If your explanation needs a symbol they have never seen, you are describing the notation rather than the idea.
Answer:
Hints are stored as data and read by tools and humans - but the interpreter never checks them while your program runs.
Ranking
Put in order
Put the moves of A mis-typed call still runs into the order they have to happen.
Why: These are the moves of the worked example in the order it makes them, and each one is set up by the one before it. You might expect Python to reject "ab" - it does not even look at the hint.
Worked example
def double(n: int) -> int:
return n * 2
print(double("ab"))n is hinted int, but a string is passed
Why: You might expect Python to reject "ab" - it does not even look at the hint.
n * 2 just runs on the string
Why: For a string, * 2 repeats it. So "ab" * 2 is "abab" - no error at all.
Read the output
Why: Verified by execution: prints abab. The int hint was silently ignored.
| call | n | n * 2 | output |
|---|---|---|---|
| double("ab") | "ab" | "abab" | abab |
| double(3) | 3 | 6 | 6 |
Worked example
def label(x: int) -> str:
return x
print(label(5))
print(type(label(5)))The body returns an int despite -> str
Why: The hint claims a string comes back, but the code returns x, an int. Python does not object.
Read the output
Why: Verified by execution: prints 5, then <class 'int'>. The return really is an int - the -> str was ignored.
| expression | value |
|---|---|
| label(5) | 5 |
| type(label(5)) | <class 'int'> |
Blank canvas
Draw it
Draw what A wrong return type is not caught either just did — the shape of it, not the line-by-line working. One picture, labels only where you need them. Then check it against the steps: anything you could not draw is a step you followed rather than understood.
Anomaly
Predict first
A student writes this, and it looks reasonable:
You annotate a parameter as int and assume Python will reject anything else.
It is wrong. Say what breaks — and say it before you turn the page.
Correct: Verified by execution: prints abab.
If you need a real guarantee, check it yourself (or run a type checker like mypy before you ship).
Why: Verified by execution: prints abab. The int hint does nothing at runtime, so a wrong type slides right through and can produce a silently wrong result.
Trap
You annotate a parameter as int and assume Python will reject anything else.
def double(n: int) -> int:
return n * 2
print(double("ab"))No TypeError - the string just runs
Why: Verified by execution: prints abab. The int hint does nothing at runtime, so a wrong type slides right through and can produce a silently wrong result.
| you expected | what happened |
|---|---|
| TypeError on "ab" | no error |
| - | prints abab |
If you need a real guarantee, check it yourself (or run a type checker like mypy before you ship).
def double(n: int) -> int:
if not isinstance(n, int):
raise TypeError("n must be int")
return n * 2
print(double(5))An explicit isinstance check does enforce it
Why: Verified by execution: double(5) prints 10, and double("ab") would now raise your TypeError. The hint documents intent; the check enforces it.
| call | result |
|---|---|
| double(5) | 10 |
| double("ab") | TypeError: n must be int |
Two truths and a lie
Sort into buckets
Some of these hold up and some are the exact mistakes this lesson is built to prevent. Sort them.
with resource as name: opens the resource, runs your indented block, then closes the resource - whether the block finished normally or blew up.; Think of borrowing a library book. with is a librarian standing beside you: the moment you finish (or storm off), they take the book back and check it in.Intuition
Think of hints like labels on boxes in a warehouse. The labels do not physically stop you from putting the wrong thing in a box - but a checker walking the aisles can flag every mismatch before shipping day.
That checker is a tool like mypy. You run it separately; it reads your hints and warns you where the types do not line up - all before the code ever runs.
Concept
Every annotated function keeps its hints in a __annotations__ dictionary. You can print it - proof that hints are data Python holds, not rules it applies.
Analogy
Discussion prompt
Explain Hints are just stored data by analogy to something with no Python Fundamentals in it at all — a queue, a recipe, a map, a bank balance, whatever fits. Then say where your analogy breaks.
Hint: An analogy that never breaks is not an analogy, it is the same idea wearing a hat. Find the seam — that is the part that is actually new.
Answer:
Every annotated function keeps its hints in a __annotations__ dictionary. You can print it - proof that hints are data Python holds, not rules it applies.
Pattern
Predict first
The table runs: 'x' | <class 'int'> · 'y' | <class 'str'>
In Peek at __annotations__, given the rows so far: what is the next one — the row where key is 'return'?
Correct: 'return' | <class 'bool'>
| key | stored value |
|---|---|
| 'x' | <class 'int'> |
| 'y' | <class 'str'> |
| 'return' | <class 'bool'> |
Why: The relationship between the columns, not the individual numbers, is what generates the next row. Python filed the annotations away in a dict on the function.
Worked example
def f(x: int, y: str) -> bool:
return True
print(f.__annotations__)The hints are stored, not enforced
Why: Python filed the annotations away in a dict on the function. It reads them for tools, never to block a call.
Read the output
Why: Verified by execution, exact CPython 3.12 output:
{'x': <class 'int'>, 'y': <class 'str'>, 'return': <class 'bool'>}
| key | stored value |
|---|---|
| 'x' | <class 'int'> |
| 'y' | <class 'str'> |
| 'return' | <class 'bool'> |
Error analysis
Annotate
Walk the callouts on Peek at __annotations__. Each one is a place this is easy to get subtly wrong.
Concept
They pay off in two ways. Readers learn a function's shape without reading its body - the signature tells the story.
Tools use them: editors autocomplete and warn, and mypy catches type mistakes before you run. Both benefits are free of runtime cost - the hints never slow the program.
Counterexample
Discussion prompt
They pay off in two ways. Readers learn a function's shape without reading its body - the signature tells the story.
That is stated as though it always holds. Do one of two things: produce a case where it fails, or say precisely what rules such a case out. "It just does" is not on the menu.
Hint: Hunt at the extremes first — zero, one, negative, empty, equal. If every extreme survives, the reason they survive is the proof.
Section
Part 9
Pattern
1. Decide the setup step and the teardown step
Why: The teardown is whatever must always happen: close, release, restore.
2a. Class way: __enter__ returns the resource; __exit__ does the teardown
Why: __exit__(self, exc_type, exc_value, tb) runs no matter how the block ends.
2b. Function way: @contextmanager, setup, then yield, then teardown
Why: Before yield is __enter__; after yield is __exit__. Shorter for simple cases.
3. Use it with with, and let cleanup take care of itself
Why: Even an exception in the block cannot skip the teardown.
Pattern
1. Annotate each parameter: name: Type
Why: Say what each input is meant to be - str, int, list[int], and so on.
2. Annotate the return with -> Type (or -> None)
Why: The reader learns what comes back without reading the body.
3. Spell out collections and maybe-None: list[int], int | None
Why: The brackets and the pipe carry real information about the contents.
4. Remember: hints document, they do not enforce
Why: For a runtime guarantee, add an explicit check or run mypy - the interpreter will not do it for you.
Real world
Discussion prompt
Outside this lesson: where does Session 32 - Context Managers & Type Hints actually turn up? Name one concrete situation — a job, a piece of software someone ships, a decision somebody has to make — and say which part of Adding type hints is doing the work in it.
Hint: Vague is the failure mode here. "Engineering" is not a situation; "deciding whether this build is fast enough to ship" is.
Answer:
Two professional finishers. Part 1: context managers - the with statement, __enter__/__exit__, guaranteed cleanup even when an error is raised, writing a class-based context manager, and the shorter contextlib.contextmanager + yield form.
Check
Predict the output order before you click.
class Timer:
def __enter__(self):
print("enter")
return self
def __exit__(self, exc_type, exc_value, tb):
print("exit")
with Timer():
print("inside")| order | output |
|---|---|
| 1 | ? |
| 2 | ? |
| 3 | ? |
Check your understanding
What does this print, in order?
Answer: A
Why: with calls __enter__ first (enter), then runs the block body (inside), then calls __exit__ (exit). Verified by execution.
Invariant
Step through it
Step through Check: enter/exit order one row at a time. One of these columns never changes — find it, and say why it cannot.
Elimination
Eliminate the wrong options
What prints before the ValueError traceback appears?
3 of these 4 are wrong. Strike them one at a time, and say what rules each one out before you strike the next. The survivor is the answer.
Survives elimination: A
Why: __enter__ prints enter, then the block raises; with runs __exit__ (exit runs anyway) before re-raising the error. Verified by execution.
Check
The block raises. Does __exit__ still run?
class Guard:
def __enter__(self):
print("enter")
return self
def __exit__(self, exc_type, exc_value, tb):
print("exit runs anyway")
with Guard():
raise ValueError("boom")| event | prints? |
|---|---|
| __enter__ | enter |
| __exit__ | ? |
Check your understanding
What prints before the ValueError traceback appears?
Answer: A
Why: __enter__ prints enter, then the block raises; with runs __exit__ (exit runs anyway) before re-raising the error. Verified by execution.
Check
Where does the block body run relative to yield?
from contextlib import contextmanager
@contextmanager
def tag(name):
print("<" + name + ">")
yield
print("</" + name + ">")
with tag("p"):
print("hello")| phase | output |
|---|---|
| before yield | ? |
| body | ? |
| after yield | ? |
Check your understanding
What does this print, in order?
Answer: A
Why: Code before yield is setup (<p>), the block body runs at the yield (hello), and code after yield is cleanup (</p>). Verified by execution.
Trade off
Comparison matrix
From Check: @contextmanager and yield: every row here is a choice with a cost. Fill the output column, then say which row you would actually pick and what you give up for it.
| phase | output |
|---|---|
| before yield | ? |
| body | ? |
| after yield | ? |
Check
n is hinted int, but a string is passed.
def double(n: int) -> int:
return n * 2
print(double("ab"))| arg | hint | runs? |
|---|---|---|
| "ab" | int | ? |
Check your understanding
What does this print?
Answer: A
Why: Hints are ignored at runtime, so "ab" is accepted and "ab" * 2 repeats the string to abab. Verified by execution.
Check
The target is not in the list.
def find(names: list[str], target: str) -> int | None:
if target in names:
return names.index(target)
return None
print(find(["a", "b"], "z"))| target | in names? | returns |
|---|---|---|
| "z" | no | ? |
Check your understanding
What does this print?
Answer: A
Why: "z" is not in names, so the if is skipped and the function returns None, which prints as None. Verified by execution.
if target in names avoids calling .index on a missing value, so no ValueError occurs.Check
Hints are stored, not enforced - but where?
def f(x: int) -> bool:
return True
print(f.__annotations__)| key | value |
|---|---|
| 'x' | ? |
| 'return' | ? |
Check your understanding
What does this print?
Answer: A
Why: Python stores annotations in __annotations__ as a dict mapping each name (and 'return') to the actual type object, so int and bool appear as class objects. Verified by execution.
Comparison
Comparison matrix
From Check: where the hints live: refill the value column from what you know. The rest of the table is as it appeared.
| key | value |
|---|---|
| 'x' | ? |
| 'return' | ? |
Connect it up
Draw it
One page, no notation unless you need it: draw how these connect — The with Statement · Guaranteed Cleanup · Under the Hood: __enter__ / __exit__ · __exit__ and Errors · The Shorter Way: @contextmanager · Type Hints: Annotating Functions. Put an arrow wherever one of them is what makes another possible, and label the arrow with why.
Recap
Context managers give guaranteed cleanup. with calls __enter__ at the start and __exit__ at the end - even when the block raises. Write one as a class or as a @contextmanager function with yield.
| You write | It means |
|---|---|
| with open(f) as x: | open, use, close automatically |
| __enter__ / __exit__ | setup / guaranteed teardown |
| @contextmanager + yield | before yield = setup, after = cleanup |
| def f(x: int) -> str: | hint the input and the return |
| list[int], int | None | a list of ints; an int or None |
| hints are not enforced | a wrong type still runs at runtime |
Type hints document intent for readers and tools - Python stores them in __annotations__ but never checks them while running. For a real guarantee, check inputs yourself or run mypy. That completes your Python fundamentals toolkit.
Want this taught 1-on-1? Alexander tutors Python Fundamentals — $55/session, free consultation.