Skip to content

Commit cb80e36

Browse files
committed
Add option to launch each test in separate process
Either specify directly on command line, or add tool.robotpy.pyfrc.multiprocess to pyproject.toml. If it works out, expect to make this default in 2026
1 parent f6f4e8e commit cb80e36

File tree

4 files changed

+184
-11
lines changed

4 files changed

+184
-11
lines changed

pyfrc/mains/cli_test.py

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

88
import wpilib
99

10+
import tomli
1011
import pytest
1112

1213
from ..util import yesno
@@ -37,6 +38,12 @@ def __init__(self, parser=None):
3738
action="store_true",
3839
help="Use pyfrc's builtin tests if no tests are specified",
3940
)
41+
parser.add_argument(
42+
"--isolated",
43+
default=None,
44+
action="store_true",
45+
help="Run each test in a separate robot process. Set `tool.robotpy.pyfrc.isolated` to true in your pyproject.toml to enable by default",
46+
)
4047
parser.add_argument(
4148
"--coverage-mode",
4249
default=False,
@@ -55,16 +62,41 @@ def run(
5562
project_path: pathlib.Path,
5663
robot_class: typing.Type[wpilib.RobotBase],
5764
builtin: bool,
65+
isolated: typing.Optional[bool],
5866
coverage_mode: bool,
67+
verbose: bool,
5968
pytest_args: typing.List[str],
6069
):
70+
if isolated is None:
71+
pyproject_path = project_path / "pyproject.toml"
72+
if pyproject_path.exists():
73+
with open(pyproject_path, "rb") as fp:
74+
d = tomli.load(fp)
75+
76+
try:
77+
v = d["tool"]["robotpy"]["pyfrc"]["isolated"]
78+
except KeyError:
79+
pass
80+
else:
81+
if not isinstance(v, bool):
82+
raise ValueError(
83+
f"tool.robotpy.pyfrc.isolated must be a boolean value (got {v})"
84+
)
85+
86+
isolated = v
87+
88+
if isolated is None:
89+
isolated = False
90+
6191
try:
6292
return self._run_test(
6393
main_file,
6494
project_path,
6595
robot_class,
6696
builtin,
97+
isolated,
6798
coverage_mode,
99+
verbose,
68100
pytest_args,
69101
)
70102
except _TryAgain:
@@ -73,7 +105,9 @@ def run(
73105
project_path,
74106
robot_class,
75107
builtin,
108+
isolated,
76109
coverage_mode,
110+
verbose,
77111
pytest_args,
78112
)
79113

@@ -83,7 +117,9 @@ def _run_test(
83117
project_path: pathlib.Path,
84118
robot_class: typing.Type[wpilib.RobotBase],
85119
builtin: bool,
120+
isolated: bool,
86121
coverage_mode: bool,
122+
verbose: bool,
87123
pytest_args: typing.List[str],
88124
):
89125
# find test directory, change current directory so pytest can find the tests
@@ -92,14 +128,15 @@ def _run_test(
92128
curdir = pathlib.Path.cwd().absolute()
93129

94130
self.try_dirs = [
95-
(project_path / "tests").absolute(),
96-
(project_path / ".." / "tests").absolute(),
131+
((project_path / "tests").absolute(), False),
132+
((project_path / ".." / "tests").absolute(), True),
97133
]
98134

99-
for d in self.try_dirs:
135+
for d, chdir in self.try_dirs:
100136
if d.exists():
101-
test_directory = d
102-
os.chdir(test_directory)
137+
builtin = False
138+
if chdir:
139+
os.chdir(d)
103140
break
104141
else:
105142
if not builtin:
@@ -112,10 +149,22 @@ def _run_test(
112149
pytest_args.insert(0, abspath(inspect.getfile(basic)))
113150

114151
try:
115-
retv = pytest.main(
116-
pytest_args,
117-
plugins=[pytest_plugin.PyFrcPlugin(robot_class, main_file)],
118-
)
152+
if isolated:
153+
from ..test_support import pytest_dist_plugin
154+
155+
retv = pytest.main(
156+
pytest_args,
157+
plugins=[
158+
pytest_dist_plugin.DistPlugin(
159+
robot_class, main_file, builtin, verbose
160+
)
161+
],
162+
)
163+
else:
164+
retv = pytest.main(
165+
pytest_args,
166+
plugins=[pytest_plugin.PyFrcPlugin(robot_class, main_file, False)],
167+
)
119168
finally:
120169
os.chdir(curdir)
121170

@@ -132,7 +181,7 @@ def _no_tests(
132181
):
133182
print()
134183
print("Looked for tests at:")
135-
for d in self.try_dirs:
184+
for d, _ in self.try_dirs:
136185
print("-", d)
137186
print()
138187
print(
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import multiprocessing
2+
import pathlib
3+
import sys
4+
5+
from typing import Type
6+
7+
import pytest
8+
9+
import robotpy.logconfig
10+
import wpilib
11+
12+
13+
from .pytest_plugin import PyFrcPlugin
14+
15+
16+
def _run_test(item_nodeid, config_args, robot_class, robot_file, verbose):
17+
"""This function runs in a subprocess"""
18+
robotpy.logconfig.configure_logging(verbose)
19+
ec = pytest.main(
20+
[item_nodeid, "--no-header", "--no-summary", *config_args],
21+
plugins=[PyFrcPlugin(robot_class, robot_file, True)],
22+
)
23+
sys.exit(ec)
24+
25+
26+
def _run_test_in_new_process(
27+
test_function, config, robot_class, robot_file, builtin_tests, verbose
28+
):
29+
"""Run a test function in a new process."""
30+
31+
config_args = config.invocation_params.args
32+
if builtin_tests:
33+
item_nodeid = f"{config_args[0]}::{test_function.name}"
34+
config_args = config_args[1:]
35+
else:
36+
item_nodeid = test_function.nodeid
37+
38+
process = multiprocessing.Process(
39+
target=_run_test,
40+
args=(item_nodeid, config_args, robot_class, robot_file, verbose),
41+
)
42+
process.start()
43+
process.join()
44+
45+
if process.exitcode != 0:
46+
pytest.fail(f"Test failed in subprocess: {item_nodeid}", pytrace=False)
47+
48+
49+
def _make_runtest(item, config, robot_class, robot_file, builtin_tests, verbose):
50+
def isolated_runtest():
51+
_run_test_in_new_process(
52+
item, config, robot_class, robot_file, builtin_tests, verbose
53+
)
54+
55+
return isolated_runtest
56+
57+
58+
class DistPlugin:
59+
60+
def __init__(
61+
self,
62+
robot_class: Type[wpilib.RobotBase],
63+
robot_file: pathlib.Path,
64+
builtin_tests: bool,
65+
verbose: bool,
66+
) -> None:
67+
self._robot_class = robot_class
68+
self._robot_file = robot_file
69+
self._builtin_tests = builtin_tests
70+
self._verbose = verbose
71+
72+
@pytest.hookimpl(tryfirst=True)
73+
def pytest_collection_modifyitems(
74+
self,
75+
session: pytest.Session,
76+
config: pytest.Config,
77+
items: list[pytest.Function],
78+
):
79+
"""Modify collected test items to run each in a new process."""
80+
81+
multiprocessing.set_start_method("spawn")
82+
83+
for item in items:
84+
# Overwrite the runtest protocol for each item
85+
item.runtest = _make_runtest(
86+
item,
87+
config,
88+
self._robot_class,
89+
self._robot_file,
90+
self._builtin_tests,
91+
self._verbose,
92+
)
93+
94+
#
95+
# These fixtures match the ones in PyFrcPlugin but these have no effect
96+
#
97+
98+
@pytest.fixture(scope="function", autouse=True)
99+
def robot(self):
100+
pass
101+
102+
@pytest.fixture(scope="function")
103+
def control(self, reraise, robot):
104+
pass
105+
106+
@pytest.fixture()
107+
def robot_file(self):
108+
pass
109+
110+
@pytest.fixture()
111+
def robot_path(self):
112+
pass

pyfrc/test_support/pytest_plugin.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,14 @@ class PyFrcPlugin:
3131
be passed to your test function.
3232
"""
3333

34-
def __init__(self, robot_class: Type[wpilib.RobotBase], robot_file: pathlib.Path):
34+
def __init__(
35+
self,
36+
robot_class: Type[wpilib.RobotBase],
37+
robot_file: pathlib.Path,
38+
isolated: bool,
39+
):
40+
self.isolated = isolated
41+
3542
# attach physics
3643
physics, robot_class = PhysicsInterface._create_and_attach(
3744
robot_class,
@@ -103,6 +110,10 @@ def robot(self):
103110
# Tests only get a proxy to ensure cleanup is more reliable
104111
yield weakref.proxy(robot)
105112

113+
# If running in separate processes, no need to do cleanup
114+
if self.isolated:
115+
return
116+
106117
# reset engine to ensure it gets cleaned up too
107118
# -> might be holding wpilib objects, or the robot
108119
if self._physics:

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ install_requires =
2929

3030
wpilib>=2025.1.1,<2026
3131
robotpy-cli~=2024.0
32+
tomli
3233
setup_requires =
3334
setuptools_scm > 6
3435
python_requires = >=3.9

0 commit comments

Comments
 (0)