Skip to content

Commit 22d4d33

Browse files
committed
feat: support marker attributes to customize the walltime execution
1 parent 7723404 commit 22d4d33

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.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from pytest import Session
1414

1515
from pytest_codspeed.instruments import P, T
16-
from pytest_codspeed.plugin import CodSpeedConfig
16+
from pytest_codspeed.plugin import BenchmarkMarkerOptions, CodSpeedConfig
1717

1818
SUPPORTS_PERF_TRAMPOLINE = sys.version_info >= (3, 12)
1919

@@ -35,7 +35,7 @@ def __init__(self, config: CodSpeedConfig) -> None:
3535
def get_instrument_config_str_and_warns(self) -> tuple[str, list[str]]:
3636
config = (
3737
f"mode: instrumentation, "
38-
f"callgraph: {'enabled' if SUPPORTS_PERF_TRAMPOLINE else 'not supported'}"
38+
f"callgraph: {'enabled' if SUPPORTS_PERF_TRAMPOLINE else 'not supported'}"
3939
)
4040
warnings = []
4141
if not self.should_measure:
@@ -49,6 +49,7 @@ def get_instrument_config_str_and_warns(self) -> tuple[str, list[str]]:
4949

5050
def measure(
5151
self,
52+
marker_options: BenchmarkMarkerOptions,
5253
name: str,
5354
uri: str,
5455
fn: Callable[P, T],

src/pytest_codspeed/instruments/walltime.py

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,12 @@
2323
from pytest import Session
2424

2525
from pytest_codspeed.instruments import P, T
26-
from pytest_codspeed.plugin import CodSpeedConfig
26+
from pytest_codspeed.plugin import BenchmarkMarkerOptions, CodSpeedConfig
2727

2828
DEFAULT_WARMUP_TIME_NS = 1_000_000_000
2929
DEFAULT_MAX_TIME_NS = 3_000_000_000
3030
TIMER_RESOLUTION_NS = get_clock_info("perf_counter").resolution * 1e9
31-
DEFAULT_MIN_ROUND_TIME_NS = TIMER_RESOLUTION_NS * 1_000_000
31+
DEFAULT_MIN_ROUND_TIME_NS = int(TIMER_RESOLUTION_NS * 1_000_000)
3232

3333
IQR_OUTLIER_FACTOR = 1.5
3434
STDEV_OUTLIER_FACTOR = 3
@@ -42,16 +42,35 @@ class BenchmarkConfig:
4242
max_rounds: int | None
4343

4444
@classmethod
45-
def from_codspeed_config(cls, config: CodSpeedConfig) -> BenchmarkConfig:
45+
def from_codspeed_config_and_marker_data(
46+
cls, config: CodSpeedConfig, marker_data: BenchmarkMarkerOptions
47+
) -> BenchmarkConfig:
48+
if marker_data.max_time is not None:
49+
max_time_ns = int(marker_data.max_time * 1e9)
50+
elif config.max_time_ns is not None:
51+
max_time_ns = config.max_time_ns
52+
else:
53+
max_time_ns = DEFAULT_MAX_TIME_NS
54+
55+
if marker_data.max_rounds is not None:
56+
max_rounds = marker_data.max_rounds
57+
elif config.max_rounds is not None:
58+
max_rounds = config.max_rounds
59+
else:
60+
max_rounds = None
61+
62+
if marker_data.min_time is not None:
63+
min_round_time_ns = int(marker_data.min_time * 1e9)
64+
else:
65+
min_round_time_ns = DEFAULT_MIN_ROUND_TIME_NS
66+
4667
return cls(
4768
warmup_time_ns=config.warmup_time_ns
4869
if config.warmup_time_ns is not None
4970
else DEFAULT_WARMUP_TIME_NS,
50-
min_round_time_ns=DEFAULT_MIN_ROUND_TIME_NS,
51-
max_time_ns=config.max_time_ns
52-
if config.max_time_ns is not None
53-
else DEFAULT_MAX_TIME_NS,
54-
max_rounds=config.max_rounds,
71+
min_round_time_ns=min_round_time_ns,
72+
max_time_ns=max_time_ns,
73+
max_rounds=max_rounds,
5574
)
5675

5776

@@ -231,6 +250,7 @@ def get_instrument_config_str_and_warns(self) -> tuple[str, list[str]]:
231250

232251
def measure(
233252
self,
253+
marker_options: BenchmarkMarkerOptions,
234254
name: str,
235255
uri: str,
236256
fn: Callable[P, T],
@@ -244,7 +264,9 @@ def measure(
244264
fn=fn,
245265
args=args,
246266
kwargs=kwargs,
247-
config=BenchmarkConfig.from_codspeed_config(self.config),
267+
config=BenchmarkConfig.from_codspeed_config_and_marker_data(
268+
self.config, marker_options
269+
),
248270
)
249271
self.benchmarks.append(bench)
250272
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)