Skip to content

Commit c65814f

Browse files
gh-152548: Address review of test.support.isolated()
Move the helper into a public test.support.isolation submodule (used as "from test.support import isolation"), drop the test.support re-export, and document running_isolated and isolated() under that module. Replay expected failures and forward subprocess durations to the parent, so an @expectedfailure isolated test is no longer misreported as an unexpected success and reported timings reflect the subprocess run. Add test.test_support.TestIsolated covering the outcomes, subtest reporting, traceback-as-cause, duration forwarding and the no-subprocess skip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4aa316a commit c65814f

6 files changed

Lines changed: 263 additions & 52 deletions

File tree

Doc/library/test.rst

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -961,6 +961,8 @@ The :mod:`!test.support` module defines the following functions:
961961
:mod:`tracemalloc` is enabled.
962962

963963

964+
.. currentmodule:: test.support.isolation
965+
964966
.. decorator:: isolated()
965967

966968
Decorator that runs the decorated test in isolation, in a fresh interpreter
@@ -973,10 +975,11 @@ The :mod:`!test.support` module defines the following functions:
973975
individually. A reported failure or error shows the original subprocess
974976
traceback as the cause of the exception.
975977

976-
When a **method** is decorated, only that method runs in a subprocess;
977-
:meth:`~unittest.TestCase.setUp` and :meth:`~unittest.TestCase.tearDown`
978-
run both in the parent process (as usual) and in the subprocess around the
979-
method.
978+
When a **method** is decorated, only that method runs in a subprocess; all
979+
fixtures (:meth:`~unittest.TestCase.setUp` / :meth:`~unittest.TestCase.tearDown`,
980+
:meth:`~unittest.TestCase.setUpClass` / :meth:`~unittest.TestCase.tearDownClass`
981+
and ``setUpModule()`` / ``tearDownModule()``) run both in the parent process
982+
(as usual) and in the subprocess around the method.
980983

981984
When a **class** is decorated, the whole class runs in a single subprocess,
982985
and :meth:`~unittest.TestCase.setUpClass`,
@@ -988,13 +991,11 @@ The :mod:`!test.support` module defines the following functions:
988991
so it still runs in the parent process too; test it with
989992
:data:`running_isolated` if needed.
990993

991-
Fixtures can test :data:`running_isolated` to decide what to run in each
992-
process.
993-
994994
The subprocess inherits the enabled resources (``-u``), memory limit
995995
(``-M``) and verbosity (``-v``) of the parent test run, so that
996-
:func:`requires_resource`, :func:`requires`, :func:`bigmemtest` and the like
997-
behave consistently in both processes.
996+
:func:`~test.support.requires_resource`, :func:`~test.support.requires`,
997+
:func:`~test.support.bigmemtest` and the like behave consistently in both
998+
processes.
998999

9991000
The test is skipped on platforms without subprocess support.
10001001

@@ -1010,6 +1011,9 @@ The :mod:`!test.support` module defines the following functions:
10101011
to run in the subprocess.
10111012

10121013

1014+
.. currentmodule:: test.support
1015+
1016+
10131017
.. function:: check_free_after_iterating(test, iter, cls, args=())
10141018

10151019
Assert instances of *cls* are deallocated after iterating.

