Typorithm · medium

Context managers and reverse cleanup with ExitStack

pythonlibrariespackagingindependent-practice
Saved on this device
Full lesson · 4 learning steps Open any exercise for focused practice
1

Step 1 of 4 · intro card

Context managers and reverse cleanup with ExitStack

0%

Concept and contract

Own a variable number of resources and preserve cleanup when the body fails.

Resource ownership needs an explicit end even when several acquisitions are dynamic. A context manager describes entry and exit; ExitStack composes an input-dependent number of those lifetimes. In this example the resources are owned event records, so the order can be checked without touching external services.

Each contextmanager generator yields exactly once and places its cleanup in finally. The stack enters resources in input order and unwinds them in reverse order. The contract includes normal completion, a deliberate body failure and an empty resource list, which exercise different paths through the same ownership structure.

Mechanism and worked example

For names a and b, acquisition appends open:a and open:b.

For names a and b, acquisition appends open:a and open:b. The normal body records that two resources were acquired. Leaving the with statement closes b before a. On a deliberate ValueError, the same exits run before the outer handler appends caught.

The try statement around the stack is deliberately outside the managed body. That placement lets the returned event list distinguish cleanup from subsequent exception handling. No reliance on garbage collection is needed, and no manual loop must guess which resources were acquired successfully before a failure.

Complete worked example

python

from contextlib import ExitStack, contextmanager

def inspect_example(names, fail):
    events = []
    @contextmanager
    def resource(name):
        events.append('open:' + name)
        try:
            yield name
        finally:
            events.append('close:' + name)
    try:
        with ExitStack() as stack:
            acquired = [stack.enter_context(resource(name)) for name in names]
            if fail:
                raise ValueError('controlled body failure')
            events.append('body:' + str(len(acquired)))
    except ValueError:
        events.append('caught')
    return events
Reasoning
For names a and b, acquisition appends open:a and open:b. The normal body records that two resources were acquired. Leaving the with statement closes b before a. On a deliberate ValueError, the same exits run before the outer handler appends caught.
Download the reusable Python and packaging computer lab ↗

Focused decision: use the complete example for context

python

with ExitStack() as stack:
    acquired = [stack.enter_context(resource(name)) for name in names]
Decision
Report acquired names in order while preserving reverse cleanup on normal and exceptional exits.
Download the reusable Python and packaging computer lab ↗

Counterexamples and common mistakes

The controlled bug replaces the cleanup append with pass.

The controlled bug replaces the cleanup append with pass. The function still returns a list and the successful body still runs, but the expected close events disappear. The verifier rejects the missing lifetime evidence rather than accepting a plausible body result.

Catching an exception inside a generator-based manager without reraising can also suppress it unintentionally. Finally is appropriate for unconditional release work; an except block has different propagation semantics. A cleanup function that itself fails deserves its own policy, because one resource's exit must not be assumed to establish another resource's successful release.

Controlled bug: the executable contract rejects this version

python

from contextlib import ExitStack, contextmanager

def inspect_example(names, fail):
    events = []
    @contextmanager
    def resource(name):
        events.append('open:' + name)
        try:
            yield name
        finally:
            pass
    try:
        with ExitStack() as stack:
            acquired = [stack.enter_context(resource(name)) for name in names]
            if fail:
                raise ValueError('controlled body failure')
            events.append('body:' + str(len(acquired)))
    except ValueError:
        events.append('caught')
    return events
Reasoning
The controlled bug replaces the cleanup append with pass. The function still returns a list and the successful body still runs, but the expected close events disappear. The verifier rejects the missing lifetime evidence rather than accepting a plausible body result.
Download the reusable Python and packaging computer lab ↗

Independent practice

Replace the body count with the comma-separated acquired names, preserving acquisition order and reverse cleanup.

Replace the body count with the comma-separated acquired names, preserving acquisition order and reverse cleanup. Do not move the body observation outside the stack: the resources must still be alive at the point the body uses their names.

The first hint is that acquired is an ordered list returned by enter_context calls. The second is to keep the existing with and finally structure while changing only the body observation. An empty list should produce body: with no names; the deliberate failure should still skip that normal-body observation and end with caught.

