Skip to content

Commit a4ea7f9

Browse files
kenodegardart049
andauthored
feat: avoid overriding pytest's default protocol (#32)
* Ruff & ignore * Wrap runtest with instrumentation * Additional type annotations * Improve error handling * Update src/pytest_codspeed/plugin.py * fix plugin.lib typing issue * fix pytest-xdist detection --------- Co-authored-by: Arthur Pastel <[email protected]>
1 parent 4b7120c commit a4ea7f9

File tree

5 files changed

+113
-64
lines changed

5 files changed

+113
-64
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ repos:
1616
rev: v0.3.3
1717
hooks:
1818
- id: ruff
19+
args: [--fix]
1920
- id: ruff-format
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
dist_callgrind_wrapper.*
2+
build.lock

src/pytest_codspeed/plugin.py

Lines changed: 51 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import functools
34
import gc
45
import os
56
import pkgutil
@@ -16,11 +17,12 @@
1617
from ._wrapper import get_lib
1718

1819
if TYPE_CHECKING:
19-
from typing import Any, Callable, TypeVar
20+
from typing import Any, Callable, ParamSpec, TypeVar
2021

2122
from ._wrapper import LibType
2223

2324
T = TypeVar("T")
25+
P = ParamSpec("P")
2426

2527
IS_PYTEST_BENCHMARK_INSTALLED = pkgutil.find_loader("pytest_benchmark") is not None
2628
SUPPORTS_PERF_TRAMPOLINE = sys.version_info >= (3, 12)
@@ -172,86 +174,73 @@ def pytest_collection_modifyitems(
172174

173175
def _run_with_instrumentation(
174176
lib: LibType,
175-
nodeId: str,
177+
nodeid: str,
176178
config: pytest.Config,
177-
fn: Callable[..., Any],
178-
*args,
179-
**kwargs,
180-
):
179+
fn: Callable[P, T],
180+
*args: P.args,
181+
**kwargs: P.kwargs,
182+
) -> T:
181183
is_gc_enabled = gc.isenabled()
182184
if is_gc_enabled:
183185
gc.collect()
184186
gc.disable()
185187

186-
result = None
187-
188-
def __codspeed_root_frame__():
189-
nonlocal result
190-
result = fn(*args, **kwargs)
191-
192-
if SUPPORTS_PERF_TRAMPOLINE:
193-
# Warmup CPython performance map cache
194-
__codspeed_root_frame__()
195-
lib.zero_stats()
196-
lib.start_instrumentation()
197-
__codspeed_root_frame__()
198-
lib.stop_instrumentation()
199-
uri = get_git_relative_uri(nodeId, config.rootpath)
200-
lib.dump_stats_at(uri.encode("ascii"))
201-
if is_gc_enabled:
202-
gc.enable()
188+
def __codspeed_root_frame__() -> T:
189+
return fn(*args, **kwargs)
190+
191+
try:
192+
if SUPPORTS_PERF_TRAMPOLINE:
193+
# Warmup CPython performance map cache
194+
__codspeed_root_frame__()
195+
196+
lib.zero_stats()
197+
lib.start_instrumentation()
198+
try:
199+
return __codspeed_root_frame__()
200+
finally:
201+
# Ensure instrumentation is stopped even if the test failed
202+
lib.stop_instrumentation()
203+
uri = get_git_relative_uri(nodeid, config.rootpath)
204+
lib.dump_stats_at(uri.encode("ascii"))
205+
finally:
206+
# Ensure GC is re-enabled even if the test failed
207+
if is_gc_enabled:
208+
gc.enable()
209+
210+
211+
def wrap_runtest(
212+
lib: LibType,
213+
nodeid: str,
214+
config: pytest.Config,
215+
fn: Callable[P, T],
216+
) -> Callable[P, T]:
217+
@functools.wraps(fn)
218+
def wrapped(*args: P.args, **kwargs: P.kwargs) -> T:
219+
return _run_with_instrumentation(lib, nodeid, config, fn, *args, **kwargs)
203220

204-
return result
221+
return wrapped
205222

206223

207224
@pytest.hookimpl(tryfirst=True)
208225
def pytest_runtest_protocol(item: pytest.Item, nextitem: pytest.Item | None):
209226
plugin = get_plugin(item.config)
210227
if not plugin.is_codspeed_enabled or not should_benchmark_item(item):
211-
return (
212-
None # Defer to the default test protocol since no benchmarking is needed
213-
)
228+
# Defer to the default test protocol since no benchmarking is needed
229+
return None
214230

215231
if has_benchmark_fixture(item):
216-
return None # Instrumentation is handled by the fixture
232+
# Instrumentation is handled by the fixture
233+
return None
217234

