Session 32 - Context Managers & Type Hints

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

What this lesson covers

The lesson, slide by slide

1. Context Managers & Type Hints

Title

Python Fundamentals - Session 32

Two finishers: guaranteed cleanup, and code that documents itself

2. What you will be able to do

Objectives

You can write functions, loops, and error handling. This session adds two touches that make code look professional. By the end you can:

  1. Use with so cleanup happens automatically, even when an error is raised.
  2. Write a context manager two ways: a class with __enter__/__exit__, and @contextmanager + yield.
  3. Annotate parameters and returns: def f(x: int) -> str.
  1. Write hints for collections (list[int], dict[str, int]) and for maybe-None values.
  2. Explain that type hints are not enforced at runtime - a mis-typed call still runs.
  3. Say why hints still earn their place: for readers and for tools like mypy.

3. What survived from Session 31 - Decorators?

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.

4. The with Statement

Section

Part 1

5. Cleanup is the chore you forget

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.

6. Break it if you can: Cleanup is the chore you forget

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.

7. with runs setup and cleanup for you

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.

8. By analogy: with runs setup and cleanup for you

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.

9. What has to happen first: Open a file with with

Ranking

Put in order

Put the moves of Open a file with with into the order they have to happen.

  1. with opens the file and binds it to f
  2. The block writes, then with closes the file
  3. Read the output

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.

10. Open a file with with

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.

linewhat happensoutput
with open(...)file opens, f bound-
f.write("hello")text written-
print(f.closed)file already closedTrue

11. Fill in: what happens for Open a file with with

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.

linewhat happensoutput
with open(...)file opens, f bound-
f.write("hello")text written-
print(f.closed)file already closedTrue

12. with is borrow-and-return

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.

13. Teach it back: with is borrow-and-return

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.

14. as names the thing you opened

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.

15. Guaranteed Cleanup

Section

Part 2

16. Cleanup runs even on error

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.

17. Plan first: The file closes even when the block errors

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:

  1. The block raises before it finishes normally
  2. with closes the file on the way out anyway
  3. Read the output

18. The file closes even when the block errors

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.

stepstate of foutput
open(...) as fopen-
raise ValueErrorwith unwinds-
with closes fclosed-
except printsclosedf.closed is True

19. Which is which, by state of f

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.

open
open(...) as f
with unwinds
raise ValueError
closed
with closes f; except prints
g1
state of f is "open" for open(...) as f — that is what the table on "The file closes even when the block…" records, and it is the single property separating this group from the rest.
g2
state of f is "with unwinds" for raise ValueError — that is what the table on "The file closes even when the block…" records, and it is the single property separating this group from the rest.
g3
state of f is "closed" for with closes f, except prints — that is what the table on "The file closes even when the block…" records, and it is the single property separating this group from the rest.

20. Something is wrong here: managing a resource by hand

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.

21. Trap: managing a resource by hand

Trap

The 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.

lineoutput
c.open()opened
raise ValueError("boom")ValueError: boom (traceback)
c.close()never runs

The fix

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.

eventoutput
__enter__opened
raise (block errors)-
__exit__ runs anywayclosed
error continuesValueError: boom (traceback)

22. What each one costs: Trap: managing a resource by hand

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.

lineoutput
c.open()opened
raise ValueError("boom")ValueError: boom (traceback)
c.close()never runs

23. Under the Hood: __enter__ / __exit__

Section

Part 3

24. Two methods make an object a context manager

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.

25. Take the definitions apart: context manager vs __enter__ / __exit__

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.

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.
__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.
b1
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.
b2
The setup and teardown methods of a context manager. __enter__ runs when the with block begins; __exit__ runs when it ends, no matter how.

26. Predict the next row: Watch the enter/exit order

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

orderwhat runsoutput
1__enter__enter
2block bodyinside
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.

27. Watch the enter/exit order

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.

orderwhat runsoutput
1__enter__enter
2block bodyinside
3__exit__exit

28. Inspect it line by line: Watch the enter/exit order

Error analysis

Annotate

Walk the callouts on Watch the enter/exit order. Each one is a place this is easy to get subtly wrong.

  • Before your indented code runs, Python calls __enter__, which prints enter.
  • print("inside") runs after __enter__ and before __exit__.
  • Verified by execution: the three lines print enter, inside, exit - in that exact order.

29. as binds whatever __enter__ returns

Concept

The value you return from __enter__ is exactly what as name receives. Returning self is common, but you can hand back anything.

30. Where does each piece belong: Session 32 - Context Managers & Type Hints

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.

The with Statement
Cleanup is the chore you forget; with runs setup and cleanup for you; Open a file with with
Guaranteed Cleanup
Cleanup runs even on error; The file closes even when the block errors
Under the Hood: __enter__ / __exit__
Two methods make an object a context manager; Watch the enter/exit order; as binds whatever __enter__ returns
s1
The with Statement is where Session 32 - Context Managers & Type Hints puts Cleanup is the chore you forget, with runs setup and cleanup for you, Open a file with with. Knowing which part of the lesson a problem belongs to is most of knowing which method to reach for.
s2
Guaranteed Cleanup is where Session 32 - Context Managers & Type Hints puts Cleanup runs even on error, The file closes even when the block errors. Knowing which part of the lesson a problem belongs to is most of knowing which method to reach for.
s3
Under the Hood: __enter__ / __exit__ is where Session 32 - Context Managers & Type Hints puts Two methods make an object a context manager, Watch the enter/exit order, as binds whatever __enter__ returns. Knowing which part of the lesson a problem belongs to is most of knowing which method to reach for.

31. __enter__ can return any value

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.

expressionvalue
Box().__enter__()"the value"
x"the value"
print(x)the value

32. Draw the shape of it: __enter__ can return any 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.

33. __exit__ always runs on the way out

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.

34. __exit__ and Errors

Section

Part 4

35. __exit__ still runs when the block raises

Concept

If the block raises, Python calls __exit__ first, then re-raises the error. The cleanup is never skipped.

36. Plan first: Enter, error, exit - then the traceback

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:

  1. The block prints, then raises
  2. __exit__ runs before the error escapes
  3. Read the output

37. Enter, error, exit - then the traceback

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

eventoutput
__enter__enter
blockbefore error
__exit__exit runs anyway
error re-raisedValueError: boom
print("after")never runs

38. __exit__ is told what went wrong

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.

39. State the rule before it runs: __exit__ inspects how the block ended

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.

40. __exit__ inspects how the block ended

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 endedexc_typeoutput
normallyNoneclean exit
raised an errorthe error classerror: ...

41. __exit__ is the finally you never write

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.

42. The Shorter Way: @contextmanager

Section

Part 5

43. A generator can be a context manager

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).

44. What has to happen first: A tag context manager in one function

Ranking

Put in order

Put the moves of A tag context manager in one function into the order they have to happen.

  1. Code before yield runs as setup
  2. The block body runs where yield paused
  3. Code after yield runs as cleanup

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.

45. A tag context manager in one function

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.

phasecode runsoutput
setup (before yield)print open tag<p>
block bodyprint("hello")hello
cleanup (after yield)print close tag</p>

46. yield can hand out a value too

Concept

yield value gives that value to as name, exactly like __enter__'s return. A bare yield hands out None.

47. yield a value into as

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.

phaseoutput
before yieldstart
body prints val42
after yieldend

48. Type Hints: Annotating Functions

Section

Part 6

49. A type hint records the intended type

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.

50. Plan first: Annotate a parameter and the return

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:

  1. name: str says the input should be a string
  2. -> str says the function returns a string
  3. Read the output

51. Annotate a parameter and the return

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.

parthintmeaning
namestrinput should be a string
-> strstrreturn is a string
greet("Ana")-Hi, Ana

52. The arrow annotates the return

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.

53. A function that returns a bool

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.

ageage >= 18returns
20TrueTrue
15FalseFalse

54. Variables can be annotated too

Concept

You can annotate a plain variable: count: int = 0. The : int is the hint; the = 0 is the ordinary assignment.

55. Restore the missing line: A variable annotation

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.

56. A variable annotation

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.

linecount
count: int = 00
count = count + 55
print(count)5

57. Fill in: count for A variable annotation

Comparison

Comparison matrix

From A variable annotation: refill the count column from what you know. The rest of the table is as it appeared.

linecount
count: int = 00
count = count + 55
print(count)5

58. Collections & Maybe-None

Section

Part 7

59. Spell out what a collection holds

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.

60. A list of ints in, an int out

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.

scoressumreturns
[10, 20, 30]6060

61. A dict[str, int] lookup

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.

nameages[name]returns
"Bo"2525
"Ana"3030

62. Optional: a value that may be None

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.

