Skip to content

Commit a4bf262

Browse files
gh-152548: Add options, env and timeout parameters to runInSubprocess()
They run the test subprocess with specific interpreter command line options and environment variables, and limit how long it may take. All are keyword-only. env is layered over the inherited environment; a None value unsets a variable. There is no timeout by default. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent ef0affb commit a4bf262

4 files changed

Lines changed: 130 additions & 15 deletions

File tree

Doc/library/test.rst

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -963,7 +963,7 @@ The :mod:`!test.support` module defines the following functions:
963963

964964
.. currentmodule:: test.support.isolation
965965

966-
.. decorator:: runInSubprocess()
966+
.. decorator:: runInSubprocess(*, options=(), env=None, timeout=None)
967967

968968
Decorator that runs the decorated test in a fresh interpreter subprocess, in
969969
isolation, so that it does not share global or interpreter state with the
@@ -997,6 +997,19 @@ The :mod:`!test.support` module defines the following functions:
997997
:func:`~test.support.bigmemtest` and the like behave consistently in both
998998
processes.
999999

1000+
*options* is a sequence of interpreter command line options
1001+
to run the subprocess with,
1002+
and *env* is a mapping of environment variables to set in it,
1003+
on top of the inherited environment.
1004+
A value of ``None`` in *env* unsets the variable.
1005+
Note that :option:`-E` and :option:`-I` make the subprocess ignore
1006+
the ``PYTHON*`` environment variables, including :envvar:`PYTHONPATH`.
1007+
1008+
*timeout* is the number of seconds to wait for the subprocess;
1009+
the test is reported as an error if it does not complete in time.
1010+
By default there is no timeout,
1011+
and a hung test is left to the timeout of the test runner.
1012+
10001013
The test is skipped on platforms without subprocess support.
10011014

10021015

Lib/test/_isolated_sample.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import atexit
99
import os
10+
import sys
1011
import time
1112
import unittest
1213
from test.support import isolation
@@ -141,3 +142,39 @@ def test_pass(self):
141142

142143
def test_dies(self):
143144
_die_at_exit()
145+
146+
147+
@isolation.runInSubprocess(options=['-X', 'dev', '-W', 'error::BytesWarning'])
148+
class OptionsSample(unittest.TestCase):
149+
150+
def test_options_applied(self):
151+
self.assertTrue(sys.flags.dev_mode)
152+
self.assertIn('error::BytesWarning', sys.warnoptions)
153+
154+
155+
class EnvSample(unittest.TestCase):
156+
157+
@isolation.runInSubprocess(env={'_PYTHON_ISOLATION_PROBE': 'set-by-test'})
158+
def test_env_set(self):
159+
self.assertEqual(os.environ.get('_PYTHON_ISOLATION_PROBE'), 'set-by-test')
160+
161+
@isolation.runInSubprocess(env={'_PYTHON_ISOLATION_PROBE': None})
162+
def test_env_unset(self):
163+
self.assertNotIn('_PYTHON_ISOLATION_PROBE', os.environ)
164+
165+
@isolation.runInSubprocess()
166+
def test_env_inherited(self):
167+
# Without env= the subprocess inherits the parent environment as it is.
168+
self.assertEqual(os.environ.get('_PYTHON_ISOLATION_PROBE'), 'set-by-parent')
169+
170+
171+
# TimeoutSample hangs this long, so that the timeout always fires first.
172+
TIMEOUT_HANG = 60.0
173+
TIMEOUT = 0.5
174+
175+
176+
class TimeoutSample(unittest.TestCase):
177+
178+
@isolation.runInSubprocess(timeout=TIMEOUT)
179+
def test_hang(self):
180+
time.sleep(TIMEOUT_HANG)

Lib/test/support/isolation.py

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,11 @@ def _decode(data):
7878

