Context Managers
Ever forgotten to close a file after you’re done with it? Or spent ages debugging a database connection that wouldn’t close? We’ve all been there. It’s a common struggle for programmers, but Python offers an exquisite solution to this very problem: the with statement. In this article, we’ll delve into this powerful feature, exploring not only how to use it but also why it’s essential for writing clean, robust, and reliable code.
The Problem: The Perils of Unmanaged Resources
Before we introduce our hero, the ‘with’ statement, let’s examine the problem it was designed to address. Imagine you’re writing a script to read from a file. A common, straightforward way to do this might look like this:
file = open('my_data.txt', 'r')
data = file.read()
print(data)
# You remember to close it
file.close() This code seems harmless enough. But what happens if something goes wrong in the middle?
file = open('my_data.txt', 'r')
data = file.read()
# What if an error happens right here?
result = 10 / 0 # A ZeroDivisionError occurs!
file.close() # This line is never reached.In the example above, the ZeroDivisionError halts the program abruptly. The file.close() line is never executed, leaving the file resource open. While this might seem like a minor issue for a single script, in a larger application—especially one that handles many files or database connections—this can lead to serious problems, such as resource leaks, where the program’s resources are slowly depleted, eventually causing it to crash.
This is the messy and error-prone world that developers navigated before the advent of context managers. It required a lot of manual handling and boilerplate code to ensure that every single resource was closed correctly, regardless of the circumstances.
The Solution: Enter the with Statement
Now, let’s rewrite that problematic code using Python’s with statement. This simple keyword not only makes our code cleaner but also ensures it is safe. It’s like a promise: “No matter what happens, I will handle the cleanup.”
try:
with open('my_data.txt', 'r') as file:
data = file.read()
print(data)
# What if an error happens here?
result = 10 / 0 # Still a ZeroDivisionError!
except ZeroDivisionError:
print("Oops, you can't divide by zero!")Even though a ZeroDivisionError still occurs, the with statement ensures that the file is automatically and properly closed before the except block is executed. You don’t have to remember to call file.close() yourself. The with statement handles the entire process, making your code more robust and reliable.
This is the power of a context manager. It wraps around a block of code, setting up a context for it to run in (__enter__) and then tearing it down afterward (__exit__), regardless of whether the code runs to completion or raises an error.
Under the Hood: The What, Why, How, When, and Where
So, how does the with statement work its magic? At its core, it’s all about a simple protocol. Any object can be a context manager as long as it has two special methods: __enter__ and __exit__.
The “What”: The __enter__ and __exit__ Methods
The with statement works like a temporary wrapper. When the line with some_object as my_alias: is executed, Python does the following behind the scenes:
- It calls
some_object.__enter__(). - The value returned by
__enter__is assigned tomy_alias. - The code block inside the
withstatement is executed. - Finally, no matter what happens (even if an error occurs!), it calls
some_object.__exit__().
This simple mechanism ensures that the cleanup code in __exit__ is always executed. It’s the ultimate “I promise to clean up after myself” guarantee.
The “Why”: Beyond Just Files
The reason this is so powerful is that the concept of “resource management” goes far beyond just files. It applies to anything that needs a setup and a teardown process. Think about it:
- Databases: You
__enter__to open a database connection and__exit__to ensure it’s closed. - Threading: You
__enter__to acquire a lock and__exit__to release it, preventing deadlocks. - Networking: You
__enter__to open a socket and__exit__to close it.
The “How”: An __exit__ with a Purpose
The __exit__ method is actually quite clever. It takes three arguments: exc_type, exc_val, and traceback. These arguments are None if the with block ran without an error. But if an exception occurred, they contain the details of that error. This gives your context manager the power to handle or suppress exceptions gracefully, which we’ll explore in a more advanced section.
Context Managers in Standard Library
You’ve actually already seen the most famous context manager in Python: the built-in open() function. But countless others solve different problems. Let’s examine a few examples that intermediate programmers are likely to encounter.
File Handling: The open() Function
This is the one that started it all. When you use with open(...), you’re getting an object that handles all the setup and cleanup for you.
Code Walkthrough:
# A simple example
with open('data.txt', 'w') as f:
f.write('Hello, World!')open('data.txt', 'w')is called, and the file is opened for writing. This is the__enter__part of the process.- The file object is assigned to the variable
fthanks to theaskeyword. - The code inside the
withblock,f.write('Hello, World!'), is executed. - When the block is exited, Python automatically calls the file object’s
__exit__method, which flushes any buffered data and closes the file, guaranteeing that the changes are saved and the resource is released.
Threading: The threading.Lock
In multithreaded programs, a common issue is race conditions where multiple threads attempt to access the same resource simultaneously. The threading.Lock object acts as a context manager to prevent this.
import threading
lock = threading.Lock()
count = 0
def increment():
global count
with lock:
# This code block is a "critical section"
# Only one thread can be here at a time.
count += 1
# ... run threads that call increment()Code Walkthrough:
- When the
with lock:line is executed, the lock’s__enter__method is called. This method acquires the lock, making the current thread the only one that can proceed. Any other thread that tries to enter this block will be forced to wait. - The code inside the block (
count += 1) runs safely. - Once the block is exited, the lock’s
__exit__method is called, which releases the lock. This allows another waiting thread to acquire it and continue.
Even if the code inside the with block throws an exception, the lock will still get released.
Redirecting Standard Output with contextlib
The contextlib module is a goldmine for context manager utilities. One very cool example is redirect_stdout, which allows you to temporarily redirect print statements to a different location, such as a file or a string.
from contextlib import redirect_stdout
import io
f = io.StringIO()
with redirect_stdout(f):
print('This will not be printed to the console!')
print('Instead, it will be captured in the StringIO object.')
# The `with` block has ended, and our standard output is back to normal.
s = f.getvalue()
print('This is the captured text:')
print(s)Code Walkthrough:
redirect_stdout(f)’s__enter__method saves the currentsys.stdoutand temporarily replaces it with ourio.StringIOobjectf.- The
printstatements inside the block write their output to thefobject instead of the console. - When the block ends,
redirect_stdout’s__exit__method is called, which restores the originalsys.stdout. The standard output is now back to where it was, and theprintstatement at the end of the code works as expected.
Networking: The socket Module
When working with network connections, ensure that sockets are properly closed to free up system resources. Python’s socket objects can also be used as context managers.
import socket
# This is a simplified example
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
# A simplified example of connecting
s.connect(('www.python.org', 80))
print("Socket connected successfully!")
# The socket is automatically closed when the 'with' block is exitedCode Walkthrough:
- The
with socket.socket(...) as s:line calls the socket object’s__enter__method. This returns the socket object itself, which is then assigned tos. - The code inside the block connects to the host.
- When the
withblock is exited (successfully or with an error), the socket’s__exit__method is called, which handles the necessary cleanup and closes the connection. This prevents a “dangling” socket that could keep a port busy or waste system resources.
Decimal Precision: decimal.localcontext
Sometimes, you need to temporarily change a global setting for a specific block of code. The decimal.localcontext context manager is perfect for this. It allows you to adjust the precision of decimal calculations temporarily.
import decimal
with decimal.localcontext() as ctx:
ctx.prec = 4 # Temporarily set precision to 4
x = decimal.Decimal('1.23456789')
print(f"Inside with block (precision 4): {x}") # Output: 1.235
# The precision is automatically reset to the original value here
x = decimal.Decimal('1.23456789')
print(f"Outside with block (original precision): {x}") # Output: 1.23456789Code Walkthrough:
decimal.localcontext()’s__enter__method creates a new, temporary context and makes it active.- Inside the
withblock, we can modify thectx.precattribute, and this change only affects the code within this block. - When the block is exited,
__exit__automatically restores the globaldecimalcontext to its original state. This is a brilliant use of a context manager for “scoped” state changes.
Testing: unittest.mock.patch
In software testing, you often need to temporarily replace an object with a “mock” version for a test. The unittest.mock.patch context manager makes this incredibly easy and reliable.
from unittest.mock import patch
import os
# Imagine we have a function that checks for a file
def check_if_exists(filepath):
return os.path.exists(filepath)
# Now let's test it without creating a real file
with patch('os.path.exists') as mock_exists:
mock_exists.return_value = True
assert check_if_exists('my_fake_file.txt') == True
# The original os.path.exists is restored automatically
assert check_if_exists('my_fake_file.txt') == FalseCode Walkthrough:
patch('os.path.exists')’s__enter__method replaces the realos.path.existsfunction with aMagicMockobject and assigns it tomock_exists.- We can then set the mock’s behavior inside the
withblock (mock_exists.return_value = True). - When the
withblock is exited, the__exit__method automatically restores the originalos.path.existsfunction, ensuring our test doesn’t have unintended side effects on other parts of our program. This is a crucial feature for writing isolated and repeatable tests.
Temporary Directories: tempfile.TemporaryDirectory
When you need a temporary space to create files and subdirectories for a task, you want to be absolutely sure that everything gets cleaned up afterward. The tempfile.TemporaryDirectory context manager is perfect for this. It creates a temporary directory and automatically deletes it and all its contents when the with block is exited.
import tempfile
import os
with tempfile.TemporaryDirectory() as tmpdir:
# We are inside the temporary directory
print(f'Created temporary directory: {tmpdir}')
# Create a file inside it
temp_file_path = os.path.join(tmpdir, 'my_temp_file.txt')
with open(temp_file_path, 'w') as f:
f.write('This file will be gone soon!')
# You can still see the file here
print(f'File exists: {os.path.exists(temp_file_path)}')
# The 'with' block is now exited, and the temporary directory is deleted.
print(f'Directory and file are gone: {os.path.exists(tmpdir)}')Code Walkthrough:
tempfile.TemporaryDirectory()’s__enter__method creates a new, empty directory with a unique name and returns its path as a string.- The code inside the
withblock can use this directory for any temporary work. - When the block is exited, the
__exit__method is called, which recursively deletes the directory and all of its contents. This is an incredibly safe way to handle temporary data.
Re-entrant Locks: threading.RLock
You already saw threading.Lock used as a context manager, but what if a function that already holds a lock needs to call another function that also tries to acquire the same lock? A standard Lock would cause a deadlock, but a re-entrant lock (RLock) solves this problem.
import threading
# A re-entrant lock allows the same thread to acquire it multiple times.
r_lock = threading.RLock()
def step_one():
with r_lock:
print("Inside step one, acquiring lock.")
step_two() # Calls another function that needs the same lock
def step_two():
with r_lock:
print("Inside step two, acquiring lock again.")
# This works because the lock is re-entrant.
# The lock is released only when the outermost 'with' block is exited.
step_one()Code Walkthrough:
- The first
with r_lock:call instep_oneacquires the lock, and an internal counter is set to 1. - Inside this block,
step_twois called, and it tries to acquire the lock again. - Because it’s an
RLock, the same thread can acquire it, and the internal counter simply increments to 2. No deadlock occurs. - The lock is not truly released until the
__exit__method is called for bothwithblocks, decrementing the counter back to 0.
Closing: contextlib.closing
Sometimes you have a third-party object that has a close() method but isn’t designed to be a context manager. Instead of writing your own class, you can use contextlib.closing to make it behave like one!
from contextlib import closing
from urllib.request import urlopen
# The urlopen object has a close() method, but no __enter__/__exit__
with closing(urlopen('https://www.python.org')) as page:
for line in page:
print(line)
# The 'page' object is guaranteed to be closed here.Code Walkthrough:
closing(urlopen(...))takes any object that has aclose()method.- Its
__enter__method simply returns the object itself. - Its
__exit__method guarantees thatobj.close()is called, no matter what happens inside thewithblock. This is a brilliant way to wrap non-compliant objects and add a layer of safety without writing any extra boilerplate code.
These examples clearly demonstrate that context managers are more than just file management; they represent a pattern for consistently and robustly managing a wide range of resources.
Crafting Your Own Context Managers
Now that you’ve seen how context managers work in the wild, let’s learn how to build your own. This is useful when you have a custom resource—such as a network connection, a temporary file, or even just some repetitive setup and teardown logic—that you want to manage elegantly. There are two main ways to do it: using a class or using a generator function with the contextlib module.
Class-based Context Managers
This is the most straightforward way to implement the __enter__ and __exit__ protocol directly. You create a class, and inside that class, you define these two special methods.
Example: A Simple Timer
Let’s create a Timer context manager that measures the time it takes for a block of code to run.
import time
class Timer:
def __enter__(self):
# This method is called when the 'with' block is entered.
self.start_time = time.time()
print("Timer started...")
# We don't need to return anything here, so we'll just continue.
return self
def __exit__(self, exc_type, exc_val, exc_tb):
# This method is called when the 'with' block is exited.
end_time = time.time()
elapsed_time = end_time - self.start_time
print(f"Timer stopped. Elapsed time: {elapsed_time:.4f} seconds.")
# Let's use our new context manager!
with Timer():
# Simulate some work
time.sleep(1.5)Walkthrough:
__enter__: Whenwith Timer():is called, our__enter__method is triggered. It records the current time usingtime.time(). This is our setup phase.__exit__: Once thewithblock is completed,__exit__is automatically called. It calculates the elapsed time and prints a formatted message. This is our teardown phase.
The @contextmanager Decorator
For simpler cases, writing a whole class can feel like overkill. Python’s contextlib module provides the @contextmanager decorator, which lets you create a context manager using a generator function. This is often a more elegant and readable solution.
Example: The Same Timer, but with a Decorator
import time
from contextlib import contextmanager
@contextmanager
def timer_decorator():
# This is the 'setup' phase.
start_time = time.time()
print("Timer started...")
# The 'yield' keyword is what separates the setup from the teardown.
# The code inside the 'with' block runs right here.
try:
yield
finally:
# This is the 'teardown' phase, guaranteed to run.
end_time = time.time()
elapsed_time = end_time - start_time
print(f"Timer stopped. Elapsed time: {elapsed_time:.4f} seconds.")
# Use the function as a context manager
with timer_decorator():
time.sleep(1.5)Walkthrough:
@contextmanager: This decorator automatically turns your generator function into a context manager.- Setup: All the code before the
yieldstatement is your setup logic. - The
yieldstatement: This is the most crucial part. It pauses the function and hands control over to the code block inside thewithstatement. The value yielded (if any) is assigned to theasvariable. - Teardown: When the
withblock finishes, the function resumes right after theyieldstatement. Thefinallyblock ensures that your teardown code (in this case, calculating and printing the time) always runs, even if an exception occurs inside thewithblock.
As you can see, both methods achieve the same result; however, the @contextmanager decorator often yields more concise and readable code for simple cases.
More Custom Context Manager Examples
Here are a few more examples of building your own context managers to solve everyday, real-life programming challenges.
Managing a Database Connection
In a web application or data script, you often need to connect to a database, perform a few queries, and then close the connection. A context manager is the perfect tool for this, as it guarantees the connection is always closed, preventing a common source of resource leaks.
import sqlite3
@contextmanager
def db_connection(db_name):
conn = None
try:
conn = sqlite3.connect(db_name)
yield conn
finally:
if conn:
conn.close()
# Real-world usage:
with db_connection('my_app.db') as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM users")
users = cursor.fetchall()
print(f"Found {len(users)} users.")What’s Happening:
- Setup: The generator function first creates the database connection.
yield: It then yields the connection object. Thewithblock now has access to thisconnobject.- Teardown: The
finallyblock ensures that theconn.close()method is always called, whether the code inside thewithblock succeeds or raises an error. This is crucial for preventing connections from being left open.
Suppressing print Statements for Testing
When you’re testing functions that print to the console, it can clutter your test output. You can create a context manager to temporarily redirect sys.stdout (standard output) so that print statements are captured instead of being displayed. This is a convenient utility for clean test results.
import sys
from io import StringIO
@contextmanager
def suppress_stdout():
old_stdout = sys.stdout
sys.stdout = StringIO()
try:
yield
finally:
sys.stdout = old_stdout
def my_function_that_prints():
print("This message should not appear on the console!")
# Use the context manager to suppress the output
with suppress_stdout():
my_function_that_prints()
print("The test is complete, and this message appears as normal.")What’s Happening:
- Setup: We save the original
sys.stdoutand replace it with aStringIOobject, which acts like a file in memory. yield: Thewithblock executes, and anyprintstatements write to our in-memory “file” instead of the console.- Teardown: The
finallyblock restoressys.stdoutto its original value, so normal printing resumes after the block is exited.
Reverting an Attribute Change
Sometimes, you need to temporarily change a global setting or an object’s attribute for a single function call, but you must remember to revert it. A context manager is perfect for this.
class temporary_attr:
def __init__(self, obj, attr_name, new_value):
self.obj = obj
self.attr_name = attr_name
self.new_value = new_value
self.old_value = None
def __enter__(self):
self.old_value = getattr(self.obj, self.attr_name)
setattr(self.obj, self.attr_name, self.new_value)
def __exit__(self, exc_type, exc_val, exc_tb):
setattr(self.obj, self.attr_name, self.old_value)
# Example object
class User:
def __init__(self, is_admin=False):
self.is_admin = is_admin
user = User()
print(f"Before: User is admin? {user.is_admin}")
# Temporarily make the user an admin
with temporary_attr(user, 'is_admin', True):
print(f"Inside 'with': User is admin? {user.is_admin}")
print(f"After: User is admin? {user.is_admin}")What’s Happening:
- Setup: In
__enter__, we first save the original value of the attribute, and then we set the new, temporary value. - Teardown: In
__exit__, we use the storedself.old_valueto restore the attribute to its original state.
Simulating contextlib.closing
The closing function is actually a class that implements the context manager protocol. It’s designed to be a thin wrapper around another object.
The goal is to take an object that has a .close() method but isn’t a context manager itself, and make it behave like one. The key is that closing doesn’t know what kind of object it’s wrapping; it just knows that it needs to call a .close() method when it’s done.
We can create a simple class to replicate this behavior. Let’s call it SimpleClosing.
class SimpleClosing:
def __init__(self, thing):
# The 'thing' is the object we're wrapping, e.g., a network connection.
self.thing = thing
def __enter__(self):
# The __enter__ method simply returns the object itself.
# This is what gets assigned to the 'as' variable in the 'with' statement.
return self.thing
def __exit__(self, exc_type, exc_val, exc_tb):
# The __exit__ method is the cleanup crew.
# It's called whether the block runs successfully or with an error.
# Its only job is to call the close() method on our wrapped object.
self.thing.close()
# Let's see it in action with a dummy object
class MyResource:
def close(self):
print("MyResource is now closed.")
with SimpleClosing(MyResource()) as my_resource:
print("Inside the 'with' block.")
# The with block is running, but the cleanup hasn't happened yet.
print("Outside the 'with' block.")Walkthrough of the SimpleClosing code:
__init__(self, thing): The constructor takes one argument,thing, which is the object we want to manage (e.g., a file, socket, or database connection). It simply stores this object as an instance variable.__enter__(self): When thewith SimpleClosing(...)statement is executed, Python calls this method. Its responsibility is to return the object that will be used inside thewithblock. In this case, we just returnself.thing, the resource we are managing. This is what gets assigned to the variablemy_resourcein our example.__exit__(self, exc_type, exc_val, exc_tb): When thewithblock is exited for any reason (normal completion, an exception, or areturnstatement), Python calls this method. Theexc_type,exc_val, andexc_tbarguments are used for advanced exception handling, which we won’t use in this simple example. The crucial part is that this method callsself.thing.close(), ensuring that our resource is cleaned up regardless of the circumstances.
This simple class effectively turns any object with a close() method into a fully compliant context manager, proving that the with statement is just a clever bit of syntactic sugar built on a straightforward protocol.
These examples should provide a robust set of real-life scenarios that demonstrate the power and flexibility of custom context managers. Now, are you ready to tackle the final, more advanced topics of exception handling and nesting?
Advanced Context Manager Topics
Exception Handling in __exit__
One of the most powerful features of context managers is their ability to handle exceptions that occur inside the with block. Remember those three arguments to __exit__: exc_type, exc_val, and traceback? They’re not just for show!
- If the
withblock runs without an error, all three arguments will beNone. - If an exception occurs, they will contain the type of the exception, the exception object itself, and the traceback object.
The magic part is what happens next. If the __exit__ method returns a truthy value (like True), it tells Python to suppress the exception. This means Python will pretend the error never happened and continue executing the code after the with block.
Example: Suppressing an Exception
Let’s create a context manager that safely handles a ZeroDivisionError and prevents the program from crashing.
class Suppressor:
def __enter__(self):
print("Entering context...")
def __exit__(self, exc_type, exc_val, exc_tb):
print("Exiting context...")
if exc_type is ZeroDivisionError:
print("Caught a ZeroDivisionError! Suppressing it.")
return True # Suppress the exception
print("Before the 'with' block.")
with Suppressor():
x = 10 / 0 # This will raise a ZeroDivisionError!
print("This line will never be reached.")
print("After the 'with' block. The program continues!")Walkthrough:
- The
withblock is entered. - The
ZeroDivisionErroris raised. Python’s normal behavior would be to stop the program and print a traceback. - However, because this is a
withstatement, it calls our__exit__method first, passing in the details of the exception. - Our
__exit__method checks if the exception is aZeroDivisionError. It is, so it prints a message and returnsTrue. - Python sees that
Truereturn value and understands it needs to suppress the exception, allowing the code to continue running from theprintstatement after thewithblock. This is a potent tool for building resilient code.
Nesting Context Managers
For tasks that require managing multiple resources, you can nest with statements. This is a typical pattern for tasks such as opening two different files simultaneously.
Method 1: Nested Blocks
with open('file1.txt', 'r') as f1:
with open('file2.txt', 'w') as f2:
for line in f1:
f2.write(line)This is a perfectly valid and readable way to nest them. However, for a long chain of with statements, it can lead to deeply indented code.
Method 2: One-Liner (Python 3.1 and later)
To avoid excessive nesting, you can use a single with statement with multiple context managers separated by commas.
with open('file1.txt', 'r') as f1, open('file2.txt', 'w') as f2:
for line in f1:
f2.write(line)This is a more concise and readable approach. The cleanup process works as expected: the context managers are exited in the reverse order of their entry, ensuring proper resource management.
Summary & Key Learnings
This journey into the with statement has shown us that it’s far more than a simple convenience for file handling. It’s a foundational concept in writing clean, safe, and robust Python code.
Here are the key takeaways to remember:
- It’s for Resource Management: The primary purpose of a context manager is to guarantee that resources—like files, network connections, or locks—are properly set up (
__enter__) and torn down (__exit__) automatically, even if errors occur. - It Prevents Leaks: By ensuring that the cleanup logic in
__exit__is always executed, thewithstatement prevents common bugs like resource leaks and deadlocks. - It’s a Protocol, Not a Keyword: Any object that implements the
__enter__and__exit__methods can be used as a context manager. This simple protocol is what makes the pattern so flexible. - Classes vs. Decorators: You can create your own custom context managers using a class (for more complex logic) or the elegant
@contextmanagerdecorator from thecontextlibmodule (for more straightforward, generator-based logic). - Advanced Power: Context managers can be used to handle exceptions gracefully and can be nested to manage multiple resources simultaneously, making them incredibly versatile.
What’s Next? Take Action!
Now that you understand the power of context managers, your next step is to put this knowledge into practice. The concepts you’ve learned here—resource management, setup, and teardown—are a key part of writing production-ready code.
- Review Your Codebase: Look for instances in your existing projects where you manually open and close resources. Can you refactor them to use a
withstatement? - Build a Novel Context Manager: Consider a repetitive task in your own code. Is there a setup and teardown process involved? Could you write a custom context manager to make that task cleaner? For example, you could write a context manager to change a logger’s level temporarily or to profile a block of code and write the results to a file.
- Explore More Python Concepts: If you enjoyed diving into the mechanics of the
withstatement, you’ll love exploring other “Pythonic” concepts that make your code cleaner and more efficient. Try looking into:- Decorators: The
@contextmanagerdecorator is a perfect entry point into understanding how decorators work, a powerful tool for modifying functions or classes. - Iterators and Generators: The
yieldkeyword in our generator-based context manager is a fundamental part of generators, which are particularly well-suited for efficiently working with large datasets. - Metaclasses: If you want to go even deeper into Python’s object model, metaclasses are the ultimate topic for creating classes that define the behavior of other classes.
- Decorators: The
Embrace the with statement as a powerful tool in your arsenal. Happy coding!
References
To dive deeper into the world of context managers and related Python topics, check out these excellent resources:
- Python Documentation: The official documentation is always the best place to start.
- Blog Articles & Tutorials: These resources offer practical insights and alternative explanations.
- Primer on Python Decorators by Real Python - A great resource to understand decorators, a concept closely related to the
@contextmanagerdecorator. - Context Manager in Python - GeeksforGeeks - A clear and concise overview with more examples.
- Primer on Python Decorators by Real Python - A great resource to understand decorators, a concept closely related to the
- Books: For a more comprehensive look, consider these well-regarded Python books.
- Fluent Python, the lizard book - This book has an excellent chapter on context managers that goes into even more detail.