|
| 1 | +"""Run tests in isolated subprocesses (the test.support.isolated decorator). |
| 2 | +
|
| 3 | +A failure, error or skip that happens in the subprocess is replayed in the |
| 4 | +parent process so that the test runner records it. The original (subprocess) |
| 5 | +traceback is attached as the cause of the replayed exception, the same way |
| 6 | +:mod:`concurrent.futures` surfaces tracebacks from worker processes. |
| 7 | +""" |
| 8 | + |
| 9 | +import functools |
| 10 | +import os |
| 11 | +import sys |
| 12 | +import unittest |
| 13 | + |
| 14 | +# Mark this module's frames as belonging to the test machinery, so that |
| 15 | +# unittest strips them from reported tracebacks (see TestResult._clean_tracebacks |
| 16 | +# in Lib/unittest/result.py). Only the original subprocess traceback, attached |
| 17 | +# as the cause, is then shown -- not the parent-side replay frames. |
| 18 | +__unittest = True |
| 19 | + |
| 20 | +# Environment variable set in the child process so that the decorated test |
| 21 | +# method runs its real body instead of spawning yet another subprocess. |
| 22 | +_RUN_IN_SUBPROCESS_ENV = '_PYTHON_RUN_IN_SUBPROCESS' |
| 23 | + |
| 24 | +# Environment variable carrying (as JSON) the regrtest-configured test.support |
| 25 | +# state, so that the bare subprocess honors -u, -M, -v, etc. like the parent. |
| 26 | +_CONFIG_ENV = '_PYTHON_ISOLATED_CONFIG' |
| 27 | + |
| 28 | +# test.support globals set by regrtest (libregrtest/setup.py) that affect how |
| 29 | +# tests run and which are skipped at runtime in the subprocess. |
| 30 | +_PROPAGATED_CONFIG = ( |
| 31 | + 'use_resources', # -u (is_resource_enabled/requires) |
| 32 | + 'max_memuse', 'real_max_memuse', # -M (bigmemtest) |
| 33 | + 'verbose', # -v |
| 34 | + 'failfast', # -f |
| 35 | +) |
| 36 | + |
| 37 | +def _child_config(): |
| 38 | + import test.support as support |
| 39 | + return {name: getattr(support, name) for name in _PROPAGATED_CONFIG} |
| 40 | + |
| 41 | +def _apply_child_config(): |
| 42 | + """Mirror the parent's test.support configuration in the subprocess. |
| 43 | +
|
| 44 | + Called by subprocess_runner before loading the test, so that import-time |
| 45 | + decorators (e.g. requires_resource) and runtime checks see the same -u/-M/-v |
| 46 | + configuration as the parent process. |
| 47 | + """ |
| 48 | + import json |
| 49 | + import test.support as support |
| 50 | + data = os.environ.get(_CONFIG_ENV) |
| 51 | + if data: |
| 52 | + for name, value in json.loads(data).items(): |
| 53 | + setattr(support, name, value) |
| 54 | + |
| 55 | +# True while running inside the isolated subprocess spawned by @isolated(). |
| 56 | +# setUp()/tearDown() and the class- and module-level fixtures can test it to |
| 57 | +# decide which code to run in the subprocess as opposed to the parent process. |
| 58 | +running_isolated = bool(os.environ.get(_RUN_IN_SUBPROCESS_ENV)) |
| 59 | + |
| 60 | + |
| 61 | +class _RemoteTraceback(Exception): |
| 62 | + """Carry a formatted traceback string from the subprocess for display. |
| 63 | +
|
| 64 | + Attached as the ``__cause__`` of the replayed failure/error, so that the |
| 65 | + original traceback is shown by the traceback machinery. |
| 66 | + """ |
| 67 | + def __init__(self, tb): |
| 68 | + self.tb = tb |
| 69 | + |
| 70 | + def __str__(self): |
| 71 | + return self.tb |
| 72 | + |
| 73 | + |
| 74 | +class _SubprocessTestError(Exception): |
| 75 | + """Replay a subprocess error (as opposed to a failure) in the parent.""" |
| 76 | + |
| 77 | + |
| 78 | +def _remote(detail): |
| 79 | + # Wrap the subprocess traceback the way concurrent.futures does, so it is |
| 80 | + # clearly delimited when shown as the cause. |
| 81 | + return _RemoteTraceback('\n"""\n%s"""' % detail) |
| 82 | + |
| 83 | + |
| 84 | +def _run_in_subprocess(module, qualname): |
| 85 | + """Run module.qualname (a test method or class) in a fresh subprocess. |
| 86 | +
|
| 87 | + Return ``(outcomes, output, returncode)``, where *outcomes* is the list of |
| 88 | + test outcomes decoded from the subprocess, or ``None`` if it did not run to |
| 89 | + completion (crash, import error, ...). |
| 90 | + """ |
| 91 | + import json |
| 92 | + import subprocess |
| 93 | + import tempfile |
| 94 | + env = dict(os.environ) |
| 95 | + env[_RUN_IN_SUBPROCESS_ENV] = '1' |
| 96 | + env[_CONFIG_ENV] = json.dumps(_child_config()) |
| 97 | + fd, result_path = tempfile.mkstemp(suffix='.json') |
| 98 | + os.close(fd) |
| 99 | + try: |
| 100 | + cmd = [sys.executable, '-m', 'test.support.subprocess_runner', |
| 101 | + module, qualname, result_path] |
| 102 | + proc = subprocess.run(cmd, capture_output=True, text=True, env=env) |
| 103 | + try: |
| 104 | + with open(result_path, encoding='utf-8') as f: |
| 105 | + outcomes = json.load(f) |
| 106 | + except (OSError, ValueError): |
| 107 | + outcomes = None |
| 108 | + finally: |
| 109 | + try: |
| 110 | + os.unlink(result_path) |
| 111 | + except OSError: |
| 112 | + pass |
| 113 | + return outcomes, (proc.stdout or '') + (proc.stderr or ''), proc.returncode |
| 114 | + |
| 115 | + |
| 116 | +def _replay_outcome(test, outcome): |
| 117 | + kind = outcome['kind'] |
| 118 | + detail = outcome['detail'] |
| 119 | + if kind == 'skipped': |
| 120 | + test.skipTest(detail) # the detail is the skip reason, not a traceback |
| 121 | + elif kind == 'failure': |
| 122 | + raise test.failureException('test failed in the subprocess') \ |
| 123 | + from _remote(detail) |
| 124 | + else: # 'error' |
| 125 | + raise _SubprocessTestError('test failed in the subprocess') \ |
| 126 | + from _remote(detail) |
| 127 | + |
| 128 | + |
| 129 | +def _replay_outcomes(test, outcomes): |
| 130 | + # Replay each subtest outcome in its own subTest() context so that they are |
| 131 | + # reported individually, then replay the whole-test outcome (if any). |
| 132 | + main = [] |
| 133 | + for outcome in outcomes: |
| 134 | + if outcome['subtest']: |
| 135 | + with test.subTest(outcome['desc']): |
| 136 | + _replay_outcome(test, outcome) |
| 137 | + else: |
| 138 | + main.append(outcome) |
| 139 | + for outcome in main: |
| 140 | + _replay_outcome(test, outcome) |
| 141 | + |
| 142 | + |
| 143 | +def _raise_fixture_outcome(outcome): |
| 144 | + # Reproduce a setUpClass()/setUpModule() failure or skip from the |
| 145 | + # subprocess in a parent-process fixture, so it applies to every test. |
| 146 | + if outcome['kind'] == 'skipped': |
| 147 | + raise unittest.SkipTest(outcome['detail']) |
| 148 | + raise _SubprocessTestError('class failed in the subprocess') \ |
| 149 | + from _remote(outcome['detail']) |
| 150 | + |
| 151 | + |
| 152 | +def _isolate_method(func): |
| 153 | + @functools.wraps(func) |
| 154 | + def wrapper(self, /, *args, **kwargs): |
| 155 | + if running_isolated: |
| 156 | + # Already running in the subprocess: run the real test. |
| 157 | + return func(self, *args, **kwargs) |
| 158 | + cls = type(self) |
| 159 | + qualname = f'{cls.__qualname__}.{func.__name__}' |
| 160 | + outcomes, output, returncode = _run_in_subprocess(cls.__module__, |
| 161 | + qualname) |
| 162 | + if outcomes is None: |
| 163 | + raise _SubprocessTestError( |
| 164 | + f'test did not complete in a subprocess (exit code ' |
| 165 | + f'{returncode})') from _remote(output) |
| 166 | + _replay_outcomes(self, outcomes) |
| 167 | + return wrapper |
| 168 | + |
| 169 | + |
| 170 | +def _isolate_class(cls): |
| 171 | + orig_setUpClass = cls.setUpClass.__func__ |
| 172 | + orig_tearDownClass = cls.tearDownClass.__func__ |
| 173 | + orig_setUp = cls.setUp |
| 174 | + orig_tearDown = cls.tearDown |
| 175 | + |
| 176 | + def setUpClass(cls): |
| 177 | + if running_isolated: |
| 178 | + orig_setUpClass(cls) |
| 179 | + return |
| 180 | + # Run the whole class in a single subprocess and stash the outcomes |
| 181 | + # for the wrapped test methods to replay. |
| 182 | + outcomes, output, returncode = _run_in_subprocess(cls.__module__, |
| 183 | + cls.__qualname__) |
| 184 | + if outcomes is None: |
| 185 | + raise _SubprocessTestError( |
| 186 | + f'class did not complete in a subprocess (exit code ' |
| 187 | + f'{returncode})') from _remote(output) |
| 188 | + by_id = {} |
| 189 | + for outcome in outcomes: |
| 190 | + if outcome['fixture']: |
| 191 | + # A setUpClass()/setUpModule() failure or skip: apply it to the |
| 192 | + # whole class by raising it here, in the parent's setUpClass(). |
| 193 | + _raise_fixture_outcome(outcome) |
| 194 | + by_id.setdefault(outcome['id'], []).append(outcome) |
| 195 | + cls._isolated_outcomes = by_id |
| 196 | + |
| 197 | + def tearDownClass(cls): |
| 198 | + if running_isolated: |
| 199 | + orig_tearDownClass(cls) |
| 200 | + else: |
| 201 | + cls._isolated_outcomes = None |
| 202 | + |
| 203 | + def setUp(self): |
| 204 | + # In the parent the real test does not run, so neither should setUp(). |
| 205 | + if running_isolated: |
| 206 | + orig_setUp(self) |
| 207 | + |
| 208 | + def tearDown(self): |
| 209 | + if running_isolated: |
| 210 | + orig_tearDown(self) |
| 211 | + |
| 212 | + def replay(self): |
| 213 | + by_id = getattr(type(self), '_isolated_outcomes', None) or {} |
| 214 | + _replay_outcomes(self, by_id.get(self.id(), [])) |
| 215 | + |
| 216 | + cls.setUpClass = classmethod(setUpClass) |
| 217 | + cls.tearDownClass = classmethod(tearDownClass) |
| 218 | + cls.setUp = setUp |
| 219 | + cls.tearDown = tearDown |
| 220 | + for name in unittest.TestLoader().getTestCaseNames(cls): |
| 221 | + method = getattr(cls, name) |
| 222 | + @functools.wraps(method) |
| 223 | + def wrapper(self, /, *args, __func=method, **kwargs): |
| 224 | + if running_isolated: |
| 225 | + return __func(self, *args, **kwargs) |
| 226 | + replay(self) |
| 227 | + setattr(cls, name, wrapper) |
| 228 | + return cls |
| 229 | + |
| 230 | + |
| 231 | +def isolated(): |
| 232 | + """Decorator to run a test method or class in isolation from the rest. |
| 233 | +
|
| 234 | + The decorated test runs in a separate, fresh Python process, so it does not |
| 235 | + share global or interpreter state with the rest of the test run. When a |
| 236 | + :class:`~unittest.TestCase` subclass is decorated, the whole class runs in a |
| 237 | + single subprocess and its ``setUpClass()``/``setUpModule()`` fixtures run |
| 238 | + once there; when a method is decorated, only that method runs in a |
| 239 | + subprocess. Decorated methods must take no extra arguments. |
| 240 | +
|
| 241 | + A failure, error or skip of the whole test is reported for the test, and |
| 242 | + individual subtests (:meth:`~unittest.TestCase.subTest`) that fail or are |
| 243 | + skipped are reported individually. The original subprocess traceback is |
| 244 | + shown as the cause of a reported failure or error. Use |
| 245 | + :data:`running_isolated` in fixtures to choose what to run in the subprocess. |
| 246 | + """ |
| 247 | + def decorator(obj): |
| 248 | + if isinstance(obj, type): |
| 249 | + return _isolate_class(obj) |
| 250 | + return _isolate_method(obj) |
| 251 | + return decorator |
0 commit comments