Lib/test/_isolated_sample.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""Sample tests driven by test.test_support.TestIsolated.
2+
3+
This module is imported, never run as a test file, so that
4+
:func:`test.support.isolation.isolated` has a real, importable target to run in
5+
a subprocess. Several of these tests fail, error or are skipped on purpose.
6+
"""
7+
8+
import time
9+
import unittest
10+
from test.support import isolation
11+
12+
# A test in DurationSample sleeps this long in the subprocess; the parent
13+
# replays it instantly, so a parent-reported duration close to this proves the
14+
# subprocess timing was forwarded rather than the replay time measured.
15+
DURATION_SLEEP = 0.2
16+
17+
18+
class MethodSample(unittest.TestCase):
19+
20+
@isolation.isolated()
21+
def test_pass(self):
22+
self.assertTrue(isolation.running_isolated)
23+
24+
@isolation.isolated()
25+
def test_fail(self):
26+
self.assertEqual(1, 2)
27+
28+
@isolation.isolated()
29+
def test_error(self):
30+
raise RuntimeError('boom')
31+
32+
@isolation.isolated()
33+
def test_skip(self):
34+
self.skipTest('nope')
35+
36+
@isolation.isolated()
37+
@unittest.expectedFailure
38+
def test_expected_failure(self):
39+
self.assertEqual(1, 2)
40+
41+
@isolation.isolated()
42+
@unittest.expectedFailure
43+
def test_unexpected_success(self):
44+
pass
45+
46+
47+
@isolation.isolated()
48+
class ClassSample(unittest.TestCase):
49+
50+
def test_pass(self):
51+
self.assertTrue(isolation.running_isolated)
52+
53+
def test_fail(self):
54+
self.assertEqual(1, 2)
55+
56+
@unittest.expectedFailure
57+
def test_expected_failure(self):
58+
self.assertEqual(1, 2)
59+
60+
61+
class SubtestSample(unittest.TestCase):
62+
63+
@isolation.isolated()
64+
def test_subtests(self):
65+
for i in range(3):
66+
with self.subTest(i=i):
67+
self.assertNotEqual(i, 1)
68+
69+
70+
@isolation.isolated()
71+
class DurationSample(unittest.TestCase):
72+
73+
def test_slow(self):
74+
time.sleep(DURATION_SLEEP)

Lib/test/support/__init__.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
__all__ = [
2525
# globals
2626
"PIPE_MAX_SIZE", "verbose", "max_memuse", "use_resources", "failfast",
27-
"running_isolated",
2827
# exceptions
2928
"Error", "TestFailed", "TestDidNotRun", "ResourceDenied",
3029
# io
@@ -37,7 +36,6 @@
3736
"check_syntax_error",
3837
"requires_gzip", "requires_bz2", "requires_lzma", "requires_zstd",
3938
"bigmemtest", "nomemtest", "bigaddrspacetest", "cpython_only", "get_attribute",
40-
"isolated",
4139
"requires_IEEE_754", "requires_zlib",
4240
"has_fork_support", "requires_fork",
4341
"has_subprocess_support", "requires_subprocess",
@@ -1091,12 +1089,6 @@ def wrapper(self, /, *args, **kwargs):
10911089
return wrapper
10921090
return decorator
10931091

1094-
# Run a test method or class in an isolated subprocess. Implemented in a
1095-
# dedicated module so that its frames carry the __unittest marker and are
1096-
# stripped from reported tracebacks.
1097-
from test.support._isolation import isolated, running_isolated
1098-
1099-
11001092
#=======================================================================
11011093
# Decorator/context manager for running a code in a different locale,
11021094
# correctly resetting it afterwards.
Lines changed: 54 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Run tests in isolated subprocesses (the test.support.isolated decorator).
1+
"""Run tests in isolated subprocesses (the test.support.isolation.isolated decorator).
22
33
A failure, error or skip that happens in the subprocess is replayed in the
44
parent process so that the test runner records it. The original (subprocess)
@@ -78,7 +78,7 @@ class _SubprocessTestError(Exception):
7878
def _remote(detail):
7979
# Wrap the subprocess traceback the way concurrent.futures does, so it is
8080
# clearly delimited when shown as the cause.
81-
return _RemoteTraceback('\n"""\n%s"""' % detail)
81+
return _RemoteTraceback(f'\n"""\n{detail}"""')
8282

8383

8484
def _check_subprocess_support():
@@ -92,9 +92,9 @@ def _check_subprocess_support():
9292
def _run_in_subprocess(module, qualname):
9393
"""Run module.qualname (a test method or class) in a fresh subprocess.
9494
95-
Return ``(outcomes, output, returncode)``, where *outcomes* is the list of
96-
test outcomes decoded from the subprocess, or ``None`` if it did not run to
97-
completion (crash, import error, ...).
95+
Return ``(payload, output, returncode)``, where *payload* is the decoded
96+
``{'outcomes': ..., 'durations': ...}`` mapping from the subprocess, or
97+
``None`` if it did not run to completion (crash, import error, ...).
9898
"""
9999
import json
100100
import subprocess
@@ -110,28 +110,31 @@ def _run_in_subprocess(module, qualname):
110110
proc = subprocess.run(cmd, capture_output=True, text=True, env=env)
111111
try:
112112
with open(result_path, encoding='utf-8') as f:
113-
outcomes = json.load(f)
113+
payload = json.load(f)
114114
except (OSError, ValueError):
115-
outcomes = None
115+
payload = None
116116
finally:
117117
try:
118118
os.unlink(result_path)
119119
except OSError:
120120
pass
121-
return outcomes, (proc.stdout or '') + (proc.stderr or ''), proc.returncode
121+
return payload, (proc.stdout or '') + (proc.stderr or ''), proc.returncode
122122