Step 1: Report acquired names in order while preserving reverse cleanup on normal and exceptional exits. Step 2: Run the accepted baseline, edit the exercise, then check its independent solution cases; preserve caller inputs and the documented exception behavior.

Practice starter: solve before reading the solution

python

from contextlib import ExitStack, contextmanager

def inspect_example(names, fail):
    events = []
    @contextmanager
    def resource(name):
        events.append('open:' + name)
        try:
            yield name
        finally:
            events.append('close:' + name)
    try:
        with ExitStack() as stack:
            acquired = [stack.enter_context(resource(name)) for name in names]
            if fail:
                raise ValueError('controlled body failure')
            events.append('body:' + str(len(acquired)))
    except ValueError:
        events.append('caught')
    return events

# Task: Report acquired names in order while preserving reverse cleanup on normal and exceptional exits.
# Practice contract: Report acquired names in order while preserving reverse cleanup on normal and exceptional exits.
Reasoning
Report acquired names in order while preserving reverse cleanup on normal and exceptional exits.
Download the reusable Python and packaging computer lab ↗
  • Hint 1: Replace the body count with the comma-separated acquired names, preserving acquisition order and reverse cleanup. Do not move the body observation outside the stack: the resources must still be alive at the point the body uses their names.
  • Hint 2: The first hint is that acquired is an ordered list returned by enter_context calls. The second is to keep the existing with and finally structure while changing only the body observation. An empty list should produce body: with no names; the deliberate failure should still skip that normal-body observation and end with caught.

Solution, tests and trade-offs

The solution changes the normal body event and retains the same lifetime boundaries.

The solution changes the normal body event and retains the same lifetime boundaries. The acceptance cases compare the entire ordered event list, so an implementation that closes in acquisition order or records the normal body during a failure is rejected.

ExitStack is useful when static nested with statements cannot express an input-dependent resource count cleanly. Its convenience does not determine transactional policy: closing a file is different from committing a database transaction. Name those responsibilities separately and verify the actual resource protocol rather than treating every context exit as a successful commit.

Practice solution and acceptance checks

python

from contextlib import ExitStack, contextmanager

def inspect_example(names, fail):
    events = []
    @contextmanager
    def resource(name):
        events.append('open:' + name)
        try:
            yield name
        finally:
            events.append('close:' + name)
    try:
        with ExitStack() as stack:
            acquired = [stack.enter_context(resource(name)) for name in names]
            if fail:
                raise ValueError('controlled body failure')
            events.append('body:' + ','.join(acquired))
    except ValueError:
        events.append('caught')
    return events
Reasoning
The solution changes the normal body event and retains the same lifetime boundaries. The acceptance cases compare the entire ordered event list, so an implementation that closes in acquisition order or records the normal body during a failure is rejected.
Download the reusable Python and packaging computer lab ↗

Interview reasoning and next step

Walk through the exceptional case from the raise to the final caught event.

Walk through the exceptional case from the raise to the final caught event. Explain why close:a appears before caught, and why closing several resources uses reverse acquisition order. Then distinguish unconditional cleanup from suppressing the body exception.

For a stretch, make the second resource fail during acquisition and verify that the first entered resource still exits. Keep the failing resource's acquisition and cleanup semantics explicit. This extends the lesson from body failure to partial acquisition without requiring a real file descriptor, production connection or nondeterministic external dependency.

Optional stretch — after the core task works: For a stretch, make the second resource fail during acquisition and verify that the first entered resource still exits. Keep the failing resource's acquisition and cleanup semantics explicit. This extends the lesson from body failure to partial acquisition without requiring a real file descriptor, production connection or nondeterministic external dependency.

Computer practice: https://applydjinn.com/typorithm/python-engineering-lab-20261107.zip. Use an isolated Python 3.13 environment, install requirements.txt, then run verify.py. Edit exercises/pye-contexts.py and run --practice pye-contexts. Fixture files are created in fresh temporary directories for every case. Packaging checks build real local artifacts, install the exact wheel without an index and run outside the source tree. No package is published to PyPI. The app includes static examples, prepared results and questions; it does not run Python or provide an editor.

Own a variable number of resources and preserve cleanup when the body fails.