7979
def _remote(detail):
8080
# Wrap the subprocess traceback the way concurrent.futures does, so it is
81-
# clearly delimited when shown as the cause.
81+
# clearly delimited when shown as the cause. Return None if the subprocess
82+
# said nothing (a hung one usually does not), so that "raise ... from None"
83+
# suppresses an empty cause.
84+
if not detail:
85+
return None
8286
return _RemoteTraceback(f'\n"""\n{detail}"""')
8387

8488

@@ -90,7 +94,21 @@ def _check_subprocess_support():
9094
raise unittest.SkipTest('requires subprocess support')
9195

9296

93-
def _run_in_subprocess(module, qualname):
97+
def _child_environ(env):
98+
# Start from the inherited environment, so that *env* only has to name what
99+
# the test changes.
100+
if not env:
101+
return None
102+
environ = dict(os.environ)
103+
for name, value in env.items():
104+
if value is None:
105+
environ.pop(name, None)
106+
else:
107+
environ[name] = value
108+
return environ
109+
110+
111+
def _run_in_subprocess(module, qualname, options, env, timeout):
94112
"""Run module.qualname (a test method or class) in a fresh subprocess.
95113
96114
Return ``(payload, output, returncode)``, where *payload* is the decoded
@@ -104,13 +122,22 @@ def _run_in_subprocess(module, qualname):
104122
os.close(fd)
105123
try:
106124
# Pass the config on the command line, not in the environment, so that
107-
# the test cannot pass it on to the processes it spawns itself. Use
108-
# marshal, not json: it is built in, so the child imports nothing that
109-
# the test would not see in a normal test run.
110-
cmd = [sys.executable, '-m', 'test.support.subprocess_runner',
125+
# the test cannot pass it on to the processes it spawns itself, and so
126+
# that it survives the -E and -I options. Use marshal, not json: it is
127+
# built in, so the child imports nothing that the test would not see in
128+
# a normal test run.
129+
cmd = [sys.executable, *options, '-m', 'test.support.subprocess_runner',
111130
module, qualname, result_path,
112131
marshal.dumps(_child_config()).hex()]
113-
proc = subprocess.run(cmd, capture_output=True)
132+
try:
133+
proc = subprocess.run(cmd, capture_output=True,
134+
env=_child_environ(env), timeout=timeout)
135+
except subprocess.TimeoutExpired as exc:
136+
# Report the hang rather than leaving the test runner stuck.
137+
output = _decode(exc.stdout) + _decode(exc.stderr)
138+
raise _SubprocessTestError(
139+
f'test did not complete in a subprocess '
140+
f'within {timeout} seconds') from _remote(output)
114141
try:
115142
with open(result_path, 'rb') as f:
116143
payload = marshal.load(f)
@@ -173,7 +200,7 @@ def _check_returncode(returncode, output, what):
173200
raise exc from _remote(output)
174201

175202

176-
def _isolate_method(func):
203+
def _isolate_method(func, options, env, timeout):
177204
@functools.wraps(func)
178205
def wrapper(self, /, *args, **kwargs):
179206
if runningInSubprocess:
@@ -183,7 +210,8 @@ def wrapper(self, /, *args, **kwargs):
183210
cls = type(self)
184211
qualname = f'{cls.__qualname__}.{func.__name__}'
185212
payload, output, returncode = _run_in_subprocess(cls.__module__,
186-
qualname)
213+
qualname, options,
214+
env, timeout)
187215
if payload is None:
188216
exc = _SubprocessTestError(
189217
f'test did not complete in a subprocess (exit code {returncode})')
@@ -196,7 +224,7 @@ def wrapper(self, /, *args, **kwargs):
196224
return wrapper
197225

198226

199-
def _isolate_class(cls):
227+
def _isolate_class(cls, options, env, timeout):
200228
# Unwrap to the plain functions so the replacements can call them with the
201229
# runtime cls; a bound classmethod would freeze the decoration-time class
202230
# and a subclass would run the fixtures bound to the base class.
@@ -217,7 +245,8 @@ def setUpClass(cls):
217245
# Run the whole class in a single subprocess and stash the outcomes
218246
# for the test methods to replay.
219247
payload, output, returncode = _run_in_subprocess(cls.__module__,
220-
cls.__qualname__)
248+
cls.__qualname__,
249+
options, env, timeout)
221250
if payload is None:
222251
exc = _SubprocessTestError(
223252
f'class did not complete in a subprocess (exit code {returncode})')
@@ -283,7 +312,7 @@ def _addDuration(self, result, elapsed):
283312
return cls
284313

285314

286-
def runInSubprocess():
315+
def runInSubprocess(*, options=(), env=None, timeout=None):
287316
"""Decorator to run a test method or class in a fresh subprocess.
288317
289318
The decorated test runs in a separate, fresh Python process, so it does not
@@ -293,6 +322,16 @@ def runInSubprocess():
293322
once there; when a method is decorated, only that method runs in a
294323
subprocess. Decorated methods must take no extra arguments.
295324
325+
*options* is a sequence of interpreter command line options for the
326+
subprocess, and *env* is a mapping of environment variables to set in it,
327+
on top of the inherited environment; a value of ``None`` unsets a variable.
328+
Note that ``-E`` and ``-I`` make the subprocess ignore the ``PYTHON*``
329+
variables, including ``PYTHONPATH``.
330+
331+
*timeout* is the number of seconds to wait for the subprocess; the test is
332+
reported as an error if it does not complete in time. By default there is
333+
no timeout, and a hung test is left to the timeout of the test runner.
334+
296335
A failure, error or skip of the whole test is reported for the test, and
297336
individual subtests (:meth:`~unittest.TestCase.subTest`) that fail or are
298337
skipped are reported individually. The original subprocess traceback is
@@ -304,6 +343,6 @@ def runInSubprocess():
304343
"""
305344
def decorator(obj):
306345
if isinstance(obj, type) and issubclass(obj, unittest.TestCase):
307-
return _isolate_class(obj)
308-
return _isolate_method(obj)
346+
return _isolate_class(obj, options, env, timeout)
347+
return _isolate_method(obj, options, env, timeout)
309348
return decorator

Lib/test/test_support.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1205,6 +1205,32 @@ def test_class_subprocess_dying_after_the_tests_is_reported(self):
12051205
self.assertIn('tearDownClass', str(result.errors[0][0]))
12061206
self.assertIn(f'exited with code {EXIT_CODE}', result.errors[0][1])
12071207

1208+
@support.requires_subprocess()
1209+
def test_options_passed_to_subprocess(self):
1210+
result = self._run('OptionsSample')
1211+
self.assertEqual(result.testsRun, 1)
1212+
self.assertEqual(result.failures, [])
1213+
self.assertEqual(result.errors, [])
1214+
1215+
@support.requires_subprocess()
1216+
def test_env_passed_to_subprocess(self):
1217+
# The samples check the variable, so set it here to let them tell
1218+
# env= from the inherited environment.
1219+
with os_helper.EnvironmentVarGuard() as env:
1220+
env['_PYTHON_ISOLATION_PROBE'] = 'set-by-parent'
1221+
result = self._run('EnvSample')
1222+
self.assertEqual(result.testsRun, 3)
1223+
self.assertEqual(result.failures, [])
1224+
self.assertEqual(result.errors, [])
1225+
1226+
@support.requires_subprocess()
1227+
def test_timeout_reported_as_error(self):
1228+
from test._isolated_sample import TIMEOUT
1229+
result = self._run('TimeoutSample')
1230+
self.assertEqual(result.testsRun, 1)
1231+
self.assertEqual(len(result.errors), 1)
1232+
self.assertIn(f'within {TIMEOUT} seconds', result.errors[0][1])
1233+
12081234
def test_skipped_without_subprocess_support(self):
12091235
# On a platform without subprocess support the test is skipped in the
12101236
# parent, before any subprocess is spawned.

0 commit comments

Comments
 (0)