Skip to content

Commit 3932b0f

Browse files
authored
gh-110722: Make -m test -T -j use sys.monitoring (GH-111710)
Now all results from worker processes are aggregated and displayed together as a summary at the end of a regrtest run. The traditional trace is left in place for use with sequential in-process test runs but now raises a warning that those numbers are not precise. `-T -j` requires `--with-pydebug` as it relies on `-Xpresite=`.
1 parent 0b06d24 commit 3932b0f

File tree

13 files changed

+166
-34
lines changed

13 files changed

+166
-34
lines changed

Doc/library/trace.rst

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,14 +187,22 @@ Programmatic Interface
187187

188188
Merge in data from another :class:`CoverageResults` object.
189189

190-
.. method:: write_results(show_missing=True, summary=False, coverdir=None)
190+
.. method:: write_results(show_missing=True, summary=False, coverdir=None,\
191+
*, ignore_missing_files=False)
191192
192193
Write coverage results. Set *show_missing* to show lines that had no
193194
hits. Set *summary* to include in the output the coverage summary per
194195
module. *coverdir* specifies the directory into which the coverage
195196
result files will be output. If ``None``, the results for each source
196197
file are placed in its directory.
197198

199+
If *ignore_missing_files* is ``True``, coverage counts for files that no
200+
longer exist are silently ignored. Otherwise, a missing file will
201+
raise a :exc:`FileNotFoundError`.
202+
203+
.. versionchanged:: 3.13
204+
Added *ignore_missing_files* parameter.
205+
198206
A simple example demonstrating the use of the programmatic interface::
199207

200208
import sys

