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:

  1. It calls some_object.__enter__().
  2. The value returned by __enter__ is assigned to my_alias.
  3. The code block inside the with statement is executed.
  4. 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!')
  1. open('data.txt', 'w') is called, and the file is opened for writing. This is the __enter__ part of the process.
  2. The file object is assigned to the variable f thanks to the as keyword.
  3. The code inside the with block, f.write('Hello, World!'), is executed.
  4. 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:

  1. 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.
  2. The code inside the block (count += 1) runs safely.
  3. 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:

  1. redirect_stdout(f)’s __enter__ method saves the current sys.stdout and temporarily replaces it with our io.StringIO object f.
  2. The print statements inside the block write their output to the f object instead of the console.
  3. When the block ends, redirect_stdout’s __exit__ method is called, which restores the original sys.stdout. The standard output is now back to where it was, and the print statement 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 exited

Code Walkthrough:

  1. The with socket.socket(...) as s: line calls the socket object’s __enter__ method. This returns the socket object itself, which is then assigned to s.
  2. The code inside the block connects to the host.
  3. When the with block 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.23456789

Code Walkthrough:

  1. decimal.localcontext()’s __enter__ method creates a new, temporary context and makes it active.
  2. Inside the with block, we can modify the ctx.prec attribute, and this change only affects the code within this block.
  3. When the block is exited, __exit__ automatically restores the global decimal context 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') == False

Code Walkthrough:

  1. patch('os.path.exists')’s __enter__ method replaces the real os.path.exists function with a MagicMock object and assigns it to mock_exists.
  2. We can then set the mock’s behavior inside the with block (mock_exists.return_value = True).
  3. When the with block is exited, the __exit__ method automatically restores the original os.path.exists function, 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:

  1. tempfile.TemporaryDirectory()’s __enter__ method creates a new, empty directory with a unique name and returns its path as a string.
  2. The code inside the with block can use this directory for any temporary work.
  3. 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:

  1. The first with r_lock: call in step_one acquires the lock, and an internal counter is set to 1.
  2. Inside this block, step_two is called, and it tries to acquire the lock again.
  3. Because it’s an RLock, the same thread can acquire it, and the internal counter simply increments to 2. No deadlock occurs.
  4. The lock is not truly released until the __exit__ method is called for both with blocks, 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:

  1. closing(urlopen(...)) takes any object that has a close() method.
  2. Its __enter__ method simply returns the object itself.
  3. Its __exit__ method guarantees that obj.close() is called, no matter what happens inside the with block. 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:

  1. __enter__: When with Timer(): is called, our __enter__ method is triggered. It records the current time using time.time(). This is our setup phase.
  2. __exit__: Once the with block 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:

  1. @contextmanager: This decorator automatically turns your generator function into a context manager.
  2. Setup: All the code before the yield statement is your setup logic.
  3. The yield statement: This is the most crucial part. It pauses the function and hands control over to the code block inside the with statement. The value yielded (if any) is assigned to the as variable.
  4. Teardown: When the with block finishes, the function resumes right after the yield statement. The finally block ensures that your teardown code (in this case, calculating and printing the time) always runs, even if an exception occurs inside the with block.

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. The with block now has access to this conn object.
  • Teardown: The finally block ensures that the conn.close() method is always called, whether the code inside the with block 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.stdout and replace it with a StringIO object, which acts like a file in memory.
  • yield: The with block executes, and any print statements write to our in-memory “file” instead of the console.
  • Teardown: The finally block restores sys.stdout to 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 stored self.old_value to 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:

  1. __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.
  2. __enter__(self): When the with SimpleClosing(...) statement is executed, Python calls this method. Its responsibility is to return the object that will be used inside the with block. In this case, we just return self.thing, the resource we are managing. This is what gets assigned to the variable my_resource in our example.
  3. __exit__(self, exc_type, exc_val, exc_tb): When the with block is exited for any reason (normal completion, an exception, or a return statement), Python calls this method. The exc_type, exc_val, and exc_tb arguments are used for advanced exception handling, which we won’t use in this simple example. The crucial part is that this method calls self.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 with block runs without an error, all three arguments will be None.
  • 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:

  1. The with block is entered.
  2. The ZeroDivisionError is raised. Python’s normal behavior would be to stop the program and print a traceback.
  3. However, because this is a with statement, it calls our __exit__ method first, passing in the details of the exception.
  4. Our __exit__ method checks if the exception is a ZeroDivisionError. It is, so it prints a message and returns True.
  5. Python sees that True return value and understands it needs to suppress the exception, allowing the code to continue running from the print statement after the with block. 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, the with statement 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 @contextmanager decorator from the contextlib module (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 with statement?
  • 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 with statement, you’ll love exploring other “Pythonic” concepts that make your code cleaner and more efficient. Try looking into:
    • Decorators: The @contextmanager decorator is a perfect entry point into understanding how decorators work, a powerful tool for modifying functions or classes.
    • Iterators and Generators: The yield keyword 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.

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: