Python

Advanced Python

Context Managers & Type Hints

Module 3, Chapter 2 introduced with open(...) as file: as "the safe way to handle files," promising the file always closes. Now, with dunder methods (Module 4)

JrCodex·8 min read

Jr Codex Python Notes

Level: Advanced Prerequisites: Chapter 2 Time to complete: ~25 minutes


Table of Contents

  1. Recap: What with Actually Does
  2. Building a Context Manager with __enter__/__exit__
  3. Handling Exceptions in __exit__
  4. The @contextmanager Decorator — an Easier Way
  5. Practical Context Manager Examples
  6. Type Hints — the Basics
  7. Type Hints for Collections & Optional Values
  8. Checking Types with mypy
  9. Summary & Next Steps

1. Recap: What with Actually Does

Module 3, Chapter 2 introduced with open(...) as file: as "the safe way to handle files," promising the file always closes. Now, with dunder methods (Module 4) and decorators (Chapter 2) under your belt, here's the actual mechanism.

with EXPRESSION as VARIABLE:
    BODY

is equivalent to:

manager = EXPRESSION
VARIABLE = manager.__enter__()
try:
    BODY
finally:
    manager.__exit__(...)      # ALWAYS runs, success or exception

Any object implementing __enter__ and __exit__ is a context managerwith is just syntax that calls these two methods for you, guaranteeing __exit__ runs no matter what.


2. Building a Context Manager with __enter__/__exit__

class Timer:
    """Context manager that times how long the `with` block took to run."""
    def __enter__(self):
        import time
        self.start = time.perf_counter()
        return self          # whatever __enter__ returns becomes the `as` variable
 
    def __exit__(self, exc_type, exc_value, traceback):
        import time
        elapsed = time.perf_counter() - self.start
        print(f"Elapsed: {elapsed:.4f} seconds")
        return False          # False means: don't suppress any exception (Section 3)
 
with Timer() as t:
    total = sum(range(1_000_000))
 
print(total)
# Elapsed: 0.0123 seconds
# 499999500000
Execution Order
─────────────────────────────────────────
  with Timer() as t:
       │
       ▼
  Timer().__enter__()  runs → returns self, bound to `t`
       │
       ▼
  BODY runs (the sum(...) line)
       │
       ▼
  Timer().__exit__(...) runs → ALWAYS, even if BODY raised an exception
─────────────────────────────────────────

3. Handling Exceptions in __exit__

__exit__ receives details about any exception that occurred inside the with block — this lets a context manager clean up and optionally decide whether to suppress the error.

class SuppressErrors:
    """Context manager that swallows any exception raised inside the block."""
    def __enter__(self):
        return self
 
    def __exit__(self, exc_type, exc_value, traceback):
        if exc_type is not None:
            print(f"Suppressed an error: {exc_type.__name__}: {exc_value}")
        return True          # True means: SUPPRESS the exception, don't propagate it
 
with SuppressErrors():
    print("Before the error")
    raise ValueError("Something went wrong")
    print("This never runs")
 
print("Execution continues here — the error was suppressed!")
__exit__ Return ValueEffect
False (or None)Exception (if any) propagates normally — the default, safest choice
TrueException is suppressed — use sparingly, only when you genuinely want to swallow specific errors

Best practice: only return True when you've checked exc_type and are deliberately handling a specific expected exception — blindly suppressing everything hides real bugs, echoing the bare-except warning from Module 3, Chapter 4.


4. The @contextmanager Decorator — an Easier Way

Writing a full class with __enter__/__exit__ is verbose for simple cases. contextlib.contextmanager lets you write a context manager as a single generator function (Chapter 1) — the yield marks the boundary between setup and teardown.

from contextlib import contextmanager
import time
 
@contextmanager
def timer():
    start = time.perf_counter()
    yield                              # code BEFORE yield = __enter__, code AFTER = __exit__
    elapsed = time.perf_counter() - start
    print(f"Elapsed: {elapsed:.4f} seconds")
 
with timer():
    total = sum(range(1_000_000))
Mapping to the Class-Based Version
─────────────────────────────────────────
  @contextmanager
  def timer():
      start = time.perf_counter()   ← runs during __enter__
      yield                            ← the `with` block's BODY runs here
      elapsed = ...                       ← runs during __exit__ (cleanup)
      print(...)
─────────────────────────────────────────
@contextmanager
def open_file(path, mode):
    """A simplified re-implementation of what `open()` already does for you."""
    file = open(path, mode)
    try:
        yield file
    finally:
        file.close()          # guaranteed to run, even if the with-block raises
 
with open_file("notes.txt", "r") as f:
    print(f.read())

When to use which style: the class-based approach (__enter__/__exit__) is better when the context manager needs to hold significant state or be reused across many with blocks; @contextmanager is quicker for simple, one-off setup/teardown logic.


