From 1d1f1e8b53a67275722fb7a3170f49ff606ce04c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Fri, 10 Sep 2021 15:28:16 +0100 Subject: [PATCH 1/7] bpo-25625: add contextlib.chdir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Doc/library/contextlib.rst | 16 ++++++- Lib/contextlib.py | 19 +++++++- Lib/test/test_contextlib.py | 43 +++++++++++++++++++ .../2021-09-10-12-53-28.bpo-25625.SzcBCw.rst | 2 + 4 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2021-09-10-12-53-28.bpo-25625.SzcBCw.rst diff --git a/Doc/library/contextlib.rst b/Doc/library/contextlib.rst index bc38a63a52d97c..508d1edd73cc62 100644 --- a/Doc/library/contextlib.rst +++ b/Doc/library/contextlib.rst @@ -353,6 +353,18 @@ Functions and classes provided: .. versionadded:: 3.5 +.. function:: chdir(path) + + Non thread-safe context manager to change the current working directory. + + This is a simple wrapper around :func:`~os.chdir`, it changes the current + working directory upon entering and restores the old one on exit. + + This context manager is :ref:`reentrant `. + + .. versionadded:: 3.11 + + .. class:: ContextDecorator() 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 using the same context manager. :class:`threading.RLock` is an example of a reentrant context manager, as are -:func:`suppress` and :func:`redirect_stdout`. Here's a very simple example of -reentrant use:: +:func:`suppress`, :func:`redirect_stdout` and :func:`chdir`. Here's a very +simple example of reentrant use:: >>> from contextlib import redirect_stdout >>> from io import StringIO diff --git a/Lib/contextlib.py b/Lib/contextlib.py index 8343d7e5196713..a5e84d9e19a80a 100644 --- a/Lib/contextlib.py +++ b/Lib/contextlib.py @@ -1,5 +1,6 @@ """Utilities for with-statement contexts. See PEP 343.""" import abc +import os import sys import _collections_abc from collections import deque @@ -9,7 +10,8 @@ __all__ = ["asynccontextmanager", "contextmanager", "closing", "nullcontext", "AbstractContextManager", "AbstractAsyncContextManager", "AsyncExitStack", "ContextDecorator", "ExitStack", - "redirect_stdout", "redirect_stderr", "suppress", "aclosing"] + "redirect_stdout", "redirect_stderr", "suppress", "aclosing", + "chdir"] class AbstractContextManager(abc.ABC): @@ -754,3 +756,18 @@ async def __aenter__(self): async def __aexit__(self, *excinfo): pass + + +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()) diff --git a/Lib/test/test_contextlib.py b/Lib/test/test_contextlib.py index 43b8507771e25a..b3bad58485060e 100644 --- a/Lib/test/test_contextlib.py +++ b/Lib/test/test_contextlib.py @@ -1,6 +1,7 @@ """Unit tests for contextlib.py, and other context managers.""" import io +import os import sys import tempfile import threading @@ -1080,5 +1081,47 @@ def test_cm_is_reentrant(self): 1/0 self.assertTrue(outer_continued) + +class TestChdir(unittest.TestCase): + def test_simple(self): + old_cwd = os.getcwd() + target = os.path.join(os.path.dirname(__file__), 'data') + assert old_cwd != target + + with chdir(target): + assert os.getcwd() == target + assert os.getcwd() == old_cwd + + def test_reentrant(self): + old_cwd = os.getcwd() + target1 = os.path.join(os.path.dirname(__file__), 'data') + target2 = os.path.join(os.path.dirname(__file__), 'ziptestdata') + assert old_cwd not in (target1, target2) + chdir1, chdir2 = chdir(target1), chdir(target2) + + with chdir1: + assert os.getcwd() == target1 + with chdir2: + assert os.getcwd() == target2 + with chdir1: + assert os.getcwd() == target1 + assert os.getcwd() == target2 + assert os.getcwd() == target1 + assert os.getcwd() == old_cwd + + def test_exception(self): + old_cwd = os.getcwd() + target = os.path.join(os.path.dirname(__file__), 'data') + assert old_cwd != target + + try: + with chdir(target): + assert os.getcwd() == target + raise RuntimeError() + except RuntimeError: + pass + assert os.getcwd() == old_cwd + + if __name__ == "__main__": unittest.main() diff --git a/Misc/NEWS.d/next/Library/2021-09-10-12-53-28.bpo-25625.SzcBCw.rst b/Misc/NEWS.d/next/Library/2021-09-10-12-53-28.bpo-25625.SzcBCw.rst new file mode 100644 index 00000000000000..2ea1301a21eb08 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2021-09-10-12-53-28.bpo-25625.SzcBCw.rst @@ -0,0 +1,2 @@ +Added :func:`~contextlib.chdir` context manager to change the current working +directory and then restore it on exit. Simple wrapper around :func:`~os.chdir`. From b6d9e3e7a09250697987288adb3fff1265f5015d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Fri, 10 Sep 2021 18:08:33 +0200 Subject: [PATCH 2/7] Ensure own exception --- Lib/test/test_contextlib.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_contextlib.py b/Lib/test/test_contextlib.py index b3bad58485060e..eee68df5fccc4b 100644 --- a/Lib/test/test_contextlib.py +++ b/Lib/test/test_contextlib.py @@ -1117,8 +1117,9 @@ def test_exception(self): try: with chdir(target): assert os.getcwd() == target - raise RuntimeError() - except RuntimeError: + raise RuntimeError("boom") + except RuntimeError as re: + assert str(re) == "boom" pass assert os.getcwd() == old_cwd From cd6302472c6367918fa7646088ef715f5c9f3b53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Fri, 10 Sep 2021 18:10:04 +0200 Subject: [PATCH 3/7] Use Oxford comma --- Doc/library/contextlib.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/library/contextlib.rst b/Doc/library/contextlib.rst index 508d1edd73cc62..40a434bb0158f3 100644 --- a/Doc/library/contextlib.rst +++ b/Doc/library/contextlib.rst @@ -912,7 +912,7 @@ but may also be used *inside* a :keyword:`!with` statement that is already using the same context manager. :class:`threading.RLock` is an example of a reentrant context manager, as are -:func:`suppress`, :func:`redirect_stdout` and :func:`chdir`. Here's a very +:func:`suppress`, :func:`redirect_stdout`, and :func:`chdir`. Here's a very simple example of reentrant use:: >>> from contextlib import redirect_stdout From 6bdf5bbfa4fc277ce6361bbbc25cb6c8eeb8e595 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Fri, 10 Sep 2021 21:52:16 +0200 Subject: [PATCH 4/7] Update Lib/test/test_contextlib.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Filipe Laíns --- Lib/test/test_contextlib.py | 1 - 1 file changed, 1 deletion(-) diff --git a/Lib/test/test_contextlib.py b/Lib/test/test_contextlib.py index eee68df5fccc4b..c2a8ae6c87f903 100644 --- a/Lib/test/test_contextlib.py +++ b/Lib/test/test_contextlib.py @@ -1120,7 +1120,6 @@ def test_exception(self): raise RuntimeError("boom") except RuntimeError as re: assert str(re) == "boom" - pass assert os.getcwd() == old_cwd From e30c9d89237b2dbefcc0bd3b46d8f117d1fdce13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Wed, 15 Sep 2021 12:58:02 +0100 Subject: [PATCH 5/7] Serhiy's feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- Doc/library/contextlib.rst | 7 ++++++- Lib/test/test_contextlib.py | 28 ++++++++++++++-------------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/Doc/library/contextlib.rst b/Doc/library/contextlib.rst index 40a434bb0158f3..ae0ee7232a10c5 100644 --- a/Doc/library/contextlib.rst +++ b/Doc/library/contextlib.rst @@ -355,7 +355,12 @@ Functions and classes provided: .. function:: chdir(path) - Non thread-safe context manager to change the current working directory. + Non parallel-safe context manager to change the current working directory. + As this changes a global state, the working directory, it is not suitable + for use in most threaded or aync contexts. It is also not suitable for most + non-linear code execution, like generators, where the program execution is + temporarily relinquished -- unless explicitely desired, you should not yield + when this context manager is active. This is a simple wrapper around :func:`~os.chdir`, it changes the current working directory upon entering and restores the old one on exit. diff --git a/Lib/test/test_contextlib.py b/Lib/test/test_contextlib.py index c2a8ae6c87f903..468e5f7c61c895 100644 --- a/Lib/test/test_contextlib.py +++ b/Lib/test/test_contextlib.py @@ -1086,41 +1086,41 @@ class TestChdir(unittest.TestCase): def test_simple(self): old_cwd = os.getcwd() target = os.path.join(os.path.dirname(__file__), 'data') - assert old_cwd != target + self.assertNotEqual(old_cwd, target) with chdir(target): - assert os.getcwd() == target - assert os.getcwd() == old_cwd + self.assertEqual(os.getcwd(), target) + self.assertEqual(os.getcwd(), old_cwd) def test_reentrant(self): old_cwd = os.getcwd() target1 = os.path.join(os.path.dirname(__file__), 'data') target2 = os.path.join(os.path.dirname(__file__), 'ziptestdata') - assert old_cwd not in (target1, target2) + self.assertNotIn(old_cwd, (target1, target2)) chdir1, chdir2 = chdir(target1), chdir(target2) with chdir1: - assert os.getcwd() == target1 + self.assertEqual(os.getcwd(), target1) with chdir2: - assert os.getcwd() == target2 + self.assertEqual(os.getcwd(), target2) with chdir1: - assert os.getcwd() == target1 - assert os.getcwd() == target2 - assert os.getcwd() == target1 - assert os.getcwd() == old_cwd + self.assertEqual(os.getcwd(), target1) + self.assertEqual(os.getcwd(), target2) + self.assertEqual(os.getcwd(), target1) + self.assertEqual(os.getcwd(), old_cwd) def test_exception(self): old_cwd = os.getcwd() target = os.path.join(os.path.dirname(__file__), 'data') - assert old_cwd != target + self.assertNotEqual(old_cwd, target) try: with chdir(target): - assert os.getcwd() == target + self.assertEqual(os.getcwd(), target) raise RuntimeError("boom") except RuntimeError as re: - assert str(re) == "boom" - assert os.getcwd() == old_cwd + self.assertEqual(str(re), "boom") + self.assertEqual(os.getcwd(), old_cwd) if __name__ == "__main__": From 9ad9398e58b2fca1f5504d7ae5c36348432a31bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Sat, 18 Sep 2021 18:38:40 +0100 Subject: [PATCH 6/7] refeer that the cm is not parallel-safe in the news entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- .../next/Library/2021-09-10-12-53-28.bpo-25625.SzcBCw.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Misc/NEWS.d/next/Library/2021-09-10-12-53-28.bpo-25625.SzcBCw.rst b/Misc/NEWS.d/next/Library/2021-09-10-12-53-28.bpo-25625.SzcBCw.rst index 2ea1301a21eb08..4fd24484fa0cc8 100644 --- a/Misc/NEWS.d/next/Library/2021-09-10-12-53-28.bpo-25625.SzcBCw.rst +++ b/Misc/NEWS.d/next/Library/2021-09-10-12-53-28.bpo-25625.SzcBCw.rst @@ -1,2 +1,3 @@ -Added :func:`~contextlib.chdir` context manager to change the current working -directory and then restore it on exit. Simple wrapper around :func:`~os.chdir`. +Added non prarelel-safe :func:`~contextlib.chdir` context manager to change +the current working directory and then restore it on exit. Simple wrapper +around :func:`~os.chdir`. From ce102411d3da8c0528a903f1ce38082fc50a25f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Mon, 18 Oct 2021 20:18:17 +0100 Subject: [PATCH 7/7] fix typo in news MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- .../next/Library/2021-09-10-12-53-28.bpo-25625.SzcBCw.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Misc/NEWS.d/next/Library/2021-09-10-12-53-28.bpo-25625.SzcBCw.rst b/Misc/NEWS.d/next/Library/2021-09-10-12-53-28.bpo-25625.SzcBCw.rst index 4fd24484fa0cc8..c001683b657f58 100644 --- a/Misc/NEWS.d/next/Library/2021-09-10-12-53-28.bpo-25625.SzcBCw.rst +++ b/Misc/NEWS.d/next/Library/2021-09-10-12-53-28.bpo-25625.SzcBCw.rst @@ -1,3 +1,3 @@ -Added non prarelel-safe :func:`~contextlib.chdir` context manager to change +Added non parallel-safe :func:`~contextlib.chdir` context manager to change the current working directory and then restore it on exit. Simple wrapper around :func:`~os.chdir`.