Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Files, Paths & Encodings  ›  Lesson

Writing Your Own Context Manager

Writing Context Managers 24 minCleanup that cannot be skipped — as a class, or as a generator with one yield
You're building a piece ofQuarry — the yard
This piece — atomic_write(): Rewrites a file so a crash halfway leaves the old version whole, never a torn one.
Scenario Quarry rewrites its summary report every run. Last week the machine lost power halfway through and the report was left half-written, which broke the next run. The guard makes that impossible.
Your task
Write the context manager replacing(path) with @contextmanager. It opens a sibling file named path.name + ".tmp" for writing and yields it. If the with-body finishes, it closes the temp file and os.replace()s it over path; if the body raises, it closes and deletes the temp file and re-raises. The driver atomic_write(original, new_text, crash) is already written and calls it. Example: atomic_write("old", "new", True) → {"content": "old", "files": ["report.txt"]} — the crash leaves the old file whole and no temp file behind.

Writing Your Own Context Manager

In short: a context manager is any object with __enter__ and __exit__; with mgr as v: calls __enter__ and binds its return value to v, then calls __exit__ no matter how the block ends — on a clean exit its three arguments are None, on an exception they describe it, and if __exit__ returns something truthy the exception is swallowed. contextlib.contextmanager turns a generator with one yield into the same thing: everything before yield is __enter__, the yielded value is what as binds, everything after is __exit__ — but it must sit in a try/finally, because an exception from the body is thrown back in at the yield, and code after it only runs if a finally catches that detour. Several managers exit in reverse order, last-entered first, same as nested with blocks.

A generator function opened(name): the log line "open" runs, then the function parks at yield and hands control to the with-body; when the body finishes (or raises), execution resumes after yield — but only if that line sits inside a finally

Announce a stage, pass or fail

A pipeline stage should report whether it succeeded, without every stage writing that logic by hand. A class with __enter__ and __exit__ does it once:

python

The parse stage reported its failure and still let the exception through, because __exit__ returned None. That's the right default — a manager cleans up and reports; it doesn't decide the error doesn't matter. Returning something truthy is an opt-in to silence, and it's easy to get wrong by accident:

What does this print?
class Hush:
    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc, tb):
        return True

with Hush():
    1 / 0
    print("inside")
print("after")

Open every file in a batch at once

Quarry sometimes needs to open every file in a batch together — and the batch size comes from the data, so with a, b, c: won't do. contextlib.ExitStack holds a variable number of managers and unwinds them all when its own with ends:

python

enter_context enters a manager now and schedules its exit for later. If opening the third file had failed partway through, the first two would still be closed — nothing leaks. When several managers are known ahead of time, plain nesting unwinds the same way, last-in first-out:

What does this print?
log = []

class Tag:
    def __init__(self, name):
        self.name = name

    def __enter__(self):
        log.append("enter " + self.name)

    def __exit__(self, exc_type, exc, tb):
        log.append("exit " + self.name)

with Tag("a"), Tag("b"):
    log.append("body")
print(", ".join(log))

Here is the same unwinding as a diagram — push on the way in, pop on the way out, and an exception takes the same exit route:

Drawing diagram…

Rewrite a report without losing the old one on a crash

Last week Quarry's machine lost power while rewriting its summary report. The file was opened with "w" — which empties it immediately — and only half the new report reached disk. The fix: never write over the real file. Write into a temporary sibling, and only when it's complete, rename it over the original with os.replace, which is atomic — any reader sees either the whole old file or the whole new one, never a half-written one.

Writing a class with two dunder methods for a five-line cleanup is a lot of ceremony. @contextmanager turns a generator into the same thing. A generator is a function that can pause — calling it doesn't run it; each resume runs up to the next yield and parks there with its locals intact:

step through it
1from contextlib import contextmanager
2 
3log = []
4 
5@contextmanager
6def opened(name):
7 log.append("open " + name)
8 try:
9 yield name.upper()
10 finally:
11 log.append("close " + name)
12 
13with opened("feed.log") as handle:
14 log.append("using " + handle)
15print(log)

