Skip to Content
Course content

319: Using ExitStack for Dynamic Resource Management

Click on the "Edit" button in the top corner of the screen to edit your slide content.

A few years ago, I was reviewing a PR from a junior dev who was building a tool to synchronize data across a variable number of remote servers. He had a list of connection strings that changed based on a config file. His first instinct was to use a for loop to open the connections, but he quickly realized that if the third connection failed, the first two remained open, leaking resources until the process finally died. He tried to fix it by nesting with statements, but since he didn't know if there would be two servers or twenty, he couldn't hardcode the blocks. He ended up with a mess of try...finally blocks that was almost impossible to read. He was essentially trying to manually recreate a context manager's logic, which is a path to madness.

This is exactly where contextlib.ExitStack comes in. You already know how with blocks work for single resources, but ExitStack is the tool you use when the number of resources you need to manage is dynamic or depends on runtime data. Think of it as a "bucket" for context managers. You can keep throwing resources into the bucket, and when the bucket itself is closed, it ensures every single resource inside is shut down in the reverse order they were added.

Escaping the Indentation Pyramid

When you're dealing with a fixed number of resources, nesting with open(a) as a, open(b) as b: is fine. But if you have ten files, your code starts drifting off the right side of your screen. Even worse, if you try to put a with statement inside a loop, you aren't actually keeping all those files open; you're opening and closing them one by one. If your logic requires all resources to be open simultaneously—say, you're reading from five different logs to find a correlating timestamp—a standard loop won't cut it.

By using ExitStack, you can maintain a flat indentation level regardless of how many resources you're managing. You call stack.enter_context() for each resource, and Python handles the cleanup logic behind the scenes. If an exception occurs while opening the fifth file, ExitStack is smart enough to close the four files that were already successfully opened before propagating the error.

Managing Dynamic Resources at Runtime

Let's look at how this actually looks in code. Imagine you have a list of file paths provided by a user, and you need to read a line from each of them simultaneously to process a data row.

from contextlib import ExitStack

def process_multiple_files(filepaths):
    with ExitStack() as stack:
        # We use a list comprehension to open all files
        # enter_context returns the object returned by the __enter__ method
        files = [stack.enter_context(open(p, 'r')) for p in filepaths]
        
        # Now we have a list of open file handles
        # We can iterate through them as much as we want
        for line_set in zip(*(f for f in files)):
            print(f"Processing row: {line_set}")

    # All files are guaranteed to be closed here, 
    # regardless of whether the loop finished or crashed.

I love this pattern because it separates the acquisition of resources from the logic of the program. You aren't worrying about f.close() calls in a finally block. The ExitStack tracks everything. It’s essentially a stack (LIFO), meaning the last resource you entered is the first one to be closed, which is crucial when you have dependent resources (like a database connection that must stay open while a cursor is active).




📋 Practical Task

Exercise: Building a Multi-Source Log Merger

You are tasked with creating a function called merge_logs(log_files). This function should take a list of filenames as input. It must open all these files simultaneously using an ExitStack, read the first line from each file, and return them as a combined string separated by a pipe character (|).

Requirements:

  • Use contextlib.ExitStack to manage the file handles.
  • Ensure that all files are closed properly, even if one of the files in the list does not exist (which would trigger an FileNotFoundError).
  • Do not use a fixed number of with statements; the solution must work for any number of files passed in the list.
# Template to get you started:
from contextlib import ExitStack

def merge_logs(log_files):
    # Your code here
    pass

# Test your code with:
# with open('test1.log', 'w') as f: f.write('Error A')
# with open('test2.log', 'w') as f: f.write('Error B')
# print(merge_logs(['test1.log', 'test2.log'])) # Expected: "Error A|Error B"
Rating
0 0

There are no comments for now.

to be the first to leave a comment.