63. Term to definition: Session 32 - Context Managers & Type Hints

Matching

Match the pairs

Match each term to the definition this lesson gave it — not the one you would guess from the word.

  • t1. context manager
  • t2. __enter__ / __exit__
  • t3. type hint
  • t4. Optional[T]
  • d1. 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.
  • d2. The setup and teardown methods of a context manager. __enter__ runs when the with block begins; __exit__ runs when it ends, no matter how.
  • d3. An annotation of the expected type of a parameter, return value, or variable. It is documentation Python stores but does not enforce.
  • d4. A hint meaning the value is either type T or None. Import it from typing: from typing import Optional.

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.

64. Return an int or None

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.

targetin names?returns
"b"yes1
"z"noNone

65. The X | None shorthand

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.

66. Plan first: int | None, no import

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:

  1. int | None is the built-in way to say Optional
  2. Empty list gives None
  3. Read the output

67. int | None, no import

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.

itemstruthy?returns
[7, 8]yes7
[]noNone

68. Hints Are Hints, Not Rules

Section

Part 8

69. Python does not enforce hints at runtime

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.

70. Teach it back: Python does not enforce hints at runtime

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.

71. What has to happen first: A mis-typed call still runs

Ranking

Put in order

Put the moves of A mis-typed call still runs into the order they have to happen.

  1. n is hinted int, but a string is passed
  2. n * 2 just runs on the string
  3. Read the output

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.

72. A mis-typed call still runs

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.

callnn * 2output
double("ab")"ab""abab"abab
double(3)366

73. A wrong return type is not caught either

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.

expressionvalue
label(5)5
type(label(5))<class 'int'>

74. Draw the shape of it: A wrong return type is not caught either

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.

75. Something is wrong here: expecting hints to validate inputs

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.

76. Trap: expecting hints to validate inputs

Trap

The 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 expectedwhat happened
TypeError on "ab"no error
-prints abab

The fix

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.

callresult
double(5)10
double("ab")TypeError: n must be int

77. Which of these survive contact with Session 32 - Context Managers & Type Hints?

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.

Holds up
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.; 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.
Breaks
You open and close by hand - and an error strikes between the two.; You annotate a parameter as int and assume Python will reject anything else.
sound
These are stated as this lesson states them — each one survives the edge cases Session 32 - Context Managers & Type Hints puts it through.
flawed
Each of these is lifted from a trap in this deck: reasonable-sounding, and wrong in a way that only shows up once you rely on it.

78. The mypy mindset

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.

79. Hints are just stored data

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.

80. By analogy: Hints are just stored data

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.

81. Predict the next row: Peek at __annotations__

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'>

keystored 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.

82. Peek at __annotations__

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'>}

keystored value
'x'<class 'int'>
'y'<class 'str'>
'return'<class 'bool'>

83. Inspect it line by line: Peek at __annotations__

Error analysis

Annotate

Walk the callouts on Peek at __annotations__. Each one is a place this is easy to get subtly wrong.

  • Python filed the annotations away in a dict on the function. It reads them for tools, never to block a call.
  • Verified by execution, exact CPython 3.12 output: {'x': <class 'int'>, 'y': <class 'str'>, 'return': <class 'bool'>}

84. So why bother with hints?

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.

85. Break it if you can: So why bother with hints?

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.

86. Patterns & Checks

Section

Part 9

87. Writing a context manager

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.

88. Adding type hints

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.

89. Where this shows up: Session 32 - Context Managers & Type Hints

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.

90. Check: enter/exit order

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")
orderoutput
1?
2?
3?

Check your understanding

What does this print, in order?

  • A. enter, inside, exit (correct)
  • B. inside, enter, exit
  • C. enter, exit, inside
  • D. inside, exit

Answer: A

Why: with calls __enter__ first (enter), then runs the block body (inside), then calls __exit__ (exit). Verified by execution.

Why B tempts people
__enter__ runs before the block body, so enter comes first, not inside.
Why C tempts people
__exit__ runs after the block body finishes, not before it - so inside prints before exit.
Why D tempts people
__enter__ still runs, so enter prints; nothing is skipped.

91. What stays fixed: Check: enter/exit order

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.

  1. Step 1: order is 1
  2. Step 2: order is 2
  3. Step 3: order is 3