That try/finally isn't decoration. If the with body raises, @contextmanager throws that exception into the generator, at the yield line. Without a finally, the lines after yield never run, and the cleanup silently goes missing:

What does this print?
from contextlib import contextmanager
log = []

@contextmanager
def opened(name):
    log.append("open " + name)
    yield name
    log.append("close " + name)

try:
    with opened("a.log"):
        log.append("body")
        raise ValueError("bad row")
except ValueError:
    pass
print(log)

Quarry's guard follows the same shape: before the yield, open the .tmp sibling; yield the open file for the caller to write into; if the body finished, close it and os.replace it over the original; if it raised, close it, delete the .tmp, and re-raise — the guard cleans up, it doesn't pretend the write succeeded.

Quick practice

broken — fix it

Run it. Only [4] prints, not [4, 10]. Move the with so each value gets its own chance.

What does this print?
from contextlib import closing

class Handle:
    def __init__(self):
        self.closed = False
    def close(self):
        self.closed = True

h = Handle()
with closing(h) as handle:
    pass
print(handle is h, h.closed)
What does this print?
class Counter:
    def __enter__(self):
        return 0
    def __exit__(self, exc_type, exc, tb):
        return False

with Counter() as n:
    n += 1
print(n)
What happens when this runs?
from contextlib import contextmanager

@contextmanager
def guarded():
    try:
        yield "ok"
    except ZeroDivisionError:
        yield "recovered"

with guarded() as v:
    1 / 0
    print(v)

🎯 Your turn

Write replacing(path):

  • atomic_write("old", "new", False) → {"content": "new", "files": ["report.txt"]}
  • atomic_write("old report", "new report", True) → {"content": "old report", "files": ["report.txt"]}

Hint — tmp = path.with_name(path.name + ".tmp"); wrap yield f in try/except BaseException/else; success replaces, failure unlinks and re-raises.

Then press ▶ Run and hit ✓ Check. ✅

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upEnter, body, exit

Write a class Guard whose __enter__ appends "enter" to a list and whose __exit__ appends "exit" (and returns nothing). Then write guarded_events(fail): inside with Guard(events): append "body" and, if fail, raise ValueError. Catch it outside the with and append "caught". Return the list.

context-managers__exit__
DrillSkip what won't parse

Write parse_ints(values) that converts each string to an int and skips the ones that fail, using contextlib.suppress(ValueError) rather than try/except. parse_ints(["4", "x", "10"]) → [4, 10].

context-managerssuppress
BuildA variable number of guards

Write a class Tag(name, log) that logs "enter NAME" and "exit NAME". Then write nest_order(names) that enters one Tag per name using contextlib.ExitStack (you don't know how many there are), appends "body" once inside, and returns the log.

context-managersexitstack
BossA stage that reports and carries on

Quarry runs stages and wants a report line per stage without one failure stopping the rest. Write @contextmanager stage(name, report) that appends "NAME: ok" if the body finishes, or "NAME: ExcType" and SWALLOWS the exception if it raises. Then run_stages(divisors) runs 100 / d inside stage(f"s{i}", report) for each divisor and returns the report.

context-managerscontextmanager
CapstoneAll of a batch, or none of it

Write a class-based manager Transaction(store): on enter it snapshots the dict (dict(store)), on a clean exit it keeps the changes, and on an exception it restores the snapshot and swallows the error (only ValueError). Then apply_batches(store, batches): each batch is a list of [key, delta] pairs; apply each batch inside a Transaction, adding delta to store.get(key, 0) and raising ValueError if any value would go below zero. Return {"store": store, "applied": [True/False per batch]}.

context-managersrollbackdict
ExplainWhy the cleanup goes in a finally

A teammate writes an @contextmanager with setup, then yield, then cleanup — no try. It works in every test they ran. Explain exactly when the cleanup is skipped, why, and what __exit__ returning True has to do with it.

atomic_write.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
atomic_write(original, new_text, crash) → dictWhat report.txt holds afterwards, and which files are left in the folder.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.