218235
plugin.benchmark_count += 1
219236
if not plugin.should_measure:
220-
return None # Benchmark counted but will be run in the default protocol
221-
222-
# Setup phase
223-
reports = []
224-
ihook = item.ihook
225-
ihook.pytest_runtest_logstart(nodeid=item.nodeid, location=item.location)
226-
setup_call = pytest.CallInfo.from_call(
227-
lambda: ihook.pytest_runtest_setup(item=item, nextitem=nextitem), "setup"
228-
)
229-
setup_report = ihook.pytest_runtest_makereport(item=item, call=setup_call)
230-
ihook.pytest_runtest_logreport(report=setup_report)
231-
reports.append(setup_report)
232-
# Run phase
233-
if setup_report.passed and not item.config.getoption("setuponly"):
234-
assert plugin.lib is not None
235-
runtest_call = pytest.CallInfo.from_call(
236-
lambda: _run_with_instrumentation(
237-
plugin.lib, item.nodeid, item.config, item.runtest
238-
),
239-
"call",
240-
)
241-
runtest_report = ihook.pytest_runtest_makereport(item=item, call=runtest_call)
242-
ihook.pytest_runtest_logreport(report=runtest_report)
243-
reports.append(runtest_report)
244-
245-
# Teardown phase
246-
teardown_call = pytest.CallInfo.from_call(
247-
lambda: ihook.pytest_runtest_teardown(item=item, nextitem=nextitem), "teardown"
248-
)
249-
teardown_report = ihook.pytest_runtest_makereport(item=item, call=teardown_call)
250-
ihook.pytest_runtest_logreport(report=teardown_report)
251-
reports.append(teardown_report)
252-
ihook.pytest_runtest_logfinish(nodeid=item.nodeid, location=item.location)
237+
# Benchmark counted but will be run in the default protocol
238+
return None
253239

254-
return reports # Deny further protocol hooks execution
240+
# Wrap runtest and defer to default protocol
241+
assert plugin.lib is not None
242+
item.runtest = wrap_runtest(plugin.lib, item.nodeid, item.config, item.runtest)
243+
return None
255244

256245

257246
class BenchmarkFixture:

tests/conftest.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,10 @@
3737
skip_with_perf_trampoline = pytest.mark.skipif(
3838
IS_PERF_TRAMPOLINE_SUPPORTED, reason="perf trampoline is supported"
3939
)
40+
41+
# The name for the pytest-xdist plugin is just "xdist"
42+
IS_PYTEST_XDIST_INSTALLED = importlib.util.find_spec("xdist") is not None
43+
skip_without_pytest_xdist = pytest.mark.skipif(
44+
not IS_PYTEST_XDIST_INSTALLED,
45+
reason="pytest_xdist not installed",
46+
)

tests/test_pytest_plugin.py

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
skip_with_pytest_benchmark,
99
skip_without_perf_trampoline,
1010
skip_without_pytest_benchmark,
11+
skip_without_pytest_xdist,
1112
skip_without_valgrind,
1213
)
1314

@@ -289,12 +290,12 @@ def test_perf_maps_generation(pytester: pytest.Pytester, codspeed_env) -> None:
289290
290291
@pytest.mark.benchmark
291292
def test_some_addition_marked():
292-
return 1 + 1
293+
assert 1 + 1
293294
294295
def test_some_addition_fixtured(benchmark):
295296
@benchmark
296297
def fixtured_child():
297-
return 1 + 1
298+
assert 1 + 1
298299
"""
299300
)
300301
with codspeed_env():
@@ -324,6 +325,7 @@ def fixtured_child():
324325

325326
@skip_without_valgrind
326327
@skip_with_pytest_benchmark
328+
@skip_without_pytest_xdist
327329
def test_pytest_xdist_concurrency_compatibility(
328330
pytester: pytest.Pytester, codspeed_env
329331
) -> None:
@@ -346,3 +348,52 @@ def test_my_stuff(benchmark, i):
346348
result = pytester.runpytest("--codspeed", "-n", "128")
347349
assert result.ret == 0, "the run should have succeeded"
348350
result.stdout.fnmatch_lines(["*256 passed*"])
351+
352+
353+
@skip_without_valgrind
354+
def test_print(pytester: pytest.Pytester, codspeed_env) -> None:
355+
"""Test print statements are captured by pytest (i.e., not printed to terminal in
356+
the middle of the progress bar) and only displayed after test run (on failures)."""
357+
pytester.makepyfile(
358+
"""
359+
import pytest, sys
360+
361+
@pytest.mark.benchmark
362+
def test_print():
363+
print("print to stdout")
364+
print("print to stderr", file=sys.stderr)
365+
"""
366+
)
367+
with codspeed_env():
368+
result = pytester.runpytest("--codspeed")
369+
assert result.ret == 0, "the run should have succeeded"
370+
result.stdout.fnmatch_lines(["*1 benchmarked*"])
371+
result.stdout.no_fnmatch_line("*print to stdout*")
372+
result.stderr.no_fnmatch_line("*print to stderr*")
373+
374+
375+
@skip_without_valgrind
376+
def test_capsys(pytester: pytest.Pytester, codspeed_env) -> None:
377+
"""Test print statements are captured by capsys (i.e., not printed to terminal in
378+
the middle of the progress bar) and can be inspected within test."""
379+
pytester.makepyfile(
380+
"""
381+
import pytest, sys
382+
383+
@pytest.mark.benchmark
384+
def test_capsys(capsys):
385+
print("print to stdout")
386+
print("print to stderr", file=sys.stderr)
387+
388+
stdout, stderr = capsys.readouterr()
389+
390+
assert stdout == "print to stdout\\n"
391+
assert stderr == "print to stderr\\n"
392+
"""
393+
)
394+
with codspeed_env():
395+
result = pytester.runpytest("--codspeed")
396+
assert result.ret == 0, "the run should have succeeded"
397+
result.stdout.fnmatch_lines(["*1 benchmarked*"])
398+
result.stdout.no_fnmatch_line("*print to stdout*")
399+
result.stderr.no_fnmatch_line("*print to stderr*")

0 commit comments

Comments
 (0)