Context managers¶
Two unrelated protocols¶
Python has two ways to write a context manager, and neither is simple. The raw protocol needs two methods with an awkward, easy-to-misuse shape:
class Lock:
def __enter__(self):
self._acquire()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self._release()
return False # forgetting this, or returning anything else
# truthy, silently swallows the exception
__exit__'s three exception-info parameters exist only so it can
decide whether to suppress the exception, and it decides that by
return value — a convention easy to get wrong by accident, since any
stray truthy return silently swallows an exception nobody meant to
catch.
The shortcut, @contextmanager, avoids writing __exit__ by hand,
at the cost of a decorator, a generator function, and a
try/finally the author has to remember unprompted:
from contextlib import contextmanager
@contextmanager
def locked(lock):
lock.acquire()
try:
yield lock
finally:
lock.release()
try/finally and the bug is silent: lock.release()
simply never runs if the with-block raises, because an exception
injected at a bare yield propagates straight out of the generator
with nothing left to run afterward. Two protocols exist because the
second was bolted onto the first later, not because either is the
obviously right shape for the problem — the same situation
Modern type specification describes for
Python's own class-specification tools, one level down.
One modifier, one shape¶
Lucid has one way to write a context manager: contextmanager, a
modifier that stacks in front of def or classmethod the same way
Explicit overrides's override already does, rather
than replacing it the way classmethod, factory, getter, and
setter do:
yield is setup; the yielded value is what as binds;
code after yield is teardown. Nothing here is a generator in the
sense that matters to a reader — yield still means "suspend here,
hand a value across, resume later," the same meaning it already has —
but a contextmanager body yields exactly once, checked, not left to
convention the way Python's version is.
Guaranteed cleanup needs no try¶
If yield is not already inside an explicit try, the compiler
wraps everything after it in an implicit finally: teardown runs
whether the with-block raised or not, with nothing written by hand
to guarantee it.
contextmanager def locked(lock: Lock):
lock.acquire()
yield lock
lock.release() # always runs -- no try/finally needed to say so
try/finally in Python
only because nothing else would run the cleanup for you. Here, not
writing one is not a bug waiting to happen; it is what the modifier
already means.
Explicit try for anything more¶
Wrapping yield in an explicit try opts back into full control,
using nothing beyond ordinary try/except/raise (see
Exceptions) — no separate
suppression mechanism, and no return value doing double duty the way
__exit__'s does:
contextmanager def transaction(conn: Connection):
conn.begin()
try:
yield conn
conn.commit()
except:
conn.rollback()
raise
except without a raise already discards what it catches.
Suppression is visible at the exact except clause that causes it,
never a side effect of some unrelated return value.
Making a class itself usable in with¶
with x: — no call, the instance itself managed — desugars to
with x.__cm__():. A class opts in by declaring contextmanager def
__cm__(self):, folding Python's separate __enter__/__exit__
protocol into the same modifier used everywhere else, rather than
asking an author to learn a second protocol for this one case:
class Session:
conn: Connection
contextmanager def __cm__(self):
self.conn.begin()
yield self
self.conn.commit()
with Session(conn):
...
__cm__ is dunder-spelled for the same reason __eq__ is, not the
reason __len__ is (see Numeric types's
Sized): it is tied to with syntax specifically, not competing
with an ordinary word a class might separately want for something else.
classmethod combines the same way¶
A resource that is both constructed and managed — open, connect,
acquire — needs both modifiers at once, contextmanager always
first, the same order whether the other modifier is def or
classmethod:
class Session:
conn: Connection
contextmanager classmethod open(cls, path: str) -> Session:
conn = Connection.connect(path)
yield Session(conn)
conn.close()
with Session.open("db.sqlite") as s:
...
contextmanager does not combine with getter, setter, or
factory. A getter's contract ends at "compute and return a value,"
and a factory's ends at "return a fully constructed object" — neither
has anywhere for a "resume after the block" to attach to the way def
and classmethod do.
None of this touches the calling side — With covers
with expr: and with expr as name:, unchanged from Python.