123123

124124
def _replay_outcome(test, outcome):
125125
kind = outcome['kind']
126126
detail = outcome['detail']
127127
if kind == 'skipped':
128128
test.skipTest(detail) # the detail is the skip reason, not a traceback
129-
elif kind == 'failure':
130-
raise test.failureException('test failed in the subprocess') \
131-
from _remote(detail)
129+
elif kind in ('failure', 'expected_failure'):
130+
# An expected failure is replayed like a failure: the wrapper carries
131+
# the @expectedFailure marker (copied by functools.wraps), so the parent
132+
# records the raised exception as an expectedFailure, not a failure.
133+
exc = test.failureException('test failed in the subprocess')
134+
raise exc from _remote(detail)
132135
else: # 'error'
133-
raise _SubprocessTestError('test failed in the subprocess') \
134-
from _remote(detail)
136+
exc = _SubprocessTestError('test failed in the subprocess')
137+
raise exc from _remote(detail)
135138

136139

137140
def _replay_outcomes(test, outcomes):
@@ -153,8 +156,8 @@ def _raise_fixture_outcome(outcome):
153156
# subprocess in a parent-process fixture, so it applies to every test.
154157
if outcome['kind'] == 'skipped':
155158
raise unittest.SkipTest(outcome['detail'])
156-
raise _SubprocessTestError('class failed in the subprocess') \
157-
from _remote(outcome['detail'])
159+
exc = _SubprocessTestError('class failed in the subprocess')
160+
raise exc from _remote(outcome['detail'])
158161

159162

160163
def _isolate_method(func):
@@ -166,21 +169,27 @@ def wrapper(self, /, *args, **kwargs):
166169
_check_subprocess_support()
167170
cls = type(self)
168171
qualname = f'{cls.__qualname__}.{func.__name__}'
169-
outcomes, output, returncode = _run_in_subprocess(cls.__module__,
170-
qualname)
171-
if outcomes is None:
172-
raise _SubprocessTestError(
173-
f'test did not complete in a subprocess (exit code '
174-
f'{returncode})') from _remote(output)
175-
_replay_outcomes(self, outcomes)
172+
payload, output, returncode = _run_in_subprocess(cls.__module__,
173+
qualname)
174+
if payload is None:
175+
exc = _SubprocessTestError(
176+
f'test did not complete in a subprocess (exit code {returncode})')
177+
raise exc from _remote(output)
178+
# The parent measures this method's own duration (the real cost of the
179+
# isolated run, subprocess startup included), so nothing to forward here.
180+
_replay_outcomes(self, payload['outcomes'])
176181
return wrapper
177182

178183

179184
def _isolate_class(cls):
185+
# Unwrap to the plain functions: the replacements below call them with the
186+
# runtime cls, so a subclass of an isolated class runs the fixtures bound to
187+
# itself (a bound classmethod would freeze the decoration-time class).
180188
orig_setUpClass = cls.setUpClass.__func__
181189
orig_tearDownClass = cls.tearDownClass.__func__
182190
orig_setUp = cls.setUp
183191
orig_tearDown = cls.tearDown
192+
orig_addDuration = getattr(cls, '_addDuration', None)
184193

185194
def setUpClass(cls):
186195
if running_isolated:
@@ -189,26 +198,28 @@ def setUpClass(cls):
189198
_check_subprocess_support()
190199
# Run the whole class in a single subprocess and stash the outcomes
191200
# for the wrapped test methods to replay.
192-
outcomes, output, returncode = _run_in_subprocess(cls.__module__,
193-
cls.__qualname__)
194-
if outcomes is None:
195-
raise _SubprocessTestError(
196-
f'class did not complete in a subprocess (exit code '
197-
f'{returncode})') from _remote(output)
201+
payload, output, returncode = _run_in_subprocess(cls.__module__,
202+
cls.__qualname__)
203+
if payload is None:
204+
exc = _SubprocessTestError(
205+
f'class did not complete in a subprocess (exit code {returncode})')
206+
raise exc from _remote(output)
198207
by_id = {}
199-
for outcome in outcomes:
208+
for outcome in payload['outcomes']:
200209
if outcome['fixture']:
201210
# A setUpClass()/setUpModule() failure or skip: apply it to the
202211
# whole class by raising it here, in the parent's setUpClass().
203212
_raise_fixture_outcome(outcome)
204213
by_id.setdefault(outcome['id'], []).append(outcome)
205214
cls._isolated_outcomes = by_id
215+
cls._isolated_durations = dict(payload.get('durations', ()))
206216

