Skip to content

Commit 086c6b1

Browse files
bpo-45046: Support context managers in unittest (GH-28045)
Add methods enterContext() and enterClassContext() in TestCase. Add method enterAsyncContext() in IsolatedAsyncioTestCase. Add function enterModuleContext().
1 parent 8f29318 commit 086c6b1

26 files changed

+307
-92
lines changed

Doc/library/unittest.rst

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1495,6 +1495,16 @@ Test cases
14951495
.. versionadded:: 3.1
14961496

14971497

1498+
.. method:: enterContext(cm)
1499+
1500+
Enter the supplied :term:`context manager`. If successful, also
1501+
add its :meth:`~object.__exit__` method as a cleanup function by
1502+
:meth:`addCleanup` and return the result of the
1503+
:meth:`~object.__enter__` method.
1504+
1505+
.. versionadded:: 3.11
1506+
1507+
14981508
.. method:: doCleanups()
14991509

15001510
This method is called unconditionally after :meth:`tearDown`, or
@@ -1510,6 +1520,7 @@ Test cases
15101520

15111521
.. versionadded:: 3.1
15121522

1523+
15131524
.. classmethod:: addClassCleanup(function, /, *args, **kwargs)
15141525

15151526
Add a function to be called after :meth:`tearDownClass` to cleanup
@@ -1524,6 +1535,16 @@ Test cases
15241535
.. versionadded:: 3.8
15251536

15261537

1538+
.. classmethod:: enterClassContext(cm)
1539+
1540+
Enter the supplied :term:`context manager`. If successful, also
1541+
add its :meth:`~object.__exit__` method as a cleanup function by
1542+
:meth:`addClassCleanup` and return the result of the
1543+
:meth:`~object.__enter__` method.
1544+
1545+
.. versionadded:: 3.11
1546+
1547+
15271548
.. classmethod:: doClassCleanups()
15281549

15291550
This method is called unconditionally after :meth:`tearDownClass`, or
@@ -1571,6 +1592,16 @@ Test cases
15711592

15721593
This method accepts a coroutine that can be used as a cleanup function.
15731594

1595+
.. coroutinemethod:: enterAsyncContext(cm)
1596+
1597+
Enter the supplied :term:`asynchronous context manager`. If successful,
1598+
also add its :meth:`~object.__aexit__` method as a cleanup function by
1599+
:meth:`addAsyncCleanup` and return the result of the
1600+
:meth:`~object.__aenter__` method.
1601+
1602+
.. versionadded:: 3.11
1603+
1604+
15741605
.. method:: run(result=None)
15751606

15761607
Sets up a new event loop to run the test, collecting the result into
@@ -2465,6 +2496,16 @@ To add cleanup code that must be run even in the case of an exception, use
24652496
.. versionadded:: 3.8
24662497

24672498

2499+
.. classmethod:: enterModuleContext(cm)
2500+
2501+
Enter the supplied :term:`context manager`. If successful, also
2502+
add its :meth:`~object.__exit__` method as a cleanup function by
2503+
:func:`addModuleCleanup` and return the result of the
2504+
:meth:`~object.__enter__` method.
2505+
2506+
.. versionadded:: 3.11
2507+
2508+
24682509
.. function:: doModuleCleanups()
24692510

24702511
This function is called unconditionally after :func:`tearDownModule`, or
@@ -2480,6 +2521,7 @@ To add cleanup code that must be run even in the case of an exception, use
24802521

24812522
.. versionadded:: 3.8
24822523

2524+
24832525
Signal Handling
24842526
---------------
24852527

Doc/whatsnew/3.11.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -758,6 +758,18 @@ unicodedata
758758
* The Unicode database has been updated to version 14.0.0. (:issue:`45190`).
759759

760760

761+
unittest
762+
--------
763+
764+
* Added methods :meth:`~unittest.TestCase.enterContext` and
765+
:meth:`~unittest.TestCase.enterClassContext` of class
766+
:class:`~unittest.TestCase`, method
767+
:meth:`~unittest.IsolatedAsyncioTestCase.enterAsyncContext` of
768+
class :class:`~unittest.IsolatedAsyncioTestCase` and function
769+
:func:`unittest.enterModuleContext`.
770+
(Contributed by Serhiy Storchaka in :issue:`45046`.)
771+
772+
761773
venv
762774
----
763775

Lib/distutils/tests/test_build_ext.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,7 @@ def setUp(self):
4141
# bpo-30132: On Windows, a .pdb file may be created in the current
4242
# working directory. Create a temporary working directory to cleanup
4343
# everything at the end of the test.
44-
change_cwd = os_helper.change_cwd(self.tmp_dir)
45-
change_cwd.__enter__()
46-
self.addCleanup(change_cwd.__exit__, None, None, None)
44+
self.enterContext(os_helper.change_cwd(self.tmp_dir))
4745

