Run it
All three exits observed from inside __exit__, the suppression case. And a return from the body that still runs the cleanup.
All three exits, observed from inside __exit__.
A return from inside the body cannot skip the cleanup either.
The generator form, and why the try/finally is not optional.
The protocol, in full
with expr as name: does four things. It evaluates expr. It calls __enter__() on the result and binds the return value to name. Note that this is not always the object itself, which is why open() can return a file and a lock can return None. It runs the body. Then it calls __exit__(exc_type, exc, traceback).
The last step is the point. It happens on a normal fall-through, on a return from inside the body, on a break. And on an error. That is a stronger promise than try/finally written by hand, only because it is harder to forget.
Suppression, and why it is rare
A truthy return from __exit__ tells Python the error has been handled, and execution goes on after the block. It is a real feature with one common use — contextlib.suppress(FileNotFoundError) is a context manager whose entire job is to return True for the types you named.
Writing it yourself is often a mistake because a manager that swallows errors silently is a manager that hides bugs. The convention is to return None and let the error through, doing cleanup on the way out. If you do suppress, suppress one set type and nothing else.
contextlib, and the generator form
Writing a class with two dunder methods for a two-line cleanup is heavy, and contextlib.contextmanager removes it:
@contextmanager
def timed(label):
start = time.perf_counter()
try:
yield # the body runs here
finally:
print(label, time.perf_counter() - start)
All of it before the yield is __enter__. All of it after is __exit__. The try/finally is not optional — without it, an error in the body propagates out of the yield and the cleanup never runs. This quietly defeats the entire purpose.
ExitStack is the other one worth knowing. It manages a variable number of managers. So you can enter a list of files without a nested with per file.
The follow-up: why not just use try/finally
You can, and with compiles to roughly that. The gap is that the cleanup lives with the resource rather than with each call site. So it cannot be forgotten at the twelfth place the resource is used. It also composes: with a, b: nests correctly. And both exits run even if the first one raises.
The async counterpart is async with and __aenter__/__aexit__. This is what an async HTTP client or store session uses. Same protocol, awaitable methods.
Edge cases to raise
Volunteering these is most of what separates a correct answer from a good one.
with a, b: is not the same as two separate blocks on failure. It nests, so if b's __enter__ raises, a's __exit__ still runs -. This is the behaviour you want and the reason to prefer the comma form over two statements.
A @contextmanager generator can only be used once. The object it returns is a one-shot generator. So re-entering the same manager instance raises. Call the factory again instead, or write a class.
Suppressing broadly hides bugs. If __exit__ returns True unconditionally, each error in each body disappears - including KeyboardInterrupt and typos. Suppress one named type or nothing.