5. Practical Context Manager Examples

from contextlib import contextmanager
import os
 
@contextmanager
def change_directory(path):
    """Temporarily change the working directory, then restore it."""
    original = os.getcwd()
    os.chdir(path)
    try:
        yield
    finally:
        os.chdir(original)          # ALWAYS restore, even if the block raises
 
with change_directory("/tmp"):
    print(os.getcwd())      # /tmp
print(os.getcwd())            # back to the original directory automatically
@contextmanager
def temporary_setting(config, key, value):
    """Temporarily override a config value, restoring the original afterward."""
    original = config.get(key)
    config[key] = value
    try:
        yield
    finally:
        config[key] = original
 
settings = {"debug": False}
with temporary_setting(settings, "debug", True):
    print(settings["debug"])      # True
print(settings["debug"])            # False — restored automatically

This "temporarily change something, guarantee it's restored" pattern is one of the most common real-world uses for custom context managers.


6. Type Hints — the Basics

Python remains dynamically typed (Module 1, Chapter 2) — type hints are optional annotations that document expected types without being enforced by the interpreter at runtime. They exist purely to help humans and tools (IDEs, mypy) catch mistakes earlier.

def greet(name: str) -> str:          # name should be a str; function returns a str
    return f"Hello, {name}!"
 
age: int = 25                # variable annotation
price: float = 19.99
is_active: bool = True
 
print(greet("Alice"))      # "Hello, Alice!" — works normally
print(greet(42))              # ALSO runs without error! Hints are NOT enforced at runtime.
Type Hints Are Documentation, Not Enforcement
─────────────────────────────────────────
  def greet(name: str) -> str:
                 ↑          ↑
          "should be"   "should return"
          a hint, checked by TOOLS (mypy, IDEs), never by Python itself
─────────────────────────────────────────

Why bother, if Python won't enforce them? Type hints make function signatures self-documenting, let your editor autocomplete and catch mistakes as you type, and enable static checkers (Section 8) to catch bugs before you ever run the code — increasingly standard in professional Python codebases, including most ML libraries used later in this curriculum.


7. Type Hints for Collections & Optional Values

from typing import Optional
 
def process_items(items: list[str]) -> dict[str, int]:
    """Modern syntax (Python 3.9+): built-in generics, no typing.List needed."""
    return {item: len(item) for item in items}
 
def find_user(user_id: int) -> Optional[str]:
    """Optional[str] means: returns either a str, OR None."""
    users = {1: "Alice", 2: "Bob"}
    return users.get(user_id)      # returns None if user_id isn't found
 
result = find_user(1)      # "Alice"
missing = find_user(99)      # None — this is exactly why Optional[str] is accurate here
# Union types (Python 3.10+ syntax) — a value that could be one of several types
def parse_id(value: int | str) -> int:
    return int(value)
 
# Type aliases for readability in complex signatures
UserId = int
UserName = str
 
def get_username(user_id: UserId) -> UserName:
    ...
HintMeaning
list[str]A list containing strings
dict[str, int]A dict with string keys, int values
Optional[str] (or str | None)Either a string, or None
int | strEither an int or a string

8. Checking Types with mypy

Type hints only become genuinely useful for catching bugs once you run a static type checker against them — mypy is the standard tool.

pip install mypy
# script.py
def add(a: int, b: int) -> int:
    return a + b
 
result = add(5, "10")     # this RUNS fine in plain Python (no error at runtime)...
$ mypy script.py
script.py:4: error: Argument 2 to "add" has incompatible type "str"; expected "int"
Found 1 error in 1 file (checked 1 source file)

mypy catches the mismatch without ever running the code — purely by analyzing the hints. This is exactly the value proposition: bugs caught during development/CI, before the code ever reaches a user.


9. Summary & Next Steps

Key Takeaways

  • with is syntax sugar over an object's __enter__/__exit__ methods — __exit__ always runs, even if the block raises an exception, and can optionally suppress that exception by returning True.
  • @contextlib.contextmanager turns a generator function into a context manager: code before yield is setup, code after is teardown (usually inside finally).
  • Type hints (def f(x: int) -> str:) are optional, unenforced-at-runtime annotations — their value comes from IDE support and static checkers like mypy, not from Python itself.
  • Optional[T] (or T | None) documents that a value might be None — a common and important signal in function signatures.

Concept Check

  1. What does returning True from __exit__ actually do to an exception raised inside the with block?
  2. In a @contextmanager-based generator function, what does the code after yield correspond to?
  3. Why does add(5, "10") run without error in plain Python even with a def add(a: int, b: int) -> int: signature, and what tool would catch the mismatch?

Next Chapter

Chapter 4: functools, itertools & Dataclasses


Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index