4846
def tearDown(self):
4947
import site

Lib/test/test__osx_support.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,7 @@ def setUp(self):
1919
self.maxDiff = None
2020
self.prog_name = 'bogus_program_xxxx'
2121
self.temp_path_dir = os.path.abspath(os.getcwd())
22-
self.env = os_helper.EnvironmentVarGuard()
23-
self.addCleanup(self.env.__exit__)
22+
self.env = self.enterContext(os_helper.EnvironmentVarGuard())
2423
for cv in ('CFLAGS', 'LDFLAGS', 'CPPFLAGS',
2524
'BASECFLAGS', 'BLDSHARED', 'LDSHARED', 'CC',
2625
'CXX', 'PY_CFLAGS', 'PY_LDFLAGS', 'PY_CPPFLAGS',

Lib/test/test_argparse.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,8 @@ def setUp(self):
4141
# The tests assume that line wrapping occurs at 80 columns, but this
4242
# behaviour can be overridden by setting the COLUMNS environment
4343
# variable. To ensure that this width is used, set COLUMNS to 80.
44-
env = os_helper.EnvironmentVarGuard()
44+
env = self.enterContext(os_helper.EnvironmentVarGuard())
4545
env['COLUMNS'] = '80'
46-
self.addCleanup(env.__exit__)
4746

4847

4948
class TempDirMixin(object):
@@ -3428,9 +3427,8 @@ class TestShortColumns(HelpTestCase):
34283427
but we don't want any exceptions thrown in such cases. Only ugly representation.
34293428
'''
34303429
def setUp(self):
3431-
env = os_helper.EnvironmentVarGuard()
3430+
env = self.enterContext(os_helper.EnvironmentVarGuard())
34323431
env.set("COLUMNS", '15')
3433-
self.addCleanup(env.__exit__)
34343432

34353433
parser_signature = TestHelpBiggerOptionals.parser_signature
34363434
argument_signatures = TestHelpBiggerOptionals.argument_signatures

Lib/test/test_getopt.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,10 @@
1111

1212
class GetoptTests(unittest.TestCase):
1313
def setUp(self):
14-
self.env = EnvironmentVarGuard()
14+
self.env = self.enterContext(EnvironmentVarGuard())
1515
if "POSIXLY_CORRECT" in self.env:
1616
del self.env["POSIXLY_CORRECT"]
1717

18-
def tearDown(self):
19-
self.env.__exit__()
20-
del self.env
21-
2218
def assertError(self, *args, **kwargs):
2319
self.assertRaises(getopt.GetoptError, *args, **kwargs)
2420

Lib/test/test_gettext.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@
117117

118118
class GettextBaseTest(unittest.TestCase):
119119
def setUp(self):
120+
self.addCleanup(os_helper.rmtree, os.path.split(LOCALEDIR)[0])
120121
if not os.path.isdir(LOCALEDIR):
121122
os.makedirs(LOCALEDIR)
122123
with open(MOFILE, 'wb') as fp:
@@ -129,14 +130,10 @@ def setUp(self):
129130
fp.write(base64.decodebytes(UMO_DATA))
130131
with open(MMOFILE, 'wb') as fp:
131132
fp.write(base64.decodebytes(MMO_DATA))
132-
self.env = os_helper.EnvironmentVarGuard()
133+
self.env = self.enterContext(os_helper.EnvironmentVarGuard())
133134
self.env['LANGUAGE'] = 'xx'
134135
gettext._translations.clear()
135136

136-
def tearDown(self):
137-
self.env.__exit__()
138-
del self.env
139-
os_helper.rmtree(os.path.split(LOCALEDIR)[0])
140137

141138
GNU_MO_DATA_ISSUE_17898 = b'''\
142139
3hIElQAAAAABAAAAHAAAACQAAAAAAAAAAAAAAAAAAAAsAAAAggAAAC0AAAAAUGx1cmFsLUZvcm1z

Lib/test/test_global.py

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,9 @@
99
class GlobalTests(unittest.TestCase):
1010

1111
def setUp(self):
12-
self._warnings_manager = check_warnings()
13-
self._warnings_manager.__enter__()
12+
self.enterContext(check_warnings())
1413
warnings.filterwarnings("error", module="<test string>")
1514

16-
def tearDown(self):
17-
self._warnings_manager.__exit__(None, None, None)
18-
19-
2015
def test1(self):
2116
prog_text_1 = """\
2217
def wrong1():
@@ -54,9 +49,7 @@ def test4(self):
5449

5550

5651
def setUpModule():
57-
cm = warnings.catch_warnings()
58-
cm.__enter__()
59-
unittest.addModuleCleanup(cm.__exit__, None, None, None)
52+
unittest.enterModuleContext(warnings.catch_warnings())
6053
warnings.filterwarnings("error", module="<test string>")
6154

6255

Lib/test/test_importlib/source/test_finder.py

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -157,21 +157,12 @@ def test_dir_removal_handling(self):
157157
def test_no_read_directory(self):
158158
# Issue #16730
159159
tempdir = tempfile.TemporaryDirectory()
160+
self.enterContext(tempdir)
161+
# Since we muck with the permissions, we want to set them back to
162+
# their original values to make sure the directory can be properly
163+
# cleaned up.
160164
original_mode = os.stat(tempdir.name).st_mode
161-
def cleanup(tempdir):
162-
"""Cleanup function for the temporary directory.
163-
164-
Since we muck with the permissions, we want to set them back to
165-
their original values to make sure the directory can be properly
166-
cleaned up.
167-
168-
"""
169-
os.chmod(tempdir.name, original_mode)
170-
# If this is not explicitly called then the __del__ method is used,
171-
# but since already mucking around might as well explicitly clean
172-
# up.
173-
tempdir.__exit__(None, None, None)
174-
self.addCleanup(cleanup, tempdir)
165+
self.addCleanup(os.chmod, tempdir.name, original_mode)
175166
os.chmod(tempdir.name, stat.S_IWUSR | stat.S_IXUSR)
176167
finder = self.get_finder(tempdir.name)
177168
found = self._find(finder, 'doesnotexist')

Lib/test/test_importlib/test_namespace_pkgs.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,7 @@ def setUp(self):
6565
self.resolved_paths = [
6666
os.path.join(self.root, path) for path in self.paths
6767
]
68-
self.ctx = namespace_tree_context(path=self.resolved_paths)
69-
self.ctx.__enter__()
70-
71-
def tearDown(self):
72-
# TODO: will we ever want to pass exc_info to __exit__?
73-
self.ctx.__exit__(None, None, None)
68+
self.enterContext(namespace_tree_context(path=self.resolved_paths))
7469

7570

7671
class SingleNamespacePackage(NamespacePackageTest):

Lib/test/test_logging.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5650,9 +5650,7 @@ def test__all__(self):
56505650
# why the test does this, but in any case we save the current locale
56515651
# first and restore it at the end.
56525652
def setUpModule():
5653-
cm = support.run_with_locale('LC_ALL', '')
5654-
cm.__enter__()
5655-
unittest.addModuleCleanup(cm.__exit__, None, None, None)
5653+
unittest.enterModuleContext(support.run_with_locale('LC_ALL', ''))
56565654

56575655

56585656
if __name__ == "__main__":

Lib/test/test_nntplib.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1593,8 +1593,7 @@ def setUp(self):
15931593
self.background.start()
15941594
self.addCleanup(self.background.join)
15951595

1596-
self.nntp = NNTP(socket_helper.HOST, port, usenetrc=False).__enter__()
1597-
self.addCleanup(self.nntp.__exit__, None, None, None)
1596+
self.nntp = self.enterContext(NNTP(socket_helper.HOST, port, usenetrc=False))
15981597

15991598
def run_server(self, sock):
16001599
# Could be generalized to handle more commands in separate methods

Lib/test/test_peg_generator/test_c_parser.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,7 @@ def setUp(self):
9696
self.skipTest("The %r command is not found" % cmd)
9797
self.old_cwd = os.getcwd()
9898
self.tmp_path = tempfile.mkdtemp(dir=self.tmp_base)
99-
change_cwd = os_helper.change_cwd(self.tmp_path)
100-
change_cwd.__enter__()
101-
self.addCleanup(change_cwd.__exit__, None, None, None)
99+
self.enterContext(os_helper.change_cwd(self.tmp_path))
102100

103101
def tearDown(self):
104102
os.chdir(self.old_cwd)

Lib/test/test_poll.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,7 @@ def test_poll2(self):
128128
cmd = 'for i in 0 1 2 3 4 5 6 7 8 9; do echo testing...; sleep 1; done'
129129
proc = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE,
130130
bufsize=0)
131-
proc.__enter__()
132-
self.addCleanup(proc.__exit__, None, None, None)
131+
self.enterContext(proc)
133132
p = proc.stdout
134133
pollster = select.poll()
135134
pollster.register( p, select.POLLIN )

Lib/test/test_posix.py

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -53,19 +53,13 @@ class PosixTester(unittest.TestCase):
5353

5454
def setUp(self):
5555
# create empty file
56+
self.addCleanup(os_helper.unlink, os_helper.TESTFN)
5657
with open(os_helper.TESTFN, "wb"):
5758
pass
58-
self.teardown_files = [ os_helper.TESTFN ]
59-
self._warnings_manager = warnings_helper.check_warnings()
60-
self._warnings_manager.__enter__()
59+
self.enterContext(warnings_helper.check_warnings())
6160
warnings.filterwarnings('ignore', '.* potential security risk .*',
6261
RuntimeWarning)
6362

64-
def tearDown(self):
65-
for teardown_file in self.teardown_files:
66-
os_helper.unlink(teardown_file)
67-
self._warnings_manager.__exit__(None, None, None)
68-
6963
def testNoArgFunctions(self):
7064
# test posix functions which take no arguments and have
7165
# no side-effects which we need to cleanup (e.g., fork, wait, abort)
@@ -973,8 +967,8 @@ def test_lchflags_symlink(self):
973967

974968
self.assertTrue(hasattr(testfn_st, 'st_flags'))
975969

970+
self.addCleanup(os_helper.unlink, _DUMMY_SYMLINK)
976971
os.symlink(os_helper.TESTFN, _DUMMY_SYMLINK)
977-
self.teardown_files.append(_DUMMY_SYMLINK)
978972
dummy_symlink_st = os.lstat(_DUMMY_SYMLINK)
979973

980974
def chflags_nofollow(path, flags):

Lib/test/test_set.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1022,18 +1022,14 @@ def test_repr(self):
10221022

10231023
class TestBasicOpsMixedStringBytes(TestBasicOps, unittest.TestCase):
10241024
def setUp(self):
1025-
self._warning_filters = warnings_helper.check_warnings()
1026-
self._warning_filters.__enter__()
1025+
self.enterContext(warnings_helper.check_warnings())
10271026
warnings.simplefilter('ignore', BytesWarning)
10281027
self.case = "string and bytes set"
10291028
self.values = ["a", "b", b"a", b"b"]
10301029
self.set = set(self.values)
10311030
self.dup = set(self.values)
10321031
self.length = 4
10331032

1034-
def tearDown(self):
1035-
self._warning_filters.__exit__(None, None, None)
1036-
10371033
def test_repr(self):
10381034
self.check_repr_against_values()
10391035

Lib/test/test_socket.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -338,9 +338,7 @@ def serverExplicitReady(self):
338338
self.server_ready.set()
339339

340340
def _setUp(self):
341-
self.wait_threads = threading_helper.wait_threads_exit()
342-
self.wait_threads.__enter__()
343-
self.addCleanup(self.wait_threads.__exit__, None, None, None)
341+
self.enterContext(threading_helper.wait_threads_exit())
344342

345343
self.server_ready = threading.Event()
346344
self.client_ready = threading.Event()

Lib/test/test_ssl.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1999,9 +1999,8 @@ def setUp(self):
19991999
self.server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
20002000
self.server_context.load_cert_chain(SIGNED_CERTFILE)
20012001
server = ThreadedEchoServer(context=self.server_context)
2002+
self.enterContext(server)
20022003
self.server_addr = (HOST, server.port)
2003-
server.__enter__()
2004-
self.addCleanup(server.__exit__, None, None, None)
20052004

20062005
def test_connect(self):
20072006
with test_wrap_socket(socket.socket(socket.AF_INET),
@@ -3713,8 +3712,7 @@ def _recvfrom_into():
37133712

37143713
def test_recv_zero(self):
37153714
server = ThreadedEchoServer(CERTFILE)
3716-
server.__enter__()
3717-
self.addCleanup(server.__exit__, None, None)
3715+
self.enterContext(server)
37183716
s = socket.create_connection((HOST, server.port))
37193717
self.addCleanup(s.close)
37203718
s = test_wrap_socket(s, suppress_ragged_eofs=False)

Lib/test/test_tempfile.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,14 +90,10 @@ class BaseTestCase(unittest.TestCase):
9090
b_check = re.compile(br"^[a-z0-9_-]{8}$")
9191

9292
def setUp(self):
93-
self._warnings_manager = warnings_helper.check_warnings()
94-
self._warnings_manager.__enter__()
93+
self.enterContext(warnings_helper.check_warnings())
9594
warnings.filterwarnings("ignore", category=RuntimeWarning,
9695
message="mktemp", module=__name__)
9796

98-
def tearDown(self):
99-
self._warnings_manager.__exit__(None, None, None)
100-
10197
def nameCheck(self, name, dir, pre, suf):
10298
(ndir, nbase) = os.path.split(name)
10399
npre = nbase[:len(pre)]

0 commit comments

Comments
 (0)