Skip to content
Merged
2 changes: 1 addition & 1 deletion Doc/library/os.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1448,7 +1448,7 @@ or `the MSDN <https://msdn.microsoft.com/en-us/library/z0kc8e3z.aspx>`_ on Windo
Return a pair of file descriptors ``(r, w)`` usable for reading and writing,
respectively.

.. availability:: Unix, not WASI, not macOS, not iOS.
.. availability:: Unix, macOS >= 27.0, not WASI, not iOS.

.. versionadded:: 3.3

Expand Down
15 changes: 14 additions & 1 deletion Doc/library/test.rst
Original file line number Diff line number Diff line change
Expand Up @@ -963,7 +963,7 @@ The :mod:`!test.support` module defines the following functions:

.. currentmodule:: test.support.isolation

.. decorator:: runInSubprocess()
.. decorator:: runInSubprocess(*, options=(), env=None, timeout=None)

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

*options* is a sequence of interpreter command line options
to run the subprocess with,
and *env* is a mapping of environment variables to set in it,
on top of the inherited environment.
A value of ``None`` in *env* unsets the variable.
Note that :option:`-E` and :option:`-I` make the subprocess ignore
the ``PYTHON*`` environment variables, including :envvar:`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.

The test is skipped on platforms without subprocess support.


Expand Down
2 changes: 1 addition & 1 deletion Lib/ensurepip/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@


__all__ = ["version", "bootstrap"]
_PIP_VERSION = "26.2"
_PIP_VERSION = "26.2.1"

# Directory of system wheel packages. Some Linux distribution packaging
# policies recommend against bundling dependencies. For example, Fedora
Expand Down
Binary file not shown.
37 changes: 37 additions & 0 deletions Lib/test/_isolated_sample.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import atexit
import os
import sys
import time
import unittest
from test.support import isolation
Expand Down Expand Up @@ -141,3 +142,39 @@ def test_pass(self):

def test_dies(self):
_die_at_exit()


@isolation.runInSubprocess(options=['-X', 'dev', '-W', 'error::BytesWarning'])
class OptionsSample(unittest.TestCase):

def test_options_applied(self):
self.assertTrue(sys.flags.dev_mode)
self.assertIn('error::BytesWarning', sys.warnoptions)


class EnvSample(unittest.TestCase):

@isolation.runInSubprocess(env={'_PYTHON_ISOLATION_PROBE': 'set-by-test'})
def test_env_set(self):
self.assertEqual(os.environ.get('_PYTHON_ISOLATION_PROBE'), 'set-by-test')

@isolation.runInSubprocess(env={'_PYTHON_ISOLATION_PROBE': None})
def test_env_unset(self):
self.assertNotIn('_PYTHON_ISOLATION_PROBE', os.environ)

@isolation.runInSubprocess()
def test_env_inherited(self):
# Without env= the subprocess inherits the parent environment as it is.
self.assertEqual(os.environ.get('_PYTHON_ISOLATION_PROBE'), 'set-by-parent')


# TimeoutSample hangs this long, so that the timeout always fires first.
TIMEOUT_HANG = 60.0
TIMEOUT = 0.5


class TimeoutSample(unittest.TestCase):

@isolation.runInSubprocess(timeout=TIMEOUT)
def test_hang(self):
time.sleep(TIMEOUT_HANG)
140 changes: 140 additions & 0 deletions Lib/test/clinic.test.c
Original file line number Diff line number Diff line change
Expand Up @@ -5769,6 +5769,146 @@ Test___init___impl(TestObj *self, PyObject *a, int group_right_1,
/*[clinic end generated code: output=2bbb8ea60e8f57a6 input=10f5d0f1e8e466ef]*/


/*[clinic input]
group_and_optional_parameter
[
a: object
b: object
]
c: object = None
/
The optional parameter can be omitted with or without the group.
[clinic start generated code]*/

PyDoc_STRVAR(group_and_optional_parameter__doc__,
"group_and_optional_parameter([a, b,] c=None)\n"
"The optional parameter can be omitted with or without the group.");

#define GROUP_AND_OPTIONAL_PARAMETER_METHODDEF \
{"group_and_optional_parameter", (PyCFunction)group_and_optional_parameter, METH_VARARGS, group_and_optional_parameter__doc__},

static PyObject *
group_and_optional_parameter_impl(PyObject *module, int group_left_1,
PyObject *a, PyObject *b, PyObject *c);

static PyObject *
group_and_optional_parameter(PyObject *module, PyObject *args)
{
PyObject *return_value = NULL;
int group_left_1 = 0;
PyObject *a = NULL;
PyObject *b = NULL;
PyObject *c = Py_None;

switch (PyTuple_GET_SIZE(args)) {
case 0:
case 1:
if (!PyArg_ParseTuple(args, "|O:group_and_optional_parameter", &c)) {
goto exit;
}
break;
case 2:
case 3:
if (!PyArg_ParseTuple(args, "OO|O:group_and_optional_parameter", &a, &b, &c)) {
goto exit;
}
group_left_1 = 1;
break;
default:
PyErr_SetString(PyExc_TypeError, "group_and_optional_parameter requires 0 to 3 arguments");
goto exit;
}
return_value = group_and_optional_parameter_impl(module, group_left_1, a, b, c);

exit:
return return_value;
}

static PyObject *
group_and_optional_parameter_impl(PyObject *module, int group_left_1,
PyObject *a, PyObject *b, PyObject *c)
/*[clinic end generated code: output=3faea69eafd5bbbe input=7f0fbb6124f5a972]*/


/*[clinic input]
two_groups_on_the_same_level
[
a: object
b: object
]
[
c: object
]
d: object
/
Groups on the same level are independent of each other.
[clinic start generated code]*/

PyDoc_STRVAR(two_groups_on_the_same_level__doc__,
"two_groups_on_the_same_level([a, b,] [c,] d)\n"
"Groups on the same level are independent of each other.");

#define TWO_GROUPS_ON_THE_SAME_LEVEL_METHODDEF \
{"two_groups_on_the_same_level", (PyCFunction)two_groups_on_the_same_level, METH_VARARGS, two_groups_on_the_same_level__doc__},

static PyObject *
two_groups_on_the_same_level_impl(PyObject *module, int group_left_1,
PyObject *a, PyObject *b, int group_left_2,
PyObject *c, PyObject *d);

static PyObject *
two_groups_on_the_same_level(PyObject *module, PyObject *args)
{
PyObject *return_value = NULL;
int group_left_1 = 0;
PyObject *a = NULL;
PyObject *b = NULL;
int group_left_2 = 0;
PyObject *c = NULL;
PyObject *d;

switch (PyTuple_GET_SIZE(args)) {
case 1:
if (!PyArg_ParseTuple(args, "O:two_groups_on_the_same_level", &d)) {
goto exit;
}
break;
case 2:
if (!PyArg_ParseTuple(args, "OO:two_groups_on_the_same_level", &c, &d)) {
goto exit;
}
group_left_2 = 1;
break;
case 3:
if (!PyArg_ParseTuple(args, "OOO:two_groups_on_the_same_level", &a, &b, &d)) {
goto exit;
}
group_left_1 = 1;
break;
case 4:
if (!PyArg_ParseTuple(args, "OOOO:two_groups_on_the_same_level", &a, &b, &c, &d)) {
goto exit;
}
group_left_1 = 1;
group_left_2 = 1;
break;
default:
PyErr_SetString(PyExc_TypeError, "two_groups_on_the_same_level requires 1 to 4 arguments");
goto exit;
}
return_value = two_groups_on_the_same_level_impl(module, group_left_1, a, b, group_left_2, c, d);

exit:
return return_value;
}

static PyObject *
two_groups_on_the_same_level_impl(PyObject *module, int group_left_1,
PyObject *a, PyObject *b, int group_left_2,
PyObject *c, PyObject *d)
/*[clinic end generated code: output=508a61ee582da21e input=1b45d9b675b32d1a]*/


/*[clinic input]
Test._pyarg_parsestackandkeywords
cls: defining_class
Expand Down
67 changes: 53 additions & 14 deletions Lib/test/support/isolation.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,11 @@ def _decode(data):

def _remote(detail):
# Wrap the subprocess traceback the way concurrent.futures does, so it is
# clearly delimited when shown as the cause.
# 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}"""')


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


def _run_in_subprocess(module, qualname):
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


def _run_in_subprocess(module, qualname, options, env, timeout):
"""Run module.qualname (a test method or class) in a fresh subprocess.

Return ``(payload, output, returncode)``, where *payload* is the decoded
Expand All @@ -104,13 +122,22 @@ def _run_in_subprocess(module, qualname):
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. 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, '-m', 'test.support.subprocess_runner',
# 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.run(cmd, capture_output=True)
try:
proc = subprocess.run(cmd, capture_output=True,
env=_child_environ(env), timeout=timeout)
except subprocess.TimeoutExpired as exc:
# Report the hang rather than leaving the test runner stuck.
output = _decode(exc.stdout) + _decode(exc.stderr)
raise _SubprocessTestError(
f'test did not complete in a subprocess '
f'within {timeout} seconds') from _remote(output)
try:
with open(result_path, 'rb') as f:
payload = marshal.load(f)
Expand Down Expand Up @@ -173,7 +200,7 @@ def _check_returncode(returncode, output, what):
raise exc from _remote(output)


def _isolate_method(func):
def _isolate_method(func, options, env, timeout):
@functools.wraps(func)
def wrapper(self, /, *args, **kwargs):
if runningInSubprocess:
Expand All @@ -183,7 +210,8 @@ def wrapper(self, /, *args, **kwargs):
cls = type(self)
qualname = f'{cls.__qualname__}.{func.__name__}'
payload, output, returncode = _run_in_subprocess(cls.__module__,
qualname)
qualname, options,
env, timeout)
if payload is None:
exc = _SubprocessTestError(
f'test did not complete in a subprocess (exit code {returncode})')
Expand All @@ -196,7 +224,7 @@ def wrapper(self, /, *args, **kwargs):
return wrapper


def _isolate_class(cls):
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.
Expand All @@ -217,7 +245,8 @@ def setUpClass(cls):
# Run the whole class in a single subprocess and stash the outcomes
# for the test methods to replay.
payload, output, returncode = _run_in_subprocess(cls.__module__,
cls.__qualname__)
cls.__qualname__,
options, env, timeout)
if payload is None:
exc = _SubprocessTestError(
f'class did not complete in a subprocess (exit code {returncode})')
Expand Down Expand Up @@ -283,7 +312,7 @@ def _addDuration(self, result, elapsed):
return cls


def runInSubprocess():
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
Expand All @@ -293,6 +322,16 @@ def runInSubprocess():
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
Expand All @@ -304,6 +343,6 @@ def runInSubprocess():
"""
def decorator(obj):
if isinstance(obj, type) and issubclass(obj, unittest.TestCase):
return _isolate_class(obj)
return _isolate_method(obj)
return _isolate_class(obj, options, env, timeout)
return _isolate_method(obj, options, env, timeout)
return decorator
Loading
Loading