Python’s context managers are great for resource management and stopping the propagation of
leaked abstractions. You’ve probably used it while opening a file or a database connection.
Usually it starts with a with
statement like this:
with open("file.txt", "wt") as f:
f.write("contents go here")
In the above case, file.txt
gets automatically closed when the execution flow goes out of
the scope. This is equivalent to writing:
try:
f = open("file.txt", "wt")
text = f.write("contents go here")
finally:
f.close()
Writing custom context managers
To write a custom context manager, you need to create a class that includes the __enter__
and __exit__
methods. Let’s recreate a custom context manager that will execute the same
workflow as above.
class CustomFileOpen:
"""Custom context manager for opening files."""
def __init__(self, filename, mode):
self.filename = filename
self.mode = mode
def __enter__(self):
self.f = open(self.filename, self.mode)
return self.f
def __exit__(self, *args):
self.f.close()
You can use the above class just like a regular context manager.
with CustomFileOpen("file.txt", "wt") as f:
f.write("contents go here")
From generators to context managers
Creating context managers by writing a class with __enter__
and __exit__
methods, is not
difficult. However, you can achieve better brevity by defining them using
contextlib.contextmanager
decorator. This decorator converts a generator function into a
context manager. The blueprint for creating context manager decorators goes something like
this:
@contextmanager
def some_generator(<arguments>):
<setup>
try:
yield <value>
finally:
<cleanup>
When you use the context manager with the with
statement:
with some_generator(<arguments>) as <variable>:
# ...body
It roughly translates to:
<setup>
try:
<variable> = <value>
<body>
finally:
<cleanup>
The setup code goes before the try..finally
block. Notice the point where the generator
yields. This is where the code block nested in the with
statement gets executed. After the
completion of the code block, the generator is then resumed. If an unhandled exception
occurs in the block, it’s re-raised inside the generator at the point where the yield
occurred and then the finally
block is executed. If no unhandled exception occurs, the
code gracefully proceeds to the finally
block where you run your cleanup code.
Let’s implement the same CustomFileOpen
context manager with contextmanager
decorator.
from contextlib import contextmanager
@contextmanager
def CustomFileOpen(filename, method):
"""Custom context manager for opening a file."""
f = open(filename, method)
try:
yield f
finally:
f.close()
Now use it just like before:
with CustomFileOpen("file.txt", "wt") as f:
f.write("contents go here")
Writing context managers as decorators
You can use context managers as decorators also. To do so, while defining the class, you
have to inherit from contextlib.ContextDecorator
class. Let’s make a RunTime
decorator
that’ll be applied on a file-opening function. The decorator will:
- Print a user provided description of the function.
- Print the time it takes to run the function.
from contextlib import ContextDecorator
from time import time
class RunTime(ContextDecorator):
"""Timing decorator."""
def __init__(self, description):
self.description = description
def __enter__(self):
print(self.description)
self.start_time = time()
def __exit__(self, *args):
self.end_time = time()
run_time = self.end_time - self.start_time
print(f"The function took {run_time} seconds to run.")
You can use the decorator like this:
@RunTime("This function opens a file")
def custom_file_write(filename, mode, content):
with open(filename, mode) as f:
f.write(content)
Using the function like this should return:
print(custom_file_write("file.txt", "wt", "jello"))
This function opens a file
The function took 0.0005390644073486328 seconds to run.
None
You can also create the same decorator via contextlib.contextmanager
decorator.
from contextlib import contextmanager
@contextmanager
def runtime(description):
print(description)
start_time = time()
try:
yield
finally:
end_time = time()
run_time = end_time - start_time
print(f"The function took {run_time} seconds to run.")
Nesting contexts
You can nest multiple context managers to manage resources simultaneously. Consider the following dummy manager:
from contextlib import contextmanager
@contextmanager
def get_state(name):
print("entering:", name)
yield name
print("exiting :", name)
# multiple get_state can be nested like this
with get_state("A") as A, get_state("B") as B, get_state("C") as C:
print("inside with statement:", A, B, C)
entering: A
entering: B
entering: C
inside with statement: A B C
exiting : C
exiting : B
exiting : A
Notice the order they’re closed. Context managers are treated as a stack, and should be
exited in reverse order in which they’re entered. If an exception occurs, this order
matters, as any context manager could suppress the exception, at which point the remaining
managers will not even get notified of this. The __exit__
method is also permitted to
raise a different exception, and other context managers then should be able to handle that
new exception.
Combining multiple context managers
You can combine multiple context managers too. Let’s consider these two managers.
from contextlib import contextmanager
@contextmanager
def a(name):
print("entering a:", name)
yield name
print("exiting a:", name)
@contextmanager
def b(name):
print("entering b:", name)
yield name
print("exiting b:", name)
Now combine these two using the decorator syntax. The following function takes the above
define managers a
and b
and returns a combined context manager ab
.
@contextmanager
def ab(a, b):
with a("A") as A, b("B") as B:
yield (A, B)
This can be used as:
with ab(a, b) as AB:
print("Inside the composite context manager:", AB)
entering a: A
entering b: B
Inside the composite context manager: ('A', 'B')
exiting b: B
exiting a: A
If you have variable numbers of context managers and you want to combine them gracefully,
contextlib.ExitStack
is here to help. Let’s rewrite context manager ab
using
ExitStack
. This function takes the individual context managers and their arguments as
tuples and returns the combined manager.
from contextlib import contextmanager, ExitStack
@contextmanager
def ab(cms, args):
with ExitStack() as stack:
yield [stack.enter_context(cm(arg)) for cm, arg in zip(cms, args)]
with ab((a, b), ("A", "B")) as AB:
print("Inside the composite context manager:", AB)
entering a: A
entering b: B
Inside the composite context manager: ['A', 'B']
exiting b: B
exiting a: A
ExitStack
can be also used in cases where you want to manage multiple resources
gracefully. For example, suppose, you need to create a list from the contents of multiple
files in a directory. Let’s see, how you can do so while avoiding accidental memory leakage
with robust resource management.
from contextlib import ExitStack
from pathlib import Path
# ExitStack ensures all files are properly closed after o/p
with ExitStack() as stack:
streams = (
stack.enter_context(open(fname, "r"))
for fname in Path("src").rglob("*.py")
)
contents = [f.read() for f in streams]
Using context managers to create SQLAlchemy session
If you are familiar with SQLALchemy, Python’s SQL toolkit and Object Relational Mapper, then
you probably know the usage of Session
to run a query. A Session
basically turns any
query into a transaction and make it atomic. Context managers can help you write a
transaction session in a very elegant way. A basic querying workflow in SQLAlchemy may look
like this:
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from contextlib import contextmanager
# an Engine, which the Session will use for connection resources
some_engine = create_engine("sqlite://")
# create a configured "Session" class
Session = sessionmaker(bind=some_engine)
@contextmanager
def session_scope():
"""Provide a transactional scope around a series of operations."""
session = Session()
try:
yield session
session.commit()
except:
session.rollback()
raise
finally:
session.close()
The excerpt above creates an in memory SQLite
connection and a session_scope
function
with context manager. The session_scope function takes care of committing and rolling back
in case of exception automatically. The session_scope
function can be used to run queries
in the following way:
with session_scope() as session:
myobject = MyObject("foo", "bar")
session.add(myobject)
Abstract away exception handling monstrosity with context managers
This is my absolute favorite use case of context managers. Suppose you want to write a
function but want the exception handling logic out of the way. Exception handling logics
with sophisticated logging can often obfuscate the core logic of your function. You can
write a decorator type context manager that will handle the exceptions for you and decouple
these additional code from your main logic. Let’s write a decorator that will handle
ZeroDivisionError
and TypeError
simultaneously.
from contextlib import contextmanager
@contextmanager
def errhandler():
try:
yield
except ZeroDivisionError:
print("This is a custom ZeroDivisionError message.")
raise
except TypeError:
print("This is a custom TypeError message.")
raise
Now use this in a function where these exceptions occur.
@errhandler()
def div(a, b):
return a // b
div("b", 0)
This is a custom TypeError message.
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
<ipython-input-43-65497ed57253> in <module>
----> 1 div('b',0)
/usr/lib/python3.8/contextlib.py in inner(*args, **kwds)
73 def inner(*args, **kwds):
74 with self._recreate_cm():
---> 75 return func(*args, **kwds)
76 return inner
77
<ipython-input-42-b7041bcaa9e6> in div(a, b)
1 @errhandler()
2 def div(a, b):
----> 3 return a // b
TypeError: unsupported operand type(s) for //: 'str' and 'int'
You can see that the errhandler
decorator is doing the heavylifting for you. Pretty neat,
huh?
The following one is a more sophisticated example of using context manager to decouple your error handling monstrosity from the main logic. It also hides the elaborate logging logic from the main method.
import logging
from contextlib import contextmanager
import traceback
import sys
logging.getLogger(__name__)
logging.basicConfig(
level=logging.INFO,
format="\n(asctime)s [%(levelname)s] %(message)s",
handlers=[logging.FileHandler("./debug.log"), logging.StreamHandler()],
)
class Calculation:
"""Dummy class for demonstrating exception decoupling with contextmanager."""
def __init__(self, a, b):
self.a = a
self.b = b
@contextmanager
def errorhandler(self):
try:
yield
except ZeroDivisionError:
print(
f"Custom handling of Zero Division Error! Printing "
"only 2 levels of traceback.."
)
logging.exception("ZeroDivisionError")
def main_func(self):
"""Function that we want to save from nasty error handling logic."""
with self.errorhandler():
return self.a / self.b
obj = Calculation(2, 0)
print(obj.main_func())
This will return
(asctime)s [ERROR] ZeroDivisionError
Traceback (most recent call last):
File "<ipython-input-44-ff609edb5d6e>", line 25, in errorhandler
yield
File "<ipython-input-44-ff609edb5d6e>", line 37, in main_func
return self.a / self.b
ZeroDivisionError: division by zero
Custom handling of Zero Division Error! Printing only 2 levels of traceback..
None
Persistent parameters across HTTP requests with context managers
Another great use case for context managers is making parameters persistent across multiple
HTTP requests. Python’s requests
library has a Session
object that will let you easily
achieve this. So, if you’re making several requests to the same host, the underlying TCP
connection will be reused, which can result in a significant performance increase. The
following example is taken directly from the official docs of the requests1 library.
Let’s persist some cookies across requests.
with requests.Session() as session:
session.get("http://httpbin.org/cookies/set/sessioncookie/123456789")
response = session.get("http://httpbin.org/cookies")
print(response.text)
This should show:
{
"cookies": {
"sessioncookie": "123456789"
}
}
Remarks
To avoid redundencies, I have purposefully excluded examples of nested with statements and
now deprecated contextlib.nested
function to create nested context managers.
Recent posts
- SSH saga
- Injecting Pytest fixtures without cluttering test signatures
- Explicit method overriding with @typing.override
- Quicker startup with module-level __getattr__
- Docker mount revisited
- Topological sort
- Writing a circuit breaker in Go
- Discovering direnv
- Notes on building event-driven systems
- Bash namerefs for dynamic variable referencing