92. Rule out three: Check: cleanup on error

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.

  • A. enter, then exit runs anyway
  • B. only enter (exit is skipped by the error)
  • C. nothing - the error stops everything
  • D. exit runs anyway, then enter

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.

93. Check: cleanup on error

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")
eventprints?
__enter__enter
__exit__?

Check your understanding

What prints before the ValueError traceback appears?

  • A. enter, then exit runs anyway (correct)
  • B. only enter (exit is skipped by the error)
  • C. nothing - the error stops everything
  • D. exit runs anyway, then enter

Answer: A

Why: __enter__ prints enter, then the block raises; with runs __exit__ (exit runs anyway) before re-raising the error. Verified by execution.

Why B tempts people
That is exactly what with prevents: __exit__ runs even when the block raises.
Why C tempts people
__enter__ already ran and printed before the raise, so at least enter appears.
Why D tempts people
__enter__ runs first (at the start of the block); __exit__ runs at the end.

94. Check: @contextmanager and yield

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")
phaseoutput
before yield?
body?
after yield?

Check your understanding

What does this print, in order?

  • A. <p>, hello, </p> (correct)
  • B. <p>, </p>, hello
  • C. hello, <p>, </p>
  • D. <p>, hello (the close tag never prints)

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.

Why B tempts people
The block body runs at the yield, so hello prints between the two tags, not after both.
Why C tempts people
The setup before yield runs first, so <p> prints before hello.
Why D tempts people
The code after yield always runs as cleanup, so </p> does print.

95. What each one costs: Check: @contextmanager and yield

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.

phaseoutput
before yield?
body?
after yield?

96. Check: are hints enforced?

Check

n is hinted int, but a string is passed.

def double(n: int) -> int:
    return n * 2

print(double("ab"))
arghintruns?
"ab"int?

Check your understanding

What does this print?

  • A. abab (correct)
  • B. TypeError - "ab" is not an int
  • C. 6
  • D. ab

Answer: A

Why: Hints are ignored at runtime, so "ab" is accepted and "ab" * 2 repeats the string to abab. Verified by execution.

Why B tempts people
Python never checks the int hint at runtime, so no TypeError is raised.
Why C tempts people
6 would be 3 * 2 - but the argument here is the string "ab", not a number.
Why D tempts people
* 2 repeats the string twice, giving abab, not a single ab.

97. Check: Optional return

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"))
targetin names?returns
"z"no?

Check your understanding

What does this print?

  • A. None (correct)
  • B. -1
  • C. 0
  • D. ValueError - "z" is not in the list

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.

Why B tempts people
The code returns None, not -1; there is no -1 anywhere in the function.
Why C tempts people
0 would be the index of a first match - but "z" is not found, so the None branch runs.
Why D tempts people
The guard if target in names avoids calling .index on a missing value, so no ValueError occurs.

98. Check: where the hints live

Check

Hints are stored, not enforced - but where?

def f(x: int) -> bool:
    return True

print(f.__annotations__)
keyvalue
'x'?
'return'?

Check your understanding

What does this print?

  • A. {'x': <class 'int'>, 'return': <class 'bool'>} (correct)
  • B. {'x': 'int', 'return': 'bool'}
  • C. None - annotations are not stored
  • D. {'int': 'x', 'bool': 'return'}

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.

Why B tempts people
The values are the real type objects (<class 'int'>), not the strings 'int' and 'bool'.
Why C tempts people
Annotations are stored - that dict is exactly where they live.
Why D tempts people
The dict maps names to types, not types to names - the keys are 'x' and 'return'.

99. Fill in: value for Check: where the hints live

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.

keyvalue
'x'?
'return'?

100. Connect it up: Session 32 - Context Managers & Type Hints

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.

101. What you can do now

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 writeIt means
with open(f) as x:open, use, close automatically
__enter__ / __exit__setup / guaranteed teardown
@contextmanager + yieldbefore yield = setup, after = cleanup
def f(x: int) -> str:hint the input and the return
list[int], int | Nonea list of ints; an int or None
hints are not enforceda 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.

Sources

  1. Python 3 Reference - The with statement
  2. Python 3 Library - contextlib.contextmanager
  3. Python 3 Library - typing
  4. All snippets and error messages executed and copied from CPython 3.12. — Author verification run, 2026-07-15 (Python Fundamentals series, Session 32).

Want this taught 1-on-1? Alexander tutors Python Fundamentals — $55/session, free consultation.

Book on Wyzant · Text (657) 465-8108