Skip to content

Commit a8afe6e

Browse files
committed
pytest_plugin(feat): Add fixture performance optimization and profiling
why: Test suite was slow due to separate master copies for sync/async fixtures, no cross-session caching, and no visibility into fixture timing. what: - Add fixture profiling hooks with --fixture-durations and --fixture-durations-min - Unify master copy caching (sync/async share git_repo_master, hg_repo_master, etc.) - Add XDG persistent cache at ~/.cache/libvcs-test/<version-hash>/ - Add --libvcs-cache-dir and --libvcs-clear-cache pytest options - Add RepoFixtureResult[T] dataclass with metadata (from_cache, created_at, etc.) - Add @pytest.mark.performance marker with --run-performance option - Create tests/test_fixture_performance.py with 16 performance tests
1 parent ad76e1d commit a8afe6e

4 files changed

Lines changed: 757 additions & 86 deletions

File tree

conftest.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,130 @@
1010

1111
from __future__ import annotations
1212

13+
import time
1314
import typing as t
15+
from collections import defaultdict
1416

1517
import pytest
1618

1719
if t.TYPE_CHECKING:
1820
import pathlib
1921

22+
from _pytest.fixtures import FixtureDef, SubRequest
23+
from _pytest.terminal import TerminalReporter
24+
2025
pytest_plugins = ["pytester"]
2126

27+
# Fixture profiling storage
28+
_fixture_timings: dict[str, list[float]] = defaultdict(list)
29+
_fixture_call_counts: dict[str, int] = defaultdict(int)
30+
31+
32+
def pytest_addoption(parser: pytest.Parser) -> None:
33+
"""Add fixture profiling options."""
34+
group = parser.getgroup("libvcs", "libvcs fixture options")
35+
group.addoption(
36+
"--fixture-durations",
37+
action="store",
38+
type=int,
39+
default=0,
40+
metavar="N",
41+
help="Show N slowest fixture setup times (N=0 for all)",
42+
)
43+
group.addoption(
44+
"--fixture-durations-min",
45+
action="store",
46+
type=float,
47+
default=0.005,
48+
metavar="SECONDS",
49+
help="Minimum duration to show in fixture timing report (default: 0.005)",
50+
)
51+
group.addoption(
52+
"--run-performance",
53+
action="store_true",
54+
default=False,
55+
help="Run performance tests (marked with @pytest.mark.performance)",
56+
)
57+
58+
59+
def pytest_collection_modifyitems(
60+
config: pytest.Config,
61+
items: list[pytest.Item],
62+
) -> None:
63+
"""Skip performance tests unless --run-performance is given."""
64+
if config.getoption("--run-performance"):
65+
# --run-performance given: run all tests
66+
return
67+
68+
skip_performance = pytest.mark.skip(reason="need --run-performance option to run")
69+
for item in items:
70+
if "performance" in item.keywords:
71+
item.add_marker(skip_performance)
72+
73+
74+
@pytest.hookimpl(wrapper=True)
75+
def pytest_fixture_setup(
76+
fixturedef: FixtureDef[t.Any],
77+
request: SubRequest,
78+
) -> t.Generator[None, t.Any, t.Any]:
79+
"""Wrap fixture setup to measure timing."""
80+
start = time.perf_counter()
81+
try:
82+
result = yield
83+
return result
84+
finally:
85+
duration = time.perf_counter() - start
86+
fixture_name = fixturedef.argname
87+
_fixture_timings[fixture_name].append(duration)
88+
_fixture_call_counts[fixture_name] += 1
89+
90+
91+
def pytest_terminal_summary(
92+
terminalreporter: TerminalReporter,
93+
exitstatus: int,
94+
config: pytest.Config,
95+
) -> None:
96+
"""Display fixture timing summary."""
97+
durations_count = config.option.fixture_durations
98+
durations_min = config.option.fixture_durations_min
99+
100+
# Skip if no timing requested (durations_count defaults to 0 meaning "off")
101+
if durations_count == 0 and not config.option.verbose:
102+
return
103+
104+
# Build summary data
105+
fixture_stats: list[tuple[str, float, int, float]] = []
106+
for name, times in _fixture_timings.items():
107+
total_time = sum(times)
108+
call_count = len(times)
109+
avg_time = total_time / call_count if call_count > 0 else 0
110+
fixture_stats.append((name, total_time, call_count, avg_time))
111+
112+
# Sort by total time descending
113+
fixture_stats.sort(key=lambda x: x[1], reverse=True)
114+
115+
# Filter by minimum duration
116+
fixture_stats = [s for s in fixture_stats if s[1] >= durations_min]
117+
118+
if not fixture_stats:
119+
return
120+
121+
# Limit count if specified
122+
if durations_count > 0:
123+
fixture_stats = fixture_stats[:durations_count]
124+
125+
terminalreporter.write_sep("=", "fixture setup times")
126+
terminalreporter.write_line("")
127+
terminalreporter.write_line(
128+
f"{'Fixture':<40} {'Total':>10} {'Calls':>8} {'Avg':>10}",
129+
)
130+
terminalreporter.write_line("-" * 70)
131+
132+
for name, total, calls, avg in fixture_stats:
133+
terminalreporter.write_line(
134+
f"{name:<40} {total:>9.3f}s {calls:>8} {avg:>9.3f}s",
135+
)
136+
22137

23138
@pytest.fixture(autouse=True)
24139
def add_doctest_fixtures(

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,9 @@ filterwarnings = [
239239
]
240240
asyncio_mode = "strict"
241241
asyncio_default_fixture_loop_scope = "function"
242+
markers = [
243+
"performance: marks tests as performance tests (deselect with '-m \"not performance\"')",
244+
]
242245

243246
[tool.pytest-watcher]
244247
now = true

0 commit comments

Comments
 (0)