Lib/test/cov.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""A minimal hook for gathering line coverage of the standard library.
2+
3+
Designed to be used with -Xpresite= which means:
4+
* it installs itself on import
5+
* it's not imported as `__main__` so can't use the ifmain idiom
6+
* it can't import anything besides `sys` to avoid tainting gathered coverage
7+
* filenames are not normalized
8+
9+
To get gathered coverage back, look for 'test.cov' in `sys.modules`
10+
instead of importing directly. That way you can determine if the module
11+
was already in use.
12+
13+
If you need to disable the hook, call the `disable()` function.
14+
"""
15+
16+
import sys
17+
18+
mon = sys.monitoring
19+
20+
FileName = str
21+
LineNo = int
22+
Location = tuple[FileName, LineNo]
23+
24+
coverage: set[Location] = set()
25+
26+
27+
# `types` and `typing` aren't imported to avoid invalid coverage
28+
def add_line(
29+
code: "types.CodeType",
30+
lineno: int,
31+
) -> "typing.Literal[sys.monitoring.DISABLE]":
32+
coverage.add((code.co_filename, lineno))
33+
return mon.DISABLE
34+
35+
36+
def enable():
37+
mon.use_tool_id(mon.COVERAGE_ID, "regrtest coverage")
38+
mon.register_callback(mon.COVERAGE_ID, mon.events.LINE, add_line)
39+
mon.set_events(mon.COVERAGE_ID, mon.events.LINE)
40+
41+
42+
def disable():
43+
mon.set_events(mon.COVERAGE_ID, 0)
44+
mon.register_callback(mon.COVERAGE_ID, mon.events.LINE, None)
45+
mon.free_tool_id(mon.COVERAGE_ID)
46+
47+
48+
enable()

Lib/test/libregrtest/cmdline.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import os.path
33
import shlex
44
import sys
5-
from test.support import os_helper
5+
from test.support import os_helper, Py_DEBUG
66
from .utils import ALL_RESOURCES, RESOURCE_NAMES
77

88

@@ -448,8 +448,16 @@ def _parse_args(args, **kwargs):
448448

449449
if ns.single and ns.fromfile:
450450
parser.error("-s and -f don't go together!")
451-
if ns.use_mp is not None and ns.trace:
452-
parser.error("-T and -j don't go together!")
451+
if ns.trace:
452+
if ns.use_mp is not None:
453+
if not Py_DEBUG:
454+
parser.error("need --with-pydebug to use -T and -j together")
455+
else:
456+
print(
457+
"Warning: collecting coverage without -j is imprecise. Configure"
458+
" --with-pydebug and run -m test -T -j for best results.",
459+
file=sys.stderr
460+
)
453461
if ns.python is not None:
454462
if ns.use_mp is None:
455463
parser.error("-p requires -j!")

Lib/test/libregrtest/main.py

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import sys
66
import sysconfig
77
import time
8+
import trace
89

910
from test import support
1011
from test.support import os_helper, MS_WINDOWS
@@ -13,7 +14,7 @@
1314
from .findtests import findtests, split_test_packages, list_cases
1415
from .logger import Logger
1516
from .pgo import setup_pgo_tests
16-
from .result import State
17+
from .result import State, TestResult
1718
from .results import TestResults, EXITCODE_INTERRUPTED
1819
from .runtests import RunTests, HuntRefleak
1920
from .setup import setup_process, setup_test_dir
@@ -284,24 +285,26 @@ def display_result(self, runtests):
284285
self.results.display_result(runtests.tests,
285286
self.quiet, self.print_slowest)
286287

287-
def run_test(self, test_name: TestName, runtests: RunTests, tracer):
288+
def run_test(
289+
self, test_name: TestName, runtests: RunTests, tracer: trace.Trace | None
290+
) -> TestResult:
288291
if tracer is not None:
289292
# If we're tracing code coverage, then we don't exit with status
290293
# if on a false return value from main.
291294
cmd = ('result = run_single_test(test_name, runtests)')
292295
namespace = dict(locals())
293296
tracer.runctx(cmd, globals=globals(), locals=namespace)
294297
result = namespace['result']
298+
result.covered_lines = list(tracer.counts)
295299
else:
296300
result = run_single_test(test_name, runtests)
297301

298302
self.results.accumulate_result(result, runtests)
299303

300304
return result
301305

302-
def run_tests_sequentially(self, runtests):
306+
def run_tests_sequentially(self, runtests) -> None:
303307
if self.coverage:
304-
import trace
305308
tracer = trace.Trace(trace=False, count=True)
306309
else:
307310
tracer = None
@@ -349,8 +352,6 @@ def run_tests_sequentially(self, runtests):
349352
if previous_test:
350353
print(previous_test)
351354

352-
return tracer
353-
354355
def get_state(self):
355356
state = self.results.get_state(self.fail_env_changed)
356357
if self.first_state:
@@ -361,18 +362,18 @@ def _run_tests_mp(self, runtests: RunTests, num_workers: int) -> None:
361362
from .run_workers import RunWorkers
362363
RunWorkers(num_workers, runtests, self.logger, self.results).run()
363364

364-
def finalize_tests(self, tracer):
365+
def finalize_tests(self, coverage: trace.CoverageResults | None) -> None:
365366
if self.next_single_filename:
366367
if self.next_single_test:
367368
with open(self.next_single_filename, 'w') as fp:
368369
fp.write(self.next_single_test + '\n')
369370
else:
370371
os.unlink(self.next_single_filename)
371372

372-
if tracer is not None:
373-
results = tracer.results()
374-
results.write_results(show_missing=True, summary=True,
375-
coverdir=self.coverage_dir)
373+
if coverage is not None:
374+
coverage.write_results(show_missing=True, summary=True,
375+
coverdir=self.coverage_dir,
376+
ignore_missing_files=True)
376377

377378
if self.want_run_leaks:
378379
os.system("leaks %d" % os.getpid())
@@ -412,6 +413,7 @@ def create_run_tests(self, tests: TestTuple):
412413
hunt_refleak=self.hunt_refleak,
413414
test_dir=self.test_dir,
414415
use_junit=(self.junit_filename is not None),
416+
coverage=self.coverage,
415417
memory_limit=self.memory_limit,
416418
gc_threshold=self.gc_threshold,
417419
use_resources=self.use_resources,
@@ -458,10 +460,10 @@ def _run_tests(self, selected: TestTuple, tests: TestList | None) -> int:
458460
try:
459461
if self.num_workers:
460462
self._run_tests_mp(runtests, self.num_workers)
461-
tracer = None
462463
else:
463-
tracer = self.run_tests_sequentially(runtests)
464+
self.run_tests_sequentially(runtests)
464465

466+
coverage = self.results.get_coverage_results()
465467
self.display_result(runtests)
466468

467469
if self.want_rerun and self.results.need_rerun():
@@ -471,7 +473,7 @@ def _run_tests(self, selected: TestTuple, tests: TestList | None) -> int:
471473
self.logger.stop_load_tracker()
472474

473475
self.display_summary()
474-
self.finalize_tests(tracer)
476+
self.finalize_tests(coverage)
475477

476478
return self.results.get_exitcode(self.fail_env_changed,
477479
self.fail_rerun)

Lib/test/libregrtest/result.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,11 @@ def must_stop(state):
7878
}
7979

8080

81+
FileName = str
82+
LineNo = int
83+
Location = tuple[FileName, LineNo]
84+
85+
8186
@dataclasses.dataclass(slots=True)
8287
class TestResult:
8388
test_name: TestName
@@ -91,6 +96,9 @@ class TestResult:
9196
errors: list[tuple[str, str]] | None = None
9297
failures: list[tuple[str, str]] | None = None
9398

99+
# partial coverage in a worker run; not used by sequential in-process runs
100+
covered_lines: list[Location] | None = None
101+
94102
def is_failed(self, fail_env_changed: bool) -> bool:
95103
if self.state == State.ENV_CHANGED:
96104
return fail_env_changed
@@ -207,6 +215,10 @@ def _decode_test_result(data: dict[str, Any]) -> TestResult | dict[str, Any]:
207215
data.pop('__test_result__')
208216
if data['stats'] is not None:
209217
data['stats'] = TestStats(**data['stats'])
218+
if data['covered_lines'] is not None:
219+
data['covered_lines'] = [
220+
tuple(loc) for loc in data['covered_lines']
221+
]
210222
return TestResult(**data)
211223
else:
212224
return data

Lib/test/libregrtest/results.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import sys
2+
import trace
23

34
from .runtests import RunTests
4-
from .result import State, TestResult, TestStats
5+
from .result import State, TestResult, TestStats, Location
56
from .utils import (
67
StrPath, TestName, TestTuple, TestList, FilterDict,
78
printlist, count, format_duration)
89

910

10-
# Python uses exit code 1 when an exception is not catched
11+
# Python uses exit code 1 when an exception is not caught
1112
# argparse.ArgumentParser.error() uses exit code 2
1213
EXITCODE_BAD_TEST = 2
1314
EXITCODE_ENV_CHANGED = 3
@@ -34,6 +35,8 @@ def __init__(self):
3435
self.stats = TestStats()
3536
# used by --junit-xml
3637
self.testsuite_xml: list[str] = []
38+
# used by -T with -j
39+
self.covered_lines: set[Location] = set()
3740

3841
def is_all_good(self):
3942
return (not self.bad
@@ -119,11 +122,17 @@ def accumulate_result(self, result: TestResult, runtests: RunTests):
119122
self.stats.accumulate(result.stats)
120123
if rerun:
121124
self.rerun.append(test_name)
122-
125+
if result.covered_lines:
126+
# we don't care about trace counts so we don't have to sum them up
127+
self.covered_lines.update(result.covered_lines)
123128
xml_data = result.xml_data
124129
if xml_data:
125130
self.add_junit(xml_data)
126131

132+
def get_coverage_results(self) -> trace.CoverageResults:
133+
counts = {loc: 1 for loc in self.covered_lines}
134+
return trace.CoverageResults(counts=counts)
135+
127136
def need_rerun(self):
128137
return bool(self.rerun_results)
129138

Lib/test/libregrtest/run_workers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ def run_tmp_files(self, worker_runtests: RunTests,
277277
# Python finalization: too late for libregrtest.
278278
if not support.is_wasi:
279279
# Don't check for leaked temporary files and directories if Python is
280-
# run on WASI. WASI don't pass environment variables like TMPDIR to
280+
# run on WASI. WASI doesn't pass environment variables like TMPDIR to
281281
# worker processes.
282282
tmp_dir = tempfile.mkdtemp(prefix="test_python_")
283283
tmp_dir = os.path.abspath(tmp_dir)

Lib/test/libregrtest/runtests.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ class RunTests:
8585
hunt_refleak: HuntRefleak | None
8686
test_dir: StrPath | None
8787
use_junit: bool
88+
coverage: bool
8889
memory_limit: str | None
8990
gc_threshold: int | None
9091
use_resources: tuple[str, ...]

Lib/test/libregrtest/worker.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from typing import Any, NoReturn
55

66
from test import support
7-
from test.support import os_helper
7+
from test.support import os_helper, Py_DEBUG
88

99
from .setup import setup_process, setup_test_dir
1010
from .runtests import RunTests, JsonFile, JsonFileType
@@ -30,6 +30,8 @@ def create_worker_process(runtests: RunTests, output_fd: int,
3030
python_opts = [opt for opt in python_opts if opt != "-E"]
3131
else:
3232
executable = (sys.executable,)
33+
if runtests.coverage:
34+
python_opts.append("-Xpresite=test.cov")
3335
cmd = [*executable, *python_opts,
3436
'-u', # Unbuffered stdout and stderr
3537
'-m', 'test.libregrtest.worker',
@@ -87,6 +89,18 @@ def worker_process(worker_json: StrJSON) -> NoReturn:
8789
print(f"Re-running {test_name} in verbose mode", flush=True)
8890

8991
result = run_single_test(test_name, runtests)
92+
if runtests.coverage:
93+
if "test.cov" in sys.modules: # imported by -Xpresite=
94+
result.covered_lines = list(sys.modules["test.cov"].coverage)
95+
elif not Py_DEBUG:
96+
print(
97+
"Gathering coverage in worker processes requires --with-pydebug",
98+
flush=True,
99+
)
100+
else:
101+
raise LookupError(
102+
"`test.cov` not found in sys.modules but coverage wanted"
103+
)
90104

91105
if json_file.file_type == JsonFileType.STDOUT:
92106
print()

Lib/test/support/__init__.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1082,18 +1082,30 @@ def check_impl_detail(**guards):
10821082

10831083
def no_tracing(func):
10841084
"""Decorator to temporarily turn off tracing for the duration of a test."""
1085-
if not hasattr(sys, 'gettrace'):
1086-
return func
1087-
else:
1085+
trace_wrapper = func
1086+
if hasattr(sys, 'gettrace'):
10881087
@functools.wraps(func)
1089-
def wrapper(*args, **kwargs):
1088+
def trace_wrapper(*args, **kwargs):
10901089
original_trace = sys.gettrace()
10911090
try:
10921091
sys.settrace(None)
10931092
return func(*args, **kwargs)
10941093
finally:
10951094
sys.settrace(original_trace)
1096-
return wrapper
1095+
1096+
coverage_wrapper = trace_wrapper
1097+
if 'test.cov' in sys.modules: # -Xpresite=test.cov used
1098+
cov = sys.monitoring.COVERAGE_ID
1099+
@functools.wraps(func)
1100+
def coverage_wrapper(*args, **kwargs):
1101+
original_events = sys.monitoring.get_events(cov)
1102+
try:
1103+
sys.monitoring.set_events(cov, 0)
1104+
return trace_wrapper(*args, **kwargs)
1105+
finally:
1106+
sys.monitoring.set_events(cov, original_events)
1107+
1108+
return coverage_wrapper
10971109

10981110

10991111
def refcount_test(test):

0 commit comments

Comments
 (0)