Skip to content

← Stdlib Mastery step 37 of 55

Hard Primitives

ExitStack: all-or-nothing acquisition of N resources

Open a runtime-determined number of resources with all-or-nothing semantics: if any acquisition fails, everything already acquired is released and the error propagates.

class Handle(Protocol):
    name: str
    def close(self) -> None: ...


@contextmanager
def open_all(
    names: Sequence[str], opener: Callable[[str], Handle]
) -> Iterator[list[Handle]]: ...
class FakeFile:
    def __init__(self, name: str, log: list[str]) -> None: ...
    def close(self) -> None: ...   # sets self.closed, appends f"close:{name}"


def solve(names: list[str], fail_on: str) -> dict[str, object]:

solve builds a recording opener that appends f"open:{name}" and returns a FakeFile, except for fail_on, where it appends f"error:{name}" and raises OSError(name). It then runs open_all(names, opener), appending "body" inside the block, and returns:

{"log": [...], "opened": [handle.name for each handle], "error": "OSError:<name>" or ""}

On failure, opened stays [] and the error string is recorded.

The log is the whole assertion. For ["a", "b", "c"] with no failure it must be:

["open:a", "open:b", "open:c", "body", "close:c", "close:b", "close:a"]

Note the reverse unwind order — the same guarantee nested with statements give, and it matters whenever resources depend on each other (a transaction has to commit before its connection closes). With fail_on="b" the log is ["open:a", "error:b", "close:a"]: the partially-acquired handle is released before the exception leaves.

Why this needs ExitStack. There is no with syntax for a runtime number of resources. The hand-rolled version is a try/finally around a partially-populated list, with a cleanup loop that has to tolerate the entries that were never filled — and it is wrong more often than it is right. The naive handles = [opener(n) for n in names] followed by a close loop after the yield leaks every handle opened before the failing one, and leaks all of them if the body raises.

Two API details worth knowing. stack.callback(fn, ...) registers a plain function, but such callbacks cannot suppress exceptions — they never see them, and are called with no arguments regardless of how the block exited. Cleanup that must distinguish commit from rollback has to be a real context manager. And ExitStack is reusable but not reentrant: entering the same stack twice means leaving the inner with fires every callback registered so far, including the outer block’s.

FakeFile is not itself a context manager, so wrap it — contextlib.closing turns any object with a close() into one.

The Protocol is doing real work here: open_all never mentions FakeFile, so the same function sequences any resource with a name and a close().