pythonasyncioapiasyncfastapiframeworkjsonjson-schemaopenapiopenapi3pydanticpython-typespython3redocreststarletteswaggerswagger-uiuvicornweb
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
205 lines
6.0 KiB
205 lines
6.0 KiB
"""OpenAPI schema generation profiling utilities."""
|
|
|
|
import functools
|
|
import threading
|
|
import time
|
|
from collections import defaultdict
|
|
from contextlib import contextmanager
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Callable, Optional, TypeVar
|
|
|
|
F = TypeVar("F", bound=Callable[..., Any])
|
|
|
|
|
|
@dataclass
|
|
class TimingEntry:
|
|
"""Single timing measurement entry."""
|
|
|
|
name: str
|
|
duration_ms: float
|
|
call_count: int = 1
|
|
metadata: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
@dataclass
|
|
class ProfilingStats:
|
|
"""Aggregated profiling statistics for a function."""
|
|
|
|
name: str
|
|
total_time_ms: float = 0.0
|
|
call_count: int = 0
|
|
min_time_ms: float = float("inf")
|
|
max_time_ms: float = 0.0
|
|
timings: list[float] = field(default_factory=list)
|
|
|
|
@property
|
|
def avg_time_ms(self) -> float:
|
|
return self.total_time_ms / self.call_count if self.call_count > 0 else 0.0
|
|
|
|
def add_timing(self, duration_ms: float) -> None:
|
|
self.total_time_ms += duration_ms
|
|
self.call_count += 1
|
|
self.min_time_ms = min(self.min_time_ms, duration_ms)
|
|
self.max_time_ms = max(self.max_time_ms, duration_ms)
|
|
self.timings.append(duration_ms)
|
|
|
|
|
|
class OpenAPIProfiler:
|
|
"""Thread-safe profiler for OpenAPI schema generation."""
|
|
|
|
def __init__(self) -> None:
|
|
self._enabled = False
|
|
self._stats: dict[str, ProfilingStats] = defaultdict(
|
|
lambda: ProfilingStats(name="")
|
|
)
|
|
self._lock = threading.Lock()
|
|
self._context_stack: list[str] = []
|
|
self._start_time: Optional[float] = None
|
|
|
|
def enable(self) -> None:
|
|
"""Enable profiling."""
|
|
with self._lock:
|
|
self._enabled = True
|
|
self._start_time = time.perf_counter()
|
|
|
|
def disable(self) -> None:
|
|
"""Disable profiling."""
|
|
with self._lock:
|
|
self._enabled = False
|
|
|
|
def reset(self) -> None:
|
|
"""Reset all collected statistics."""
|
|
with self._lock:
|
|
self._stats.clear()
|
|
self._context_stack.clear()
|
|
self._start_time = None
|
|
|
|
@property
|
|
def is_enabled(self) -> bool:
|
|
return self._enabled
|
|
|
|
def record(self, name: str, duration_ms: float) -> None:
|
|
"""Record a timing measurement."""
|
|
if not self._enabled:
|
|
return
|
|
with self._lock:
|
|
if name not in self._stats:
|
|
self._stats[name] = ProfilingStats(name=name)
|
|
self._stats[name].add_timing(duration_ms)
|
|
|
|
@contextmanager
|
|
def measure(self, name: str):
|
|
"""Context manager to measure execution time of a code block."""
|
|
if not self._enabled:
|
|
yield
|
|
return
|
|
|
|
start = time.perf_counter()
|
|
try:
|
|
yield
|
|
finally:
|
|
duration_ms = (time.perf_counter() - start) * 1000
|
|
self.record(name, duration_ms)
|
|
|
|
def get_stats(self) -> dict[str, ProfilingStats]:
|
|
"""Get a copy of current statistics."""
|
|
with self._lock:
|
|
return dict(self._stats)
|
|
|
|
def get_total_time_ms(self) -> float:
|
|
"""Get total elapsed time since profiling was enabled."""
|
|
if self._start_time is None:
|
|
return 0.0
|
|
return (time.perf_counter() - self._start_time) * 1000
|
|
|
|
def get_report(self) -> str:
|
|
"""Generate a human-readable profiling report."""
|
|
stats = self.get_stats()
|
|
if not stats:
|
|
return "No profiling data collected."
|
|
|
|
total_time = self.get_total_time_ms()
|
|
lines = [
|
|
"=" * 80,
|
|
"OpenAPI Schema Generation Profiling Report",
|
|
"=" * 80,
|
|
f"Total elapsed time: {total_time:.2f}ms",
|
|
"",
|
|
f"{'Function':<45} {'Calls':>6} {'Total':>10} {'Avg':>10} {'%':>6}",
|
|
"-" * 80,
|
|
]
|
|
|
|
sorted_stats = sorted(
|
|
stats.values(), key=lambda s: s.total_time_ms, reverse=True
|
|
)
|
|
|
|
for stat in sorted_stats:
|
|
pct = (stat.total_time_ms / total_time * 100) if total_time > 0 else 0
|
|
lines.append(
|
|
f"{stat.name:<45} {stat.call_count:>6} "
|
|
f"{stat.total_time_ms:>9.2f}ms {stat.avg_time_ms:>9.2f}ms {pct:>5.1f}%"
|
|
)
|
|
|
|
lines.append("=" * 80)
|
|
return "\n".join(lines)
|
|
|
|
def print_report(self) -> None:
|
|
"""Print the profiling report to stdout."""
|
|
print(self.get_report())
|
|
|
|
|
|
# Global profiler instance
|
|
openapi_profiler = OpenAPIProfiler()
|
|
|
|
|
|
def profiled(name: Optional[str] = None) -> Callable[[F], F]:
|
|
"""Decorator to instrument a function for profiling."""
|
|
|
|
def decorator(func: F) -> F:
|
|
func_name = name or f"{func.__module__}.{func.__qualname__}"
|
|
|
|
@functools.wraps(func)
|
|
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
if not openapi_profiler.is_enabled:
|
|
return func(*args, **kwargs)
|
|
|
|
start = time.perf_counter()
|
|
try:
|
|
return func(*args, **kwargs)
|
|
finally:
|
|
duration_ms = (time.perf_counter() - start) * 1000
|
|
openapi_profiler.record(func_name, duration_ms)
|
|
|
|
return wrapper # type: ignore[return-value]
|
|
|
|
return decorator
|
|
|
|
|
|
class ProfilingContext:
|
|
"""Context manager for scoped profiling sessions."""
|
|
|
|
def __init__(self, auto_print: bool = False) -> None:
|
|
self._auto_print = auto_print
|
|
self._profiler = openapi_profiler
|
|
|
|
def __enter__(self) -> "ProfilingContext":
|
|
self._profiler.reset()
|
|
self._profiler.enable()
|
|
return self
|
|
|
|
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
self._profiler.disable()
|
|
if self._auto_print:
|
|
self.print_report()
|
|
|
|
def get_stats(self) -> dict[str, ProfilingStats]:
|
|
return self._profiler.get_stats()
|
|
|
|
def get_report(self) -> str:
|
|
return self._profiler.get_report()
|
|
|
|
def print_report(self) -> None:
|
|
self._profiler.print_report()
|
|
|
|
def get_total_time_ms(self) -> float:
|
|
return self._profiler.get_total_time_ms()
|
|
|