207217
def tearDownClass(cls):
208218
if running_isolated:
209219
orig_tearDownClass(cls)
210220
else:
211221
cls._isolated_outcomes = None
222+
cls._isolated_durations = None
212223

213224
def setUp(self):
214225
# In the parent the real test does not run, so neither should setUp().
@@ -219,6 +230,15 @@ def tearDown(self):
219230
if running_isolated:
220231
orig_tearDown(self)
221232

233+
def _addDuration(self, result, elapsed):
234+
# In the parent, report the per-test duration measured in the subprocess
235+
# rather than the replay time (subprocess startup is paid once, in
236+
# setUpClass).
237+
if not running_isolated:
238+
durations = getattr(type(self), '_isolated_durations', None) or {}
239+
elapsed = durations.get(self.id(), elapsed)
240+
orig_addDuration(self, result, elapsed)
241+
222242
def replay(self):
223243
by_id = getattr(type(self), '_isolated_outcomes', None) or {}
224244
_replay_outcomes(self, by_id.get(self.id(), []))
@@ -227,6 +247,8 @@ def replay(self):
227247
cls.tearDownClass = classmethod(tearDownClass)
228248
cls.setUp = setUp
229249
cls.tearDown = tearDown
250+
if orig_addDuration is not None:
251+
cls._addDuration = _addDuration
230252
for name in unittest.TestLoader().getTestCaseNames(cls):
231253
method = getattr(cls, name)
232254
@functools.wraps(method)
@@ -254,10 +276,11 @@ def isolated():
254276
shown as the cause of a reported failure or error. Use
255277
:data:`running_isolated` in fixtures to choose what to run in the subprocess.
256278
257-
The test is skipped on platforms without subprocess support.
279+
The test is skipped on platforms without subprocess support, since it must
280+
spawn one.
258281
"""
259282
def decorator(obj):
260-
if isinstance(obj, type):
283+
if isinstance(obj, type) and issubclass(obj, unittest.TestCase):
261284
return _isolate_class(obj)
262285
return _isolate_method(obj)
263286
return decorator

Lib/test/support/subprocess_runner.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Run a single test method in this (sub)process and report the result.
22
33
Invoked as ``python -m test.support.subprocess_runner MODULE QUALNAME OUTFILE``
4-
by :func:`test.support.isolated`. The outcome of the test (including
4+
by :func:`test.support.isolation.isolated`. The outcome of the test (including
55
that of each individual subtest) is written as JSON to OUTFILE. This module is
66
not meant to be imported.
77
"""
@@ -25,11 +25,24 @@
2525

2626
# Mirror the parent's regrtest configuration (-u, -M, -v, ...) before importing
2727
# the test, so resource gating and bigmem sizing match the parent process.
28-
from test.support._isolation import _apply_child_config
28+
from test.support.isolation import _apply_child_config
2929
_apply_child_config()
3030

31+
32+
class _Result(unittest.TestResult):
33+
# Capture per-test durations keyed by test id, so the parent can report the
34+
# subprocess timings instead of its own replay time.
35+
def __init__(self):
36+
super().__init__()
37+
self.id_durations = []
38+
39+
def addDuration(self, test, elapsed):
40+
super().addDuration(test, elapsed)
41+
self.id_durations.append((test.id(), elapsed))
42+
43+
3144
suite = unittest.TestLoader().loadTestsFromName(f'{module}.{qualname}')
32-
result = unittest.TestResult()
45+
result = _Result()
3346
suite.run(result)
3447

3548

@@ -50,9 +63,12 @@ def _outcome(kind, test, detail):
5063

5164
outcomes = [_outcome('failure', t, tb) for t, tb in result.failures]
5265
outcomes += [_outcome('error', t, tb) for t, tb in result.errors]
66+
outcomes += [_outcome('expected_failure', t, tb)
67+
for t, tb in result.expectedFailures]
5368
outcomes += [_outcome('skipped', t, reason) for t, reason in result.skipped]
5469

70+
payload = {'outcomes': outcomes, 'durations': result.id_durations}
5571
with open(outfile, 'w', encoding='utf-8') as f:
56-
json.dump(outcomes, f)
72+
json.dump(payload, f)
5773

5874
sys.exit(0)

0 commit comments

Comments
 (0)