Python · Syntax · Advanced
Context managers
Objects that manage setup and teardown with the `with` statement. Covers `__enter__`/`__exit__`, `contextlib.contextmanager`, and resource management patterns.
Quick topic start and explanations before exercises (exercises below):
@contextmanager, suppress, ExitStack, and nesting
#class vs @contextmanager, real patterns, nullcontext, common mistakes
#Exercises:
Timer context manager
#Write a class-based context manager `Timer` that measures elapsed time. `__enter__` should record the start time and return `self`. `__exit__` should compute and store the elapsed time in `self.elapsed`. After the `with` block, `timer.elapsed` should hold the elapsed seconds.
import time
class Timer:
def __enter__(self):
pass
def __exit__(self, exc_type, exc_val, exc_tb):
pass
with Timer() as t:
total = sum(range(1_000_000))
print(f'Elapsed: {t.elapsed:.4f}s')
Solution
import time
class Timer:
def __enter__(self):
self._start = time.time()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.elapsed = time.time() - self._start
return False # do not suppress exceptions
with Timer() as t:
total = sum(range(1_000_000))
print(f'Elapsed: {t.elapsed:.4f}s')
Managed file writer
#Write a context manager `ManagedFile(path, mode)` using a class that opens a file in `__enter__` and closes it in `__exit__`. If an exception occurs inside the `with` block, the file should still be closed. Return the file object from `__enter__`.
class ManagedFile:
def __init__(self, path, mode='r'):
pass
def __enter__(self):
pass
def __exit__(self, exc_type, exc_val, exc_tb):
pass
with ManagedFile('/tmp/test.txt', 'w') as f:
f.write('hello context manager\n')
with ManagedFile('/tmp/test.txt', 'r') as f:
print(f.read()) # hello context manager
Solution
class ManagedFile:
def __init__(self, path, mode='r'):
self.path = path
self.mode = mode
self._file = None
def __enter__(self):
self._file = open(self.path, self.mode)
return self._file
def __exit__(self, exc_type, exc_val, exc_tb):
if self._file:
self._file.close()
return False
with ManagedFile('/tmp/test.txt', 'w') as f:
f.write('hello context manager\n')
with ManagedFile('/tmp/test.txt', 'r') as f:
print(f.read())
@contextmanager decorator
#Rewrite the `Timer` context manager from exercise 1 using `@contextlib.contextmanager` and a generator function instead of a class. The function should `yield` a dict `{'elapsed': None}` and update it with the elapsed time after the `yield`.
import time
from contextlib import contextmanager
@contextmanager
def timer():
pass
with timer() as t:
total = sum(range(1_000_000))
print(f"Elapsed: {t['elapsed']:.4f}s")
Solution
import time
from contextlib import contextmanager
@contextmanager
def timer():
info = {'elapsed': None}
start = time.time()
try:
yield info # 'as' target receives this
finally:
info['elapsed'] = time.time() - start
with timer() as t:
total = sum(range(1_000_000))
print(f"Elapsed: {t['elapsed']:.4f}s")
suppress_and_log context manager
#Write a context manager `suppress_and_log(*exception_types)` that suppresses any of the given exception types and prints `'Suppressed: <exception message>'`. Other exceptions should propagate normally.
from contextlib import contextmanager
@contextmanager
def suppress_and_log(*exception_types):
pass
with suppress_and_log(ValueError, ZeroDivisionError):
result = 1 / 0
print('this line is not reached')
print('execution continues after the with block')
Solution
from contextlib import contextmanager
@contextmanager
def suppress_and_log(*exception_types):
try:
yield
except exception_types as e:
print(f'Suppressed: {e}')
with suppress_and_log(ValueError, ZeroDivisionError):
result = 1 / 0
print('this line is not reached')
print('execution continues after the with block')
# Suppressed: division by zero
# execution continues after the with block
Temporary file context manager
#Write a context manager `temp_file(suffix='.txt')` using `@contextmanager` that creates a temporary file, yields its path, and deletes the file when the `with` block exits (even if an exception occurs). Use `tempfile.mktemp()` to generate the path.
import os
import tempfile
from contextlib import contextmanager
@contextmanager
def temp_file(suffix='.txt'):
pass
with temp_file() as path:
with open(path, 'w') as f:
f.write('temporary data')
print(os.path.exists(path)) # True
print(os.path.exists(path)) # False — file deleted
Solution
import os
import tempfile
from contextlib import contextmanager
@contextmanager
def temp_file(suffix='.txt'):
path = tempfile.mktemp(suffix=suffix)
try:
yield path
finally:
if os.path.exists(path):
os.remove(path)
with temp_file() as path:
with open(path, 'w') as f:
f.write('temporary data')
print(os.path.exists(path)) # True
print(os.path.exists(path)) # False
Indented output context manager
#Write a context manager `indented(level=1, indent=' ')` that makes all `print()` calls inside the `with` block print with extra indentation. Patch the built-in `print` function temporarily using `builtins.print`. Restore the original `print` after the block exits.
import builtins
from contextlib import contextmanager
@contextmanager
def indented(level=1, indent=' '):
pass
print('top level')
with indented(2):
print('indented by 4 spaces')
print('also indented')
print('back to top level')
Solution
import builtins
from contextlib import contextmanager
@contextmanager
def indented(level=1, indent=' '):
prefix = indent * level
original_print = builtins.print
def indented_print(*args, **kwargs):
original_print(prefix, end='')
original_print(*args, **kwargs)
builtins.print = indented_print
try:
yield
finally:
builtins.print = original_print
print('top level')
with indented(2):
print('indented by 4 spaces')
print('also indented')
print('back to top level')
Transaction context manager
#Write a class `FakeDB` with a list `log` of committed operations. Implement a context manager `transaction(db)` using `@contextmanager`: it should collect operations in a temporary list, and on clean exit commit them all to `db.log`. On exception, roll back (discard the temp list) and re-raise the exception.
from contextlib import contextmanager
class FakeDB:
def __init__(self):
self.log = []
@contextmanager
def transaction(db):
pass
db = FakeDB()
with transaction(db) as tx:
tx.append('INSERT user')
tx.append('UPDATE balance')
print(db.log) # ['INSERT user', 'UPDATE balance']
try:
with transaction(db) as tx:
tx.append('DELETE everything')
raise RuntimeError('oops')
except RuntimeError:
pass
print(db.log) # still ['INSERT user', 'UPDATE balance'] — rolled back
Solution
from contextlib import contextmanager
class FakeDB:
def __init__(self):
self.log = []
@contextmanager
def transaction(db):
pending = []
try:
yield pending
db.log.extend(pending) # commit on success
except Exception:
pass # rollback — discard pending
raise
db = FakeDB()
with transaction(db) as tx:
tx.append('INSERT user')
tx.append('UPDATE balance')
print(db.log) # ['INSERT user', 'UPDATE balance']
try:
with transaction(db) as tx:
tx.append('DELETE everything')
raise RuntimeError('oops')
except RuntimeError:
pass
print(db.log) # ['INSERT user', 'UPDATE balance']
ExitStack for dynamic context managers
#Use `contextlib.ExitStack` to open a dynamic number of files at once and read their first line. Given a list of file paths, open all of them inside a single `with ExitStack()` block and collect the first line from each file into a list.
import contextlib
def read_first_lines(paths):
pass
import tempfile, os
# Create test files
paths = []
for i in range(3):
p = tempfile.mktemp()
with open(p, 'w') as f:
f.write(f'line from file {i}\nmore lines')
paths.append(p)
print(read_first_lines(paths))
# ['line from file 0', 'line from file 1', 'line from file 2']
for p in paths:
os.remove(p)
Solution
import contextlib
def read_first_lines(paths):
with contextlib.ExitStack() as stack:
files = [stack.enter_context(open(p)) for p in paths]
return [f.readline().strip() for f in files]
import tempfile, os
paths = []
for i in range(3):
p = tempfile.mktemp()
with open(p, 'w') as f:
f.write(f'line from file {i}\nmore lines')
paths.append(p)
print(read_first_lines(paths))
for p in paths:
os.remove(p)
Exception-suppressing context manager
#Write a class `Attempt` that suppresses `ValueError` exceptions raised inside the `with` block and stores the exception in `self.error`. If no exception occurred, `self.error` should be `None`. Other exception types should propagate normally.
class Attempt:
def __init__(self):
self.error = None
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
pass
with Attempt() as a:
int('not a number')
print(a.error) # invalid literal for int() with base 10: 'not a number'
with Attempt() as a:
x = int('42')
print(a.error) # None
Solution
class Attempt:
def __init__(self):
self.error = None
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is ValueError:
self.error = exc_val
return True # suppress the exception
return False # propagate other exceptions
with Attempt() as a:
int('not a number')
print(a.error)
with Attempt() as a:
x = int('42')
print(a.error) # None
Thread-safe counter with Lock
#Write a `SafeCounter` class with an internal `threading.Lock`. Implement an `increment()` method that uses `with self.lock:` to safely increment `self.count`. Then run 5 threads, each calling `increment()` 1000 times, and verify the final count is exactly 5000.
import threading
class SafeCounter:
def __init__(self):
self.count = 0
self.lock = threading.Lock()
def increment(self):
pass
counter = SafeCounter()
threads = [threading.Thread(target=lambda: [counter.increment() for _ in range(1000)])
for _ in range(5)]
for t in threads: t.start()
for t in threads: t.join()
print(counter.count) # 5000
Solution
import threading
class SafeCounter:
def __init__(self):
self.count = 0
self.lock = threading.Lock()
def increment(self):
with self.lock:
self.count += 1
counter = SafeCounter()
threads = [threading.Thread(target=lambda: [counter.increment() for _ in range(1000)])
for _ in range(5)]
for t in threads: t.start()
for t in threads: t.join()
print(counter.count) # 5000