Writing context managers with generators
Turn a generator function into a context manager with contextlib.contextmanager, and make sure the clean-up runs even when the block fails.
What you will learn
- Write a context manager as a generator decorated with @contextmanager
- Use try and finally around yield so that clean-up always runs
- Yield a value for the as clause, and let the manager handle an exception on purpose
- Compare the generator form with a class that defines __enter__ and __exit__
On this page
The with statement lesson showed how to use the context managers that come with Python, and set aside the question of writing your own because it needed generators. Now you have them. A generator that yields exactly once is a context manager waiting to be wrapped, and contextlib.contextmanager does the wrapping.
One yield splits the function in two
Everything before the yield is the entry step. Everything after it is the exit step. The yield is where the body of the with block runs:
from contextlib import contextmanager
@contextmanagerdef announce(name): print("enter", name) yield print("exit", name)
with announce("job"): print("working")print("after")enter jobworkingexit jobafterBehind the scenes, with starts the generator and runs it to the yield. When the block finishes, with resumes the generator, which runs off its end. You never call next yourself.
For comparison, here is the same manager written as a class. It means the same thing, but with more ceremony:
class Announce: def __init__(self, name): self.name = name
def __enter__(self): print("enter", self.name)
def __exit__(self, exc_type, exc_value, traceback): print("exit", self.name) return False
with Announce("job"): print("working")enter jobworkingexit jobUse the class form when the manager carries a lot of state or other methods. Use @contextmanager for the common case of “set something up, hand control back, tear it down”.
Always use try and finally
There is one trap, and it is the reason this lesson exists. If the with block raises an error, the exception is thrown into the generator at the yield. Without protection, the lines after yield never run:
from contextlib import contextmanager
@contextmanagerdef announce(name): print("enter", name) yield print("exit", name)
try: with announce("job"): raise ValueError("boom")except ValueError: print("caught")enter jobcaughtThe exit job line is missing, so the clean-up was skipped. A file would stay open, a directory change would stay in force. The fix is to wrap the yield in try and finally, which is exactly the pattern from the earlier lesson:
from contextlib import contextmanager
@contextmanagerdef announce(name): print("enter", name) try: yield finally: print("exit", name)
try: with announce("job"): print("working") raise ValueError("boom")except ValueError as error: print("caught", error)enter jobworkingexit jobcaught boomNow exit job runs before the error moves on. The error is not swallowed: the finally block does its work and the exception carries on to the except outside. Make try/finally your default whenever the exit step must happen.
Yielding a value
Whatever you yield becomes the name after as. This manager changes the working directory and always changes back, handing the new directory to the block:
import osimport tempfilefrom contextlib import contextmanagerfrom pathlib import Path
@contextmanagerdef working_directory(path): """Change the working directory, and always change back.""" previous = Path.cwd() os.chdir(path) try: yield Path.cwd() finally: os.chdir(previous)
start = Path.cwd()with tempfile.TemporaryDirectory() as folder: with working_directory(folder) as here: Path("note.txt").write_text("hi", encoding="utf-8") print("inside:", here.samefile(folder), sorted(p.name for p in Path.cwd().iterdir())) print("back:", Path.cwd() == start)inside: True ['note.txt']back: TrueThe relative path note.txt landed in the temporary folder because the working directory was changed. The inner with leaves before the outer one, so the program is back in its original folder before the temporary folder is deleted.
Handling the exception on purpose
If you catch the exception inside the generator and do not re-raise it, the with statement swallows it, and the program continues after the block. This is how contextlib.suppress works. Do this only when it is the whole point of the manager:
from contextlib import contextmanager
@contextmanagerdef ignore_missing_keys(): try: yield except KeyError as error: print("ignored missing key", error)
settings = {"theme": "dark"}with ignore_missing_keys(): print(settings["theme"]) print(settings["font"]) print("not reached")print("carried on")darkignored missing key 'font'carried onThe line after the failing lookup was skipped, as with any exception, and the program resumed after the block. A manager that catches too much hides bugs, so name the exact exception type, as here.
Common pitfalls
- Yielding more than once. The generator must yield exactly once. A second
yieldmakeswithraiseRuntimeError: generator didn't stop. - Leaving out
finally. Without it, the exit step is skipped when the block fails (shown above). - Reusing the manager object.
announce("job")returns a one-shot object. To enter it again, callannounce("job")again.
Write a context manager indented() that makes a helper say(text) print its text with two more spaces of indentation inside the block, and restores the indentation afterwards, even in nested blocks. Keep the depth in a module-level variable. Expected output:
top one two one againtop againShow solution
from contextlib import contextmanager
depth = 0
def say(text): print(" " * depth + text)
@contextmanagerdef indented(): global depth depth += 1 try: yield finally: depth -= 1
say("top")with indented(): say("one") with indented(): say("two") say("one again")say("top again")top one two one againtop againdepth goes up on entry and down in finally, so nested blocks stack and unwind correctly. If say raised an error inside a block, the finally would still put the depth back.
Look at announce in the cm_no_finally.py example above. If the with block does not raise, which lines print? And with the try/finally version, what is the difference when the block does raise?
Show solution
Without an error, both versions print enter job, then the body, then exit job. They differ only when the block raises. The version without finally skips the exit line (enter job then caught), and the version with finally prints exit job before the error continues outward. The try/finally costs nothing on the happy path, which is why it is the default.
Write a context manager temp_env(name, value) that sets an environment variable for the duration of a block and then restores it, or removes it if it did not exist before. Use os.environ. Print the variable inside the block, and print it with os.environ.get afterwards. Expected output:
inside: debugafter: NoneShow solution
import osfrom contextlib import contextmanager
@contextmanagerdef temp_env(name, value): old = os.environ.get(name) os.environ[name] = value try: yield finally: if old is None: del os.environ[name] else: os.environ[name] = old
with temp_env("I4_DEMO_MODE", "debug"): print("inside:", os.environ["I4_DEMO_MODE"])print("after:", os.environ.get("I4_DEMO_MODE"))inside: debugafter: NoneThe old value is saved before the change, so the exit step can tell “restore” from “delete”. Because the restore is in finally, the environment is clean even if the block fails.
Summary
@contextmanagerturns a generator with a singleyieldinto a context manager: code beforeyieldis the entry, code after is the exit, and the yielded value is theastarget.- Wrap the
yieldintry/finallyso the exit step runs even when the block raises. - Next:
itertoolsandfunctools. - Going further: the advanced lesson contextlib in depth covers the rest of
contextlibfor building and combining context managers.