Four small tools from contextlib. Each replaces a specific piece of
boilerplate, and three of them have a sharp edge.
nullcontext: the optional-resource pattern
The duplication it removes:
if stream is not None:
result = process(stream) # body, written once
else:
with open(path) as f:
result = process(f) # body, written again
Two copies of the body, which drift. With nullcontext:
scope = nullcontext(stream) if stream is not None else open(path)
with scope as active:
result = process(active)
nullcontext(x) is a context manager that yields x and does nothing on
exit. The caller-supplied stream is therefore not closed by the callee,
and the internally-opened one is — which is the correct ownership rule and the
reason this is more than a cosmetic tidy-up. Closing a handle you did not open
is one of the classic ways to break a caller.
The generic is what makes it type-check: nullcontext() reveals
nullcontext[None] and nullcontext(5) reveals nullcontext[int], so the
conditional expression above unifies to AbstractContextManager[Stream] and
active is typed correctly in the body.
closing: adapting an object that only has close()
with closing(urlopen(url)) as page:
...
Calls close() on exit, nothing on entry. For any object with a close() and
no __enter__ — older library objects, urlopen on some versions, a hand-
written handle. If the object is a context manager already, closing is
redundant and slightly misleading.
suppress: not the same as except: pass
with suppress(FileNotFoundError):
return load()
return default
The important, easily-missed property: when the exception fires, the rest of
the block is abandoned. It is not “ignore the error and carry on with the
next statement” — it is “jump to the end of the with“. So this is wrong:
with suppress(KeyError):
a = d["a"]
b = d["b"] # skipped entirely if "a" was missing
Use one suppress per statement whose failure you are willing to ignore, or
a try/except if you need the following statements to run.
And the exception tuple must be narrow. suppress(Exception) around a
file read swallows PermissionError, IsADirectoryError, OSError from a
full disk, and every bug in the code you called — turning a misconfigured
deployment into a silently empty config. Name the exceptions you actually
expect.
Since 3.12, suppress also removes matching exceptions from a
BaseExceptionGroup and re-raises the group with the rest, which makes it
work sensibly inside a TaskGroup.
💡with suppress(FileNotFoundError): return load() followed by
click to reveal
return default — how does the type checker know the second return is
reachable, given the first one is unconditional?
Because suppress.__exit__ is typed as returning bool, not
Literal[False]. mypy treats a with whose __exit__ may return truthy as
able to swallow an exception, which means control can reach the statement
after the block even when the body ends in return.
This matters more than it sounds, because --strict includes
--warn-unreachable in many setups: if __exit__ were typed
-> Literal[False] (as it is for open()), mypy would mark
return default unreachable and error on it. The stub author’s choice of
return type is what makes the idiom expressible.
It also tells you how to type your own suppressing context manager: annotate
__exit__ as -> bool when it may suppress, and -> None when it never
does. Getting that wrong produces spurious unreachable-code errors in every
caller.
chdir: correct and dangerous
contextlib.chdir(path) (3.11) changes the working directory and restores it
on exit. It is reentrant — nesting works, each level restores the previous
directory.
It is also not thread-safe, and cannot be: the working directory is
process-global state. A chdir in one thread silently changes what every
relative path in every other thread resolves to. The same applies to the
asyncio event loop — a chdir around an await moves the directory for
whatever coroutine runs next.
Use it in single-threaded scripts and tests. In a server, pass absolute paths
instead; pathlib makes that painless.
💡What is the type of nullcontext(stream) if stream is not None else closing(open(path)),
click to reveal
and why does mypy need help with it?
Each branch is a different class — nullcontext[TextIO] and
closing[TextIO] — and mypy joins the branches of a conditional expression to
their nearest common supertype. Here that is AbstractContextManager[TextIO],
which is what you want, but the inference can land somewhere less useful when
the branches are more distant.
The robust move is to annotate the variable:
scope: AbstractContextManager[TextIO] = (
nullcontext(stream) if stream is not None else closing(open(path))
)
Now the checker verifies both branches against the declared type rather than
inventing one, and with scope as active: gives active: TextIO regardless.
The general habit: when a conditional expression produces values of different classes that share only an interface, declare the interface. It converts a quiet inference into a checked contract.