| """Run tests in isolated subprocesses (the test.support.isolation.runInSubprocess decorator). |
| |
| A failure, error or skip that happens in the subprocess is replayed in the |
| parent process so that the test runner records it. The original (subprocess) |
| traceback is attached as the cause of the replayed exception, the same way |
| :mod:`concurrent.futures` surfaces tracebacks from worker processes. |
| """ |
| |
| import functools |
| import os |
| import sys |
| import unittest |
| |
| # Let unittest strip this module's frames from tracebacks, so only the original |
| # subprocess traceback (attached as the cause) is shown, not the replay frames. |
| __unittest = True |
| |
| # test.support globals set by regrtest (libregrtest/setup.py) that affect how |
| # tests run and which are skipped at runtime in the subprocess. |
| _PROPAGATED_CONFIG = ( |
| 'use_resources', # -u (is_resource_enabled/requires) |
| 'max_memuse', 'real_max_memuse', # -M (bigmemtest) |
| 'verbose', # -v |
| 'failfast', # -f |
| ) |
| |
| def _child_config(): |
| import test.support as support |
| return {name: getattr(support, name) for name in _PROPAGATED_CONFIG} |
| |
| def _apply_child_config(config): |
| """Set up the child to run the test like a regrtest worker would. |
| |
| Mark this process as the subprocess, mirror the parent's -u/-M/-v config, |
| then suppress the Windows CRT assertion dialogs, which would block a debug |
| build on a modal dialog and hang the parent. |
| """ |
| global runningInSubprocess |
| import marshal |
| import test.support as support |
| runningInSubprocess = True |
| for name, value in marshal.loads(bytes.fromhex(config)).items(): |
| setattr(support, name, value) |
| support.suppress_msvcrt_asserts(support.verbose >= 2) |
| |
| # True inside the subprocess spawned by @runInSubprocess(), set by |
| # _apply_child_config() before the test is imported. Fixtures can test it to |
| # decide what to run in the subprocess as opposed to the parent process. |
| runningInSubprocess = False |
| |
| |
| class _RemoteTraceback(Exception): |
| """Carry a formatted traceback string from the subprocess for display. |
| |
| Attached as the ``__cause__`` of the replayed failure/error, so that the |
| original traceback is shown by the traceback machinery. |
| """ |
| def __init__(self, tb): |
| self.tb = tb |
| |
| def __str__(self): |
| return self.tb |
| |
| |
| class _SubprocessTestError(Exception): |
| """Replay a subprocess error (as opposed to a failure) in the parent.""" |
| |
| |
| def _decode(data): |
| # Decode the child output, which is only ever shown as a diagnostic: an |
| # undecodable byte must not hide the failure it is part of. |
| if not data: |
| return '' |
| import locale |
| encoding = 'utf-8' if sys.flags.utf8_mode else locale.getencoding() |
| return data.decode(encoding, 'backslashreplace').replace('\r\n', '\n') |
| |
| |
| def _remote(detail): |
| # Wrap the subprocess traceback the way concurrent.futures does, so it is |
| # clearly delimited when shown as the cause. Return None if the subprocess |
| # said nothing (a hung one usually does not), so that "raise ... from None" |
| # suppresses an empty cause. |
| if not detail: |
| return None |
| return _RemoteTraceback(f'\n"""\n{detail}"""') |
| |
| |
| def _check_subprocess_support(): |
| # runInSubprocess() always runs the test in a subprocess, so skip (in the |
| # parent) on platforms that do not support spawning one. |
| import test.support as support |
| if not support.has_subprocess_support: |
| raise unittest.SkipTest('requires subprocess support') |
| |
| |
| def _child_environ(env): |
| # Start from the inherited environment, so that *env* only has to name what |
| # the test changes. |
| if not env: |
| return None |
| environ = dict(os.environ) |
| for name, value in env.items(): |
| if value is None: |
| environ.pop(name, None) |
| else: |
| environ[name] = value |
| return environ |
| |
| |
| class _SubprocessTest: |
| """A test running in a subprocess, started by _start_test(). |
| |
| The parent can watch the subprocess (its pid) while the test runs, and |
| must wait() for it. |
| """ |
| |
| def __init__(self, proc, result_path): |
| self._proc = proc |
| self._result_path = result_path |
| |
| @property |
| def pid(self): |
| return self._proc.pid |
| |
| def wait(self, timeout=None, tick=None, interval=1.0): |
| """Wait for the test to finish, calling *tick* every *interval* seconds. |
| |
| Return ``(payload, output, returncode)``, where *payload* is the |
| decoded ``{'outcomes': ..., 'durations': ...}`` mapping from the |
| subprocess, or ``None`` if it did not run to completion (crash, |
| import error, ...). |
| """ |
| import marshal |
| import subprocess |
| import time |
| deadline = None if timeout is None else time.monotonic() + timeout |
| try: |
| while True: |
| step = None if deadline is None else max( |
| 0.0, deadline - time.monotonic()) |
| # Wake up for the next tick, unless the timeout comes first. |
| ticking = tick is not None and (step is None or step > interval) |
| try: |
| # communicate(), not wait(): a test writing more than a |
| # pipe buffer would block. Retrying keeps what it read. |
| stdout, stderr = self._proc.communicate( |
| timeout=interval if ticking else step) |
| break |
| except subprocess.TimeoutExpired: |
| if ticking: |
| tick() |
| continue |
| # Report the hang rather than leaving the runner stuck. |
| self._proc.kill() |
| stdout, stderr = self._proc.communicate() |
| raise _SubprocessTestError( |
| f'test did not complete in a subprocess ' |
| f'within {timeout} seconds' |
| ) from _remote(_decode(stdout) + _decode(stderr)) |
| try: |
| with open(self._result_path, 'rb') as f: |
| payload = marshal.load(f) |
| except (OSError, EOFError, ValueError): |
| payload = None |
| output = _decode(stdout) + _decode(stderr) |
| return payload, output, self._proc.returncode |
| finally: |
| try: |
| os.unlink(self._result_path) |
| except OSError: |
| pass |
| |
| |
| def _start_test(module, qualname, options=(), env=None): |
| """Start module.qualname (a test method or class) in a fresh subprocess. |
| |
| Return a _SubprocessTest. Its wait() is what removes the temporary file |
| the subprocess writes its result to. |
| """ |
| import marshal |
| import subprocess |
| import tempfile |
| fd, result_path = tempfile.mkstemp(suffix='.json') |
| os.close(fd) |
| try: |
| # Pass the config on the command line, not in the environment, so that |
| # the test cannot pass it on to the processes it spawns itself, and so |
| # that it survives the -E and -I options. Use marshal, not json: it is |
| # built in, so the child imports nothing that the test would not see in |
| # a normal test run. |
| cmd = [sys.executable, *options, '-m', 'test.support.subprocess_runner', |
| module, qualname, result_path, |
| marshal.dumps(_child_config()).hex()] |
| proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, |
| stderr=subprocess.PIPE, env=_child_environ(env)) |
| except BaseException: |
| try: |
| os.unlink(result_path) |
| except OSError: |
| pass |
| raise |
| return _SubprocessTest(proc, result_path) |
| |
| |
| |
| def _replay_outcome(test, outcome): |
| kind = outcome['kind'] |
| detail = outcome['detail'] |
| if kind == 'skipped': |
| test.skipTest(detail) # the detail is the skip reason, not a traceback |
| elif kind in ('failure', 'expected_failure'): |
| # Replay an expected failure like a failure: the wrapper keeps the |
| # @expectedFailure marker (via functools.wraps), so the parent records |
| # the raised exception as an expectedFailure. |
| exc = test.failureException('test failed in the subprocess') |
| raise exc from _remote(detail) |
| else: # 'error' |
| exc = _SubprocessTestError('test failed in the subprocess') |
| raise exc from _remote(detail) |
| |
| |
| def _replay_outcomes(test, outcomes): |
| # Replay each subtest outcome in its own subTest() context so that they are |
| # reported individually, then replay the whole-test outcome (if any). |
| main = [] |
| for outcome in outcomes: |
| if outcome['subtest']: |
| with test.subTest(outcome['desc']): |
| _replay_outcome(test, outcome) |
| else: |
| main.append(outcome) |
| for outcome in main: |
| _replay_outcome(test, outcome) |
| |
| |
| def _raise_fixture_outcome(outcome): |
| # Reproduce a setUpClass()/setUpModule() failure or skip from the |
| # subprocess in a parent-process fixture, so it applies to every test. |
| if outcome['kind'] == 'skipped': |
| raise unittest.SkipTest(outcome['detail']) |
| exc = _SubprocessTestError('class failed in the subprocess') |
| raise exc from _remote(outcome['detail']) |
| |
| |
| def _check_returncode(returncode, output, what): |
| # The subprocess writes its result before exiting, so a non-zero exit code |
| # means it died afterwards, during finalization, unnoticed by the result. |
| if returncode: |
| exc = _SubprocessTestError( |
| f'the subprocess exited with code {returncode} ' |
| f'after running the {what}') |
| raise exc from _remote(output) |
| |
| |
| def _replay_test(test, payload, output, returncode): |
| """Reproduce in *test* the result that _SubprocessTest.wait() returned.""" |
| if payload is None: |
| exc = _SubprocessTestError( |
| f'test did not complete in a subprocess (exit code {returncode})') |
| raise exc from _remote(output) |
| # The parent measures the test method's own duration (the real cost of the |
| # isolated run, subprocess startup included), so nothing to forward here. |
| # Replay the outcomes first: a failure of the test itself is more useful. |
| _replay_outcomes(test, payload['outcomes']) |
| _check_returncode(returncode, output, 'test') |
| |
| |
| def _isolate_method(func, options, env, timeout): |
| @functools.wraps(func) |
| def wrapper(self, /, *args, **kwargs): |
| if runningInSubprocess: |
| # Already running in the subprocess: run the real test. |
| return func(self, *args, **kwargs) |
| _check_subprocess_support() |
| cls = type(self) |
| qualname = f'{cls.__qualname__}.{func.__name__}' |
| proc = _start_test(cls.__module__, qualname, options, env) |
| _replay_test(self, *proc.wait(timeout)) |
| return wrapper |
| |
| |
| def _isolate_class(cls, options, env, timeout): |
| # Unwrap to the plain functions so the replacements can call them with the |
| # runtime cls; a bound classmethod would freeze the decoration-time class |
| # and a subclass would run the fixtures bound to the base class. |
| orig_setUpClass = cls.setUpClass.__func__ |
| orig_tearDownClass = cls.tearDownClass.__func__ |
| # Hook the _call*() indirections rather than setUp(), tearDown() and the |
| # test methods themselves, to cover what a subclass adds or overrides too. |
| orig_callSetUp = cls._callSetUp |
| orig_callTearDown = cls._callTearDown |
| orig_callTestMethod = cls._callTestMethod |
| orig_addDuration = cls._addDuration |
| |
| def setUpClass(cls): |
| if runningInSubprocess: |
| orig_setUpClass(cls) |
| return |
| _check_subprocess_support() |
| # Run the whole class in a single subprocess and stash the outcomes |
| # for the test methods to replay. |
| proc = _start_test(cls.__module__, cls.__qualname__, options, env) |
| payload, output, returncode = proc.wait(timeout) |
| if payload is None: |
| exc = _SubprocessTestError( |
| f'class did not complete in a subprocess (exit code {returncode})') |
| raise exc from _remote(output) |
| by_id = {} |
| for outcome in payload['outcomes']: |
| if outcome['fixture']: |
| # A setUpClass()/setUpModule() failure or skip: apply it to the |
| # whole class by raising it here, in the parent's setUpClass(). |
| _raise_fixture_outcome(outcome) |
| by_id.setdefault(outcome['id'], []).append(outcome) |
| cls._isolated_outcomes = by_id |
| cls._isolated_durations = dict(payload.get('durations', ())) |
| # Report the crash from tearDownClass(), after replaying the outcomes. |
| cls._isolated_exit = (returncode, output) |
| |
| def tearDownClass(cls): |
| if runningInSubprocess: |
| orig_tearDownClass(cls) |
| return |
| cls._isolated_outcomes = None |
| cls._isolated_durations = None |
| # Missing if an overriding setUpClass() bypassed the subprocess. |
| exited = getattr(cls, '_isolated_exit', None) |
| cls._isolated_exit = None |
| if exited is not None: |
| _check_returncode(*exited, 'class') |
| |
| def _callSetUp(self): |
| # In the parent the real test does not run, so neither should setUp(). |
| if runningInSubprocess: |
| orig_callSetUp(self) |
| |
| def _callTearDown(self): |
| if runningInSubprocess: |
| orig_callTearDown(self) |
| |
| def _callTestMethod(self, method): |
| if runningInSubprocess: |
| orig_callTestMethod(self, method) |
| return |
| by_id = getattr(type(self), '_isolated_outcomes', None) |
| if by_id is None: |
| raise _SubprocessTestError( |
| f'{type(self).__name__} did not run in a subprocess; ' |
| f'an overriding setUpClass() must call super().setUpClass()') |
| _replay_outcomes(self, by_id.get(self.id(), [])) |
| |
| def _addDuration(self, result, elapsed): |
| # In the parent, report the subprocess timing rather than the (instant) |
| # replay time; subprocess startup is paid once, in setUpClass. |
| if not runningInSubprocess: |
| durations = getattr(type(self), '_isolated_durations', None) or {} |
| elapsed = durations.get(self.id(), elapsed) |
| orig_addDuration(self, result, elapsed) |
| |
| cls.setUpClass = classmethod(setUpClass) |
| cls.tearDownClass = classmethod(tearDownClass) |
| cls._callSetUp = _callSetUp |
| cls._callTearDown = _callTearDown |
| cls._callTestMethod = _callTestMethod |
| cls._addDuration = _addDuration |
| return cls |
| |
| |
| def runInSubprocess(*, options=(), env=None, timeout=None): |
| """Decorator to run a test method or class in a fresh subprocess. |
| |
| The decorated test runs in a separate, fresh Python process, so it does not |
| share global or interpreter state with the rest of the test run. When a |
| :class:`~unittest.TestCase` subclass is decorated, the whole class runs in a |
| single subprocess and its ``setUpClass()``/``setUpModule()`` fixtures run |
| once there; when a method is decorated, only that method runs in a |
| subprocess. Decorated methods must take no extra arguments. |
| |
| *options* is a sequence of interpreter command line options for the |
| subprocess, and *env* is a mapping of environment variables to set in it, |
| on top of the inherited environment; a value of ``None`` unsets a variable. |
| Note that ``-E`` and ``-I`` make the subprocess ignore the ``PYTHON*`` |
| variables, including ``PYTHONPATH``. |
| |
| *timeout* is the number of seconds to wait for the subprocess; the test is |
| reported as an error if it does not complete in time. By default there is |
| no timeout, and a hung test is left to the timeout of the test runner. |
| |
| A failure, error or skip of the whole test is reported for the test, and |
| individual subtests (:meth:`~unittest.TestCase.subTest`) that fail or are |
| skipped are reported individually. The original subprocess traceback is |
| shown as the cause of a reported failure or error. Use |
| :data:`runningInSubprocess` in fixtures to choose what to run in the subprocess. |
| |
| The test is skipped on platforms without subprocess support, since it must |
| spawn one. |
| """ |
| def decorator(obj): |
| if isinstance(obj, type) and issubclass(obj, unittest.TestCase): |
| return _isolate_class(obj, options, env, timeout) |
| return _isolate_method(obj, options, env, timeout) |
| return decorator |