Skip to content

Commit fbd452c

Browse files
committed
Use real subprocess for pytest collection to avoid plugin state accumulation
1 parent da57691 commit fbd452c

1 file changed

Lines changed: 62 additions & 55 deletions

File tree

ci/test_custom_linters.py

Lines changed: 62 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Custom lint tests."""
22

3+
import subprocess
4+
import sys
35
from pathlib import Path
46
from typing import TYPE_CHECKING
57

@@ -24,37 +26,33 @@ def _ci_patterns(*, repository_root: Path) -> set[str]:
2426

2527

2628
@beartype
27-
def _tests_from_pattern(
28-
*,
29-
ci_pattern: str,
30-
capsys: pytest.CaptureFixture[str],
31-
) -> set[str]:
32-
"""From a CI pattern, get all tests ``pytest`` would collect."""
33-
# Clear the captured output.
34-
capsys.readouterr()
35-
tests: Iterable[str] = set()
36-
pytest.main(
29+
def _collect(
30+
*, ci_pattern: str, repository_root: Path
31+
) -> subprocess.CompletedProcess[str]:
32+
"""Run ``pytest --collect-only`` for ``ci_pattern`` in a fresh
33+
subprocess.
34+
35+
A real subprocess (not ``pytest.main``) is used so that plugin state
36+
(notably ``pytest-beartype-tests`` re-wrapping the same test
37+
functions) does not accumulate across iterations and trigger
38+
``Cannot stringify annotation containing string formatting`` under
39+
Python 3.14 deferred annotations.
40+
"""
41+
return subprocess.run(
3742
args=[
43+
sys.executable,
44+
"-m",
45+
"pytest",
3846
"-q",
3947
"--collect-only",
40-
# If there are any warnings, these obscure the output.
4148
"--disable-warnings",
42-
# Disable ``pytest-beartype-tests`` to avoid repeated wrapping
43-
# of the same test functions across many ``pytest.main`` calls,
44-
# which can trigger ``Cannot stringify annotation containing
45-
# string formatting`` under Python 3.14 deferred annotations.
46-
"-p",
47-
"no:pytest_beartype_tests",
4849
ci_pattern,
4950
],
51+
check=False,
52+
cwd=repository_root,
53+
capture_output=True,
54+
text=True,
5055
)
51-
data = capsys.readouterr().out
52-
for line in data.splitlines():
53-
# We filter empty lines and lines which look like
54-
# "9 tests collected in 0.01s".
55-
if line and "collected in" not in line:
56-
tests = {*tests, line}
57-
return set(tests)
5856

5957

6058
def test_ci_patterns_valid(request: pytest.FixtureRequest) -> None:
@@ -63,52 +61,38 @@ def test_ci_patterns_valid(request: pytest.FixtureRequest) -> None:
6361
test in
6462
the test suite.
6563
"""
66-
ci_patterns = _ci_patterns(repository_root=request.config.rootpath)
64+
repository_root = request.config.rootpath
65+
ci_patterns = _ci_patterns(repository_root=repository_root)
6766

6867
for ci_pattern in ci_patterns:
69-
collect_only_result = pytest.main(
70-
args=[
71-
"--collect-only",
72-
ci_pattern,
73-
# Disable pytest-retry to avoid:
74-
# ```
75-
# ValueError: no option named 'filtered_exceptions'
76-
# ````
77-
"-p",
78-
"no:pytest-retry",
79-
# Disable ``pytest-beartype-tests`` to avoid repeated
80-
# wrapping of the same test functions across many
81-
# ``pytest.main`` calls, which can trigger ``Cannot
82-
# stringify annotation containing string formatting``
83-
# under Python 3.14 deferred annotations.
84-
"-p",
85-
"no:pytest_beartype_tests",
86-
# Disable warnings to avoid many instances of:
87-
# ```
88-
# Unknown config option: retry_delay
89-
# ```
90-
"--disable-warnings",
91-
],
68+
result = _collect(
69+
ci_pattern=ci_pattern,
70+
repository_root=repository_root,
9271
)
93-
94-
message = f'"{ci_pattern}" does not match any tests.'
95-
assert collect_only_result == 0, message
72+
message = (
73+
f'"{ci_pattern}" does not match any tests.\n'
74+
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
75+
)
76+
assert result.returncode == 0, message
9677

9778

9879
def test_tests_collected_once(
9980
*,
100-
capsys: pytest.CaptureFixture[str],
10181
request: pytest.FixtureRequest,
10282
) -> None:
10383
"""Each test in the test suite is collected exactly once.
10484
10585
This does not necessarily mean that they are run - they may be skipped.
10686
"""
107-
ci_patterns = _ci_patterns(repository_root=request.config.rootpath)
87+
repository_root = request.config.rootpath
88+
ci_patterns = _ci_patterns(repository_root=repository_root)
10889
tests_to_patterns: dict[str, set[str]] = {}
10990

11091
for pattern in ci_patterns:
111-
tests = _tests_from_pattern(ci_pattern=pattern, capsys=capsys)
92+
tests = _tests_from_pattern(
93+
ci_pattern=pattern,
94+
repository_root=repository_root,
95+
)
11296
for test in tests:
11397
if test in tests_to_patterns:
11498
tests_to_patterns[test].add(pattern)
@@ -123,6 +107,29 @@ def test_tests_collected_once(
123107
)
124108
assert len(patterns) == 1, message
125109

126-
all_tests = _tests_from_pattern(ci_pattern=".", capsys=capsys)
110+
all_tests = _tests_from_pattern(
111+
ci_pattern=".",
112+
repository_root=repository_root,
113+
)
127114
assert tests_to_patterns.keys() - all_tests == set()
128115
assert all_tests - tests_to_patterns.keys() == set()
116+
117+
118+
@beartype
119+
def _tests_from_pattern(
120+
*,
121+
ci_pattern: str,
122+
repository_root: Path,
123+
) -> set[str]:
124+
"""From a CI pattern, get all tests ``pytest`` would collect."""
125+
result = _collect(
126+
ci_pattern=ci_pattern,
127+
repository_root=repository_root,
128+
)
129+
tests: Iterable[str] = set()
130+
for line in result.stdout.splitlines():
131+
# We filter empty lines and lines which look like
132+
# "9 tests collected in 0.01s".
133+
if line and "collected in" not in line:
134+
tests = {*tests, line}
135+
return set(tests)

0 commit comments

Comments
 (0)