Skip to content

Commit 1d1f1e8

Browse files
committed
bpo-25625: add contextlib.chdir
This is probably the single snippet of code I find myself re-implementing the most in projects. Not being thread safe is not optimal, but there isn't really any good way to do so, and that does not negate the huge usefulness of this function. Signed-off-by: Filipe Laíns <lains@riseup.net>
1 parent 62fa613 commit 1d1f1e8

File tree

4 files changed

+77
-3
lines changed

4 files changed

+77
-3
lines changed

Doc/library/contextlib.rst

+14-2
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,18 @@ Functions and classes provided:
353353
.. versionadded:: 3.5
354354

355355

356+
.. function:: chdir(path)
357+
358+
Non thread-safe context manager to change the current working directory.
359+
360+
This is a simple wrapper around :func:`~os.chdir`, it changes the current
361+
working directory upon entering and restores the old one on exit.
362+
363+
This context manager is :ref:`reentrant <reentrant-cms>`.
364+
365+
.. versionadded:: 3.11
366+
367+
356368
.. class:: ContextDecorator()
357369

358370
A base class that enables a context manager to also be used as a decorator.
@@ -900,8 +912,8 @@ but may also be used *inside* a :keyword:`!with` statement that is already
900912
using the same context manager.
901913

902914
:class:`threading.RLock` is an example of a reentrant context manager, as are
903-
:func:`suppress` and :func:`redirect_stdout`. Here's a very simple example of
904-
reentrant use::
915+
:func:`suppress`, :func:`redirect_stdout` and :func:`chdir`. Here's a very
916+
simple example of reentrant use::
905917

906918
>>> from contextlib import redirect_stdout
907919
>>> from io import StringIO

Lib/contextlib.py

+18-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Utilities for with-statement contexts. See PEP 343."""
22
import abc
3+
import os
34
import sys
45
import _collections_abc
56
from collections import deque
@@ -9,7 +10,8 @@
910
__all__ = ["asynccontextmanager", "contextmanager", "closing", "nullcontext",
1011
"AbstractContextManager", "AbstractAsyncContextManager",
1112
"AsyncExitStack", "ContextDecorator", "ExitStack",
12-
"redirect_stdout", "redirect_stderr", "suppress", "aclosing"]
13+
"redirect_stdout", "redirect_stderr", "suppress", "aclosing",
14+
"chdir"]
1315

1416

1517
class AbstractContextManager(abc.ABC):
@@ -754,3 +756,18 @@ async def __aenter__(self):
754756

755757
async def __aexit__(self, *excinfo):
756758
pass
759+
760+
761+
class chdir(AbstractContextManager):
762+
"""Non thread-safe context manager to change the current working directory."""
763+
764+
def __init__(self, path):
765+
self.path = path
766+
self._old_cwd = []
767+
768+
def __enter__(self):
769+
self._old_cwd.append(os.getcwd())
770+
os.chdir(self.path)
771+
772+
def __exit__(self, *excinfo):
773+
os.chdir(self._old_cwd.pop())

Lib/test/test_contextlib.py

+43
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Unit tests for contextlib.py, and other context managers."""
22

33
import io
4+
import os
45
import sys
56
import tempfile
67
import threading
@@ -1080,5 +1081,47 @@ def test_cm_is_reentrant(self):
10801081
1/0
10811082
self.assertTrue(outer_continued)
10821083

1084+
1085+
class TestChdir(unittest.TestCase):
1086+
def test_simple(self):
1087+
old_cwd = os.getcwd()
1088+
target = os.path.join(os.path.dirname(__file__), 'data')
1089+
assert old_cwd != target
1090+
1091+
with chdir(target):
1092+
assert os.getcwd() == target
1093+
assert os.getcwd() == old_cwd
1094+
1095+
def test_reentrant(self):
1096+
old_cwd = os.getcwd()
1097+
target1 = os.path.join(os.path.dirname(__file__), 'data')
1098+
target2 = os.path.join(os.path.dirname(__file__), 'ziptestdata')
1099+
assert old_cwd not in (target1, target2)
1100+
chdir1, chdir2 = chdir(target1), chdir(target2)
1101+
1102+
with chdir1:
1103+
assert os.getcwd() == target1
1104+
with chdir2:
1105+
assert os.getcwd() == target2
1106+
with chdir1:
1107+
assert os.getcwd() == target1
1108+
assert os.getcwd() == target2
1109+
assert os.getcwd() == target1
1110+
assert os.getcwd() == old_cwd
1111+
1112+
def test_exception(self):
1113+
old_cwd = os.getcwd()
1114+
target = os.path.join(os.path.dirname(__file__), 'data')
1115+
assert old_cwd != target
1116+
1117+
try:
1118+
with chdir(target):
1119+
assert os.getcwd() == target
1120+
raise RuntimeError()
1121+
except RuntimeError:
1122+
pass
1123+
assert os.getcwd() == old_cwd
1124+
1125+
10831126
if __name__ == "__main__":
10841127
unittest.main()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Added :func:`~contextlib.chdir` context manager to change the current working
2+
directory and then restore it on exit. Simple wrapper around :func:`~os.chdir`.

0 commit comments

Comments
 (0)