Skip to content

Commit 51d2adb

Browse files
committed
feat: support marker attributes to customize the walltime execution
1 parent c92304c commit 51d2adb

6 files changed

Lines changed: 166 additions & 43 deletions

File tree

src/pytest_codspeed/config.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from typing import TYPE_CHECKING
5+
6+
if TYPE_CHECKING:
7+
import pytest
8+
9+
10+
@dataclass(frozen=True)
11+
class CodSpeedConfig:
12+
"""
13+
The configuration for the codspeed plugin.
14+
Usually created from the command line arguments.
15+
"""
16+
17+
warmup_time_ns: int | None = None
18+
max_time_ns: int | None = None
19+
max_rounds: int | None = None
20+
21+
@classmethod
22+
def from_pytest_config(cls, config: pytest.Config) -> CodSpeedConfig:
23+
warmup_time = config.getoption("--codspeed-warmup-time", None)
24+
warmup_time_ns = (
25+
int(warmup_time * 1_000_000_000) if warmup_time is not None else None
26+
)
27+
max_time = config.getoption("--codspeed-max-time", None)
28+
max_time_ns = int(max_time * 1_000_000_000) if max_time is not None else None
29+
return cls(
30+
warmup_time_ns=warmup_time_ns,
31+
max_rounds=config.getoption("--codspeed-max-rounds", None),
32+
max_time_ns=max_time_ns,
33+
)
34+
35+
36+
@dataclass(frozen=True)
37+
class BenchmarkMarkerOptions:
38+
group: str | None = None
39+
"""The group name to use for the benchmark."""
40+
min_time: int | None = None
41+
"""
42+
The minimum time of a round (in seconds).
43+
Only available in walltime mode.
44+
"""
45+
max_time: int | None = None
46+
"""
47+
The maximum time to run the benchmark for (in seconds).
48+
Only available in walltime mode.
49+
"""
50+
max_rounds: int | None = None
51+
"""
52+
The maximum number of rounds to run the benchmark for.
53+
Takes precedence over max_time. Only available in walltime mode.
54+
"""
55+
56+
@classmethod
57+
def from_pytest_item(cls, item: pytest.Item) -> BenchmarkMarkerOptions:
58+
marker = item.get_closest_marker(
59+
"codspeed_benchmark"
60+
) or item.get_closest_marker("benchmark")
61+
if marker is None:
62+
return cls()
63+
if len(marker.args) > 0:
64+
raise ValueError(
65+
"Positional arguments are not allowed in the benchmark marker"
66+
)
67+
68+
options = cls(
69+
group=marker.kwargs.pop("group", None),
70+
min_time=marker.kwargs.pop("min_time", None),
71+
max_time=marker.kwargs.pop("max_time", None),
72+
max_rounds=marker.kwargs.pop("max_rounds", None),
73+
)
74+
75+
if len(marker.kwargs) > 0:
76+
raise ValueError(
77+
"Unknown kwargs passed to benchmark marker: "
78+
+ ", ".join(marker.kwargs.keys())
79+
)
80+
return options

src/pytest_codspeed/instruments/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import pytest
1111

12+
from pytest_codspeed.config import BenchmarkMarkerOptions
1213
from pytest_codspeed.plugin import CodSpeedConfig
1314

