Skip to content

Commit d1d7be3

Browse files
committed
Benchmarks
1 parent 12aca58 commit d1d7be3

File tree

6 files changed

+508489
-0
lines changed

6 files changed

+508489
-0
lines changed

tests/bench/runner.py

Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Benchmark suite for openapi-spec-validator performance testing.
4+
5+
Usage:
6+
python runner.py --output results.json
7+
python runner.py --profile # Generates profile data
8+
"""
9+
10+
import argparse
11+
import cProfile
12+
import gc
13+
from io import StringIO
14+
import json
15+
import pstats
16+
import statistics
17+
import time
18+
from dataclasses import dataclass
19+
from functools import cached_property
20+
from pathlib import Path
21+
from typing import Any, Dict, Iterator, List, Optional
22+
23+
from jsonschema_path import SchemaPath
24+
25+
from openapi_spec_validator import validate
26+
from openapi_spec_validator.readers import read_from_filename
27+
from openapi_spec_validator.schemas import _FORCE_PYTHON, _FORCE_RUST
28+
from openapi_spec_validator.shortcuts import get_validator_cls
29+
30+
31+
@dataclass
32+
class BenchResult:
33+
spec_name: str
34+
spec_version: str
35+
spec_size_kb: float
36+
paths_count: int
37+
schemas_count: int
38+
repeats: int
39+
warmup: int
40+
seconds: List[float]
41+
success: bool
42+
error: Optional[str] = None
43+
44+
@cached_property
45+
def median_s(self) -> Optional[float]:
46+
if self.seconds:
47+
return statistics.median(self.seconds)
48+
return None
49+
50+
@cached_property
51+
def mean_s(self) -> Optional[float]:
52+
if self.seconds:
53+
return statistics.mean(self.seconds)
54+
return None
55+
56+
@cached_property
57+
def stdev_s(self) -> Optional[float]:
58+
if len(self.seconds) > 1:
59+
return statistics.pstdev(self.seconds)
60+
return None
61+
62+
@cached_property
63+
def validations_per_sec(self) -> Optional[float]:
64+
if self.median_s:
65+
return 1 / self.median_s
66+
return None
67+
68+
def as_dict(self) -> Dict[str, Any]:
69+
return {
70+
"spec_name": self.spec_name,
71+
"spec_version": self.spec_version,
72+
"spec_size_kb": self.spec_size_kb,
73+
"paths_count": self.paths_count,
74+
"schemas_count": self.schemas_count,
75+
"repeats": self.repeats,
76+
"warmup": self.warmup,
77+
"seconds": self.seconds,
78+
"median_s": self.median_s,
79+
"mean_s": self.mean_s,
80+
"stdev_s": self.stdev_s,
81+
"validations_per_sec": self.validations_per_sec,
82+
"success": self.success,
83+
"error": self.error,
84+
}
85+
86+
87+
def count_paths(spec: dict) -> int:
88+
"""Count paths in OpenAPI spec."""
89+
return len(spec.get("paths", {}))
90+
91+
92+
def count_schemas(spec: dict) -> int:
93+
"""Count schemas in OpenAPI spec."""
94+
components = spec.get("components", {})
95+
definitions = spec.get("definitions", {}) # OpenAPI 2.0
96+
return len(components.get("schemas", {})) + len(definitions)
97+
98+
99+
def get_spec_version(spec: dict) -> str:
100+
"""Detect OpenAPI version."""
101+
if "openapi" in spec:
102+
return spec["openapi"]
103+
elif "swagger" in spec:
104+
return spec["swagger"]
105+
return "unknown"
106+
107+
108+
def run_once(spec: dict) -> float:
109+
"""Run validation once and return elapsed time."""
110+
t0 = time.perf_counter()
111+
cls = get_validator_cls(spec)
112+
sp = SchemaPath.from_dict(spec)
113+
v = cls(sp)
114+
v.validate()
115+
# validate(spec)
116+
return time.perf_counter() - t0
117+
118+
119+
def benchmark_spec_file(
120+
spec_path: Path,
121+
repeats: int = 7,
122+
warmup: int = 2,
123+
no_gc: bool = False,
124+
) -> BenchResult:
125+
spec_name = spec_path.name
126+
spec_size_kb = spec_path.stat().st_size / 1024
127+
spec, _ = read_from_filename(str(spec_path))
128+
return benchmark_spec(
129+
spec, repeats, warmup, no_gc,
130+
spec_name=spec_name,
131+
spec_size_kb=spec_size_kb,
132+
)
133+
134+
135+
def benchmark_spec(
136+
spec: dict,
137+
repeats: int = 7,
138+
warmup: int = 2,
139+
no_gc: bool = False,
140+
profile: str | None = None,
141+
spec_name: str = "spec",
142+
spec_size_kb: float = 0,
143+
) -> BenchResult:
144+
"""Benchmark a single OpenAPI spec."""
145+
try:
146+
spec_version = get_spec_version(spec)
147+
paths_count = count_paths(spec)
148+
schemas_count = count_schemas(spec)
149+
print(f"⚡ Benchmarking {spec_name} spec (version {spec_version}, {paths_count} paths, {schemas_count} schemas)...")
150+
151+
if no_gc:
152+
gc.disable()
153+
154+
# Warmup
155+
for _ in range(warmup):
156+
run_once(spec)
157+
158+
if profile:
159+
print("\n🔬 Profiling mode enabled...")
160+
pr = cProfile.Profile()
161+
pr.enable()
162+
163+
# Actual benchmark
164+
seconds: List[float] = []
165+
for _ in range(repeats):
166+
seconds.append(run_once(spec))
167+
168+
if profile:
169+
pr.disable()
170+
171+
# Print profile stats
172+
s = StringIO()
173+
ps = pstats.Stats(pr, stream=s).sort_stats('cumulative')
174+
ps.print_stats(30)
175+
print(s.getvalue())
176+
177+
# Save profile data
178+
pr.dump_stats(profile)
179+
print(f"💾 Profile data saved to {profile}")
180+
print(f" View with: python -m pstats {profile}")
181+
182+
if no_gc:
183+
gc.enable()
184+
185+
return BenchResult(
186+
spec_name=spec_name,
187+
spec_version=spec_version,
188+
spec_size_kb=spec_size_kb,
189+
paths_count=paths_count,
190+
schemas_count=schemas_count,
191+
repeats=repeats,
192+
warmup=warmup,
193+
seconds=seconds,
194+
success=True,
195+
)
196+
197+
except Exception as e:
198+
return BenchResult(
199+
spec_name=spec_name,
200+
spec_version="unknown",
201+
spec_size_kb=spec_size_kb,
202+
paths_count=0,
203+
schemas_count=0,
204+
repeats=repeats,
205+
warmup=warmup,
206+
seconds=[],
207+
success=False,
208+
error=str(e),
209+
)
210+
211+
212+
def generate_synthetic_spec(paths: int, schemas: int, version: str = "3.0.0") -> dict:
213+
"""Generate synthetic OpenAPI spec for stress testing."""
214+
paths_obj = {}
215+
for i in range(paths):
216+
paths_obj[f"/resource/{i}"] = {
217+
"get": {
218+
"responses": {
219+
"200": {
220+
"description": "Success",
221+
"content": {
222+
"application/json": {
223+
"schema": {"$ref": f"#/components/schemas/Schema{i % schemas}"}
224+
}
225+
}
226+
}
227+
}
228+
}
229+
}
230+
231+
schemas_obj = {}
232+
for i in range(schemas):
233+
schemas_obj[f"Schema{i}"] = {
234+
"type": "object",
235+
"properties": {
236+
"id": {"type": "integer"},
237+
"name": {"type": "string"},
238+
"nested": {"$ref": f"#/components/schemas/Schema{(i + 1) % schemas}"}
239+
}
240+
}
241+
242+
return {
243+
"openapi": version,
244+
"info": {"title": f"Synthetic API ({paths} paths, {schemas} schemas)", "version": "1.0.0"},
245+
"paths": paths_obj,
246+
"components": {"schemas": schemas_obj}
247+
}
248+
249+
250+
def get_synthetic_specs_iterator(configs: List[tuple[int, int, str]]) -> Iterator[dict]:
251+
"""Iterator over synthetic specs based on provided configurations."""
252+
for paths, schemas, size in configs:
253+
spec = generate_synthetic_spec(paths, schemas)
254+
yield spec, f"synthetic_{size}", 0
255+
256+
257+
def get_specs_iterator(spec_files: List[Path]) -> Iterator[dict]:
258+
"""Iterator over provided spec files."""
259+
for spec_file in spec_files:
260+
spec, _ = read_from_filename(str(spec_file))
261+
yield spec, spec_file.name, spec_file.stat().st_size / 1024
262+
263+
264+
def main():
265+
parser = argparse.ArgumentParser(description="Benchmark openapi-spec-validator")
266+
parser.add_argument("specs", type=Path, nargs='*', help="File(s) with custom specs to benchmark, otherwise use synthetic specs.")
267+
parser.add_argument("--repeats", type=int, default=1, help="Number of benchmark repeats")
268+
parser.add_argument("--warmup", type=int, default=0, help="Number of warmup runs")
269+
parser.add_argument("--no-gc", action="store_true", help="Disable GC during benchmark")
270+
parser.add_argument("--output", type=str, help="Output JSON file path")
271+
parser.add_argument("--profile", type=str, help="Profile file path (cProfile)")
272+
args = parser.parse_args()
273+
274+
results: List[Dict[str, Any]] = []
275+
276+
print("Spec schema validator backend selection:")
277+
print(f" Force Python: {_FORCE_PYTHON}")
278+
print(f" Force Rust: {_FORCE_RUST}")
279+
280+
# Benchmark custom specs
281+
if args.specs:
282+
print(f"\n🔍 Testing with custom specs {[str(spec) for spec in args.specs]}")
283+
spec_iterator = get_specs_iterator(args.specs)
284+
285+
# Synthetic specs for stress testing
286+
else:
287+
print("\n🔍 Testing with synthetic specs")
288+
synthetic_configs = [
289+
(10, 5, "small"),
290+
(50, 20, "medium"),
291+
(200, 100, "large"),
292+
(500, 250, "xlarge"),
293+
]
294+
spec_iterator = get_synthetic_specs_iterator(synthetic_configs)
295+
296+
# Iterate over provided specs
297+
for spec, spec_name, spec_size_kb in spec_iterator:
298+
result = benchmark_spec(
299+
spec,
300+
repeats=args.repeats,
301+
warmup=args.warmup,
302+
no_gc=args.no_gc,
303+
profile=args.profile,
304+
spec_name=spec_name,
305+
spec_size_kb=spec_size_kb,
306+
)
307+
results.append(result.as_dict())
308+
if result.success:
309+
print(f" ✅ {result.median_s:.4f}s, {result.validations_per_sec:.2f} val/s")
310+
else:
311+
print(f" ❌ Error: {result.error}")
312+
313+
# Output results
314+
output = {
315+
"benchmark_config": {
316+
"repeats": args.repeats,
317+
"warmup": args.warmup,
318+
"no_gc": args.no_gc,
319+
},
320+
"results": results,
321+
}
322+
323+
print(f"\n📊 Summary: {len(results)} specs benchmarked")
324+
print(json.dumps(output, indent=2))
325+
326+
if args.output:
327+
with open(args.output, "w") as f:
328+
json.dump(output, f, indent=2)
329+
print(f"\n💾 Results saved to {args.output}")
330+
331+
332+
if __name__ == "__main__":
333+
main()

0 commit comments

Comments
 (0)