(Extracted from the umask
thread)
In discussing the possibility of adding a stdlib context manager to support applying and reverting temporary umask changes in a process, we identified a common implementation pattern across the following contextlib
context managers:
contextlib.redirect_stdout
contextlib.redirect_stderr
contextlib.redirect_stdin
contextlib.chdir
The 3 stream redirection CMs already share a common base class (contextlib._RedirectStream
), but contextlib.chdir
is implemented independently.
The current implementation of contextlib.chdir
looks like this:
class chdir(AbstractContextManager):
"""Non thread-safe context manager to change the current working directory."""
def __init__(self, path):
self.path = path
self._old_cwd = []
def __enter__(self):
self._old_cwd.append(os.getcwd())
os.chdir(self.path)
def __exit__(self, *excinfo):
os.chdir(self._old_cwd.pop())
The key pieces of the pattern are:
- on entry, the existing value is stored, while the new value is applied
- on exit, the previous value (stored on entry) is reapplied
- old values are stored in a list to make the CM re-entrant
Additionally, while chdir doesnāt currently return anything from __enter__
, the stream redirection CMs return the new stream.
While this pattern isnāt hard to use once youāre aware of it, thereās no obvious path to learning it. We could add it to the contextlib
documentation purely as a recipe, but I think we can go a step further and offer an abstract base class that makes implementing such contexts even easier.
Proposed API (combining my preferred naming with @jb2170ās suggested public API from the other thread):
class ModificationContext(ContextDecorator, AbstractContextManager):
"""Context manager ABC to change a target value on entry and revert it on exit.
Reentrant and reusable as both a context manager and function decorator.
"""
def __init__(self, value):
self._applied_value = value
self._previous_values = []
@property
def applied_value(self):
return self._applied_value
def __repr__(self):
return f"{type(self).__qualname__}({self._applied_value!r})"
def __enter__(self):
self._previous_values.append(self.apply())
return self._applied_value
def __exit__(self, *exc_info):
self.revert(self._previous_values.pop())
@abstractmethod
def apply(self):
"""Apply the change and report the previous value to be restored.""
raise NotImplementedError
@abstractmethod
def revert(self, previous_value):
"""Revert the change, restoring the given previous value.""
raise NotImplementedError
Given that base class, contextlib.chdir
would become:
class chdir(ModificationContext):
"""Non thread-safe context manager to change the current working directory."""
@property
def path(self):
return self._applied_value
def apply(self):
previous_path = os.getcwd()
os.chdir(self._applicated_value)
return previous_path
def revert(self, previous_cwd):
os.chdir(previous_cwd)
The following example recipe should also be added to the documentation to illustrate storing extra state on a modification context (whether or not to add it to contextlib
as a simpler, non-test-specific alternative to unittest.mock.patch
can be a separate discussion):
class replace_attr(ModificationContext):
"""Non thread-safe context manager to replace an attribute on the given target."""
def __init__(self, target, attr, value):
self._target = target
self._attr = attr
super().__init__(value)
def __repr__(self):
return f"{type(self).__qualname__}({self._target!r}, {self._attr!r}, {self._applied_value!r})"
def apply(self):
previous_value = getattr(self._target, self._attr)
setattr(self._target, self._attr, self._applied_value)
return previous_value
def revert(self, previous_value):
setattr(self._target, self._attr, previous_value)
Additional notes:
-
The kinds of changes this API is intended to apply to shouldnāt require blocking IO operations, so Iām not proposing to add an asynchronous variant of this API. We can always add
AsyncModificationContext
later if someone demonstrates the need. -
In my original proposal, the public
apply()
andrevert()
methods exactly mirrored__enter__()
and__exit__()
, with subclasses implementing private_apply()
and_revert()
functions. @jb2170ās API change was to make the subclass methods the publicapply()
andrevert()
methods themselves. I decided this made for a more flexible API than my version, since API consumers can decide for themselves whether to use the native context management support (either directly or viaExitStack
), or some other mechanism of their own for passing state between theapply()
andrevert()
calls. -
There were a few potential names suggested for this base class in the previous thread, but Iām going to play the ā
contextlib
co-maintainer`ā card on this particular name (there are enough defensible candidates that I donāt think true general consensus is a likely possibility, but a combination of maintainer fiat + āEh, thatās good enoughā consensus seems achievable).ModificationContext
is broad enough to cover pretty much any plausible use case (unlikeSubtitutionContext
which is sometimes stretching the terminology for in-place mutation operations), but not so broad as to almost certainly be confusing (unlikeChangeContext
, which has that problem due to āchangeā being a synonym for both āmodifyā and āmodificationā, not just the latter). We also donāt need theAbstract
prefix here -AbstractContextManager
only has the prefix to distinguish it by more than letter casing from thecontextmanager
generator decorator.