1415
T = TypeVar("T")
@@ -27,6 +28,7 @@ def get_instrument_config_str_and_warns(self) -> tuple[str, list[str]]: ...
2728
@abstractmethod
2829
def measure(
2930
self,
31+
marker_options: BenchmarkMarkerOptions,
3032
name: str,
3133
uri: str,
3234
fn: Callable[P, T],

src/pytest_codspeed/instruments/valgrind/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
from pytest_codspeed.instruments import P, T
1717
from pytest_codspeed.instruments.valgrind._wrapper import LibType
18-
from pytest_codspeed.plugin import CodSpeedConfig
18+
from pytest_codspeed.plugin import BenchmarkMarkerOptions, CodSpeedConfig
1919

2020
SUPPORTS_PERF_TRAMPOLINE = sys.version_info >= (3, 12)
2121

@@ -40,7 +40,7 @@ def __init__(self, config: CodSpeedConfig) -> None:
4040
def get_instrument_config_str_and_warns(self) -> tuple[str, list[str]]:
4141
config = (
4242
f"mode: instrumentation, "
43-
f"callgraph: {'enabled' if SUPPORTS_PERF_TRAMPOLINE else 'not supported'}"
43+
f"callgraph: {'enabled' if SUPPORTS_PERF_TRAMPOLINE else 'not supported'}"
4444
)
4545
warnings = []
4646
if not self.should_measure:
@@ -54,6 +54,7 @@ def get_instrument_config_str_and_warns(self) -> tuple[str, list[str]]:
5454

5555
def measure(
5656
self,
57+
marker_options: BenchmarkMarkerOptions,
5758
name: str,
5859
uri: str,
5960
fn: Callable[P, T],

src/pytest_codspeed/instruments/walltime.py

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,12 @@
1919
from pytest import Session
2020

2121
from pytest_codspeed.instruments import P, T
22-
from pytest_codspeed.plugin import CodSpeedConfig
22+
from pytest_codspeed.plugin import BenchmarkMarkerOptions, CodSpeedConfig
2323

2424
DEFAULT_WARMUP_TIME_NS = 1_000_000_000
2525
DEFAULT_MAX_TIME_NS = 3_000_000_000
2626
TIMER_RESOLUTION_NS = get_clock_info("perf_counter").resolution * 1e9
27-
DEFAULT_MIN_ROUND_TIME_NS = TIMER_RESOLUTION_NS * 1_000_000
27+
DEFAULT_MIN_ROUND_TIME_NS = int(TIMER_RESOLUTION_NS * 1_000_000)
2828

2929
IQR_OUTLIER_FACTOR = 1.5
3030
STDEV_OUTLIER_FACTOR = 3
@@ -38,16 +38,35 @@ class BenchmarkConfig:
3838
max_rounds: int | None
3939

4040
@classmethod
41-
def from_codspeed_config(cls, config: CodSpeedConfig) -> BenchmarkConfig:
41+
def from_codspeed_config_and_marker_data(
42+
cls, config: CodSpeedConfig, marker_data: BenchmarkMarkerOptions
43+
) -> BenchmarkConfig:
44+
if marker_data.max_time is not None:
45+
max_time_ns = int(marker_data.max_time * 1e9)
46+
elif config.max_time_ns is not None:
47+
max_time_ns = config.max_time_ns
48+
else:
49+
max_time_ns = DEFAULT_MAX_TIME_NS
50+
51+
if marker_data.max_rounds is not None:
52+
max_rounds = marker_data.max_rounds
53+
elif config.max_rounds is not None:
54+
max_rounds = config.max_rounds
55+
else:
56+
max_rounds = None
57+
58+
if marker_data.min_time is not None:
59+
min_round_time_ns = int(marker_data.min_time * 1e9)
60+
else:
61+
min_round_time_ns = DEFAULT_MIN_ROUND_TIME_NS
62+
4263
return cls(
4364
warmup_time_ns=config.warmup_time_ns
4465
if config.warmup_time_ns is not None
4566
else DEFAULT_WARMUP_TIME_NS,
46-
min_round_time_ns=DEFAULT_MIN_ROUND_TIME_NS,
47-
max_time_ns=config.max_time_ns
48-
if config.max_time_ns is not None
49-
else DEFAULT_MAX_TIME_NS,
50-
max_rounds=config.max_rounds,
67+
min_round_time_ns=min_round_time_ns,
68+
max_time_ns=max_time_ns,
69+
max_rounds=max_rounds,
5170
)
5271

5372

@@ -202,6 +221,7 @@ def get_instrument_config_str_and_warns(self) -> tuple[str, list[str]]:
202221

203222
def measure(
204223
self,
224+
marker_options: BenchmarkMarkerOptions,
205225
name: str,
206226
uri: str,
207227
fn: Callable[P, T],
@@ -214,7 +234,9 @@ def measure(
214234
fn=fn,
215235
args=args,
216236
kwargs=kwargs,
217-
config=BenchmarkConfig.from_codspeed_config(self.config),
237+
config=BenchmarkConfig.from_codspeed_config_and_marker_data(
238+
self.config, marker_options
239+
),
218240
)
219241
self.benchmarks.append(bench)
220242
return out

src/pytest_codspeed/plugin.py

Lines changed: 10 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import pytest
1515
from _pytest.fixtures import FixtureManager
1616

17+
from pytest_codspeed.config import BenchmarkMarkerOptions, CodSpeedConfig
1718
from pytest_codspeed.instruments import (
1819
MeasurementMode,
1920
get_instrument_from_mode,
@@ -58,8 +59,7 @@ def pytest_addoption(parser: pytest.Parser):
5859
action="store",
5960
type=float,
6061
help=(
61-
"The time to warm up the benchmark for (in seconds), "
62-
"only for walltime mode"
62+
"The time to warm up the benchmark for (in seconds), only for walltime mode"
6363
),
6464
)
6565
group.addoption(
@@ -82,27 +82,6 @@ def pytest_addoption(parser: pytest.Parser):
8282
)
8383

8484

85-
@dataclass(frozen=True)
86-
class CodSpeedConfig:
87-
warmup_time_ns: int | None = None
88-
max_time_ns: int | None = None
89-
max_rounds: int | None = None
90-
91-
@classmethod
92-
def from_pytest_config(cls, config: pytest.Config) -> CodSpeedConfig:
93-
warmup_time = config.getoption("--codspeed-warmup-time", None)
94-
warmup_time_ns = (
95-
int(warmup_time * 1_000_000_000) if warmup_time is not None else None
96-
)
97-
max_time = config.getoption("--codspeed-max-time", None)
98-
max_time_ns = int(max_time * 1_000_000_000) if max_time is not None else None
99-
return cls(
100-
warmup_time_ns=warmup_time_ns,
101-
max_rounds=config.getoption("--codspeed-max-rounds", None),
102-
max_time_ns=max_time_ns,
103-
)
104-
105-
10685
@dataclass(unsafe_hash=True)
10786
class CodSpeedPlugin:
10887
is_codspeed_enabled: bool
@@ -254,20 +233,21 @@ def pytest_collection_modifyitems(
254233

255234
def _measure(
256235
plugin: CodSpeedPlugin,
257-
nodeid: str,
236+
node: pytest.Item,
258237
config: pytest.Config,
259238
fn: Callable[P, T],
260239
*args: P.args,
261240
**kwargs: P.kwargs,
262241
) -> T:
242+
marker_options = BenchmarkMarkerOptions.from_pytest_item(node)
263243
random.seed(0)
264244
is_gc_enabled = gc.isenabled()
265245
if is_gc_enabled:
266246
gc.collect()
267247
gc.disable()
268248
try:
269-
uri, name = get_git_relative_uri_and_name(nodeid, config.rootpath)
270-
return plugin.instrument.measure(name, uri, fn, *args, **kwargs)
249+
uri, name = get_git_relative_uri_and_name(node.nodeid, config.rootpath)
250+
return plugin.instrument.measure(marker_options, name, uri, fn, *args, **kwargs)
271251
finally:
272252
# Ensure GC is re-enabled even if the test failed
273253
if is_gc_enabled:
@@ -276,13 +256,13 @@ def _measure(
276256

277257
def wrap_runtest(
278258
plugin: CodSpeedPlugin,
279-
nodeid: str,
259+
node: pytest.Item,
280260
config: pytest.Config,
281261
fn: Callable[P, T],
282262
) -> Callable[P, T]:
283263
@functools.wraps(fn)
284264
def wrapped(*args: P.args, **kwargs: P.kwargs) -> T:
285-
return _measure(plugin, nodeid, config, fn, *args, **kwargs)
265+
return _measure(plugin, node, config, fn, *args, **kwargs)
286266

287267
return wrapped
288268

@@ -299,7 +279,7 @@ def pytest_runtest_protocol(item: pytest.Item, nextitem: pytest.Item | None):
299279
return None
300280

301281
# Wrap runtest and defer to default protocol
302-
item.runtest = wrap_runtest(plugin, item.nodeid, item.config, item.runtest)
282+
item.runtest = wrap_runtest(plugin, item, item.config, item.runtest)
303283
return None
304284

305285

@@ -343,9 +323,7 @@ def __call__(self, func: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> T
343323
config = self._request.config
344324
plugin = get_plugin(config)
345325
if plugin.is_codspeed_enabled:
346-
return _measure(
347-
plugin, self._request.node.nodeid, config, func, *args, **kwargs
348-
)
326+
return _measure(plugin, self._request.node, config, func, *args, **kwargs)
349327
else:
350328
return func(*args, **kwargs)
351329

tests/test_pytest_plugin.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,46 @@ def _():
220220
)
221221

222222

223+
def test_codspeed_marker_unexpected_args(pytester: pytest.Pytester) -> None:
224+
pytester.makepyfile(
225+
"""
226+
import pytest
227+
228+
@pytest.mark.codspeed_benchmark(
229+
"positional_arg"
230+
)
231+
def test_bench():
232+
pass
233+
"""
234+
)
235+
result = pytester.runpytest("--codspeed")
236+
assert result.ret == 1
237+
result.stdout.fnmatch_lines_random(
238+
["*ValueError: Positional arguments are not allowed in the benchmark marker*"],
239+
)
240+
241+
242+
def test_codspeed_marker_unexpected_kwargs(pytester: pytest.Pytester) -> None:
243+
pytester.makepyfile(
244+
"""
245+
import pytest
246+
247+
@pytest.mark.codspeed_benchmark(
248+
not_allowed=True
249+
)
250+
def test_bench():
251+
pass
252+
"""
253+
)
254+
result = pytester.runpytest("--codspeed")
255+
assert result.ret == 1
256+
result.stdout.fnmatch_lines_random(
257+
[
258+
"*ValueError: Unknown kwargs passed to benchmark marker: not_allowed*",
259+
],
260+
)
261+
262+
223263
def test_pytest_benchmark_extra_info(pytester: pytest.Pytester) -> None:
224264
"""https://pytest-benchmark.readthedocs.io/en/latest/usage.html#extra-info"""
225265
pytester.makepyfile(

0 commit comments

Comments
 (0)