regscope 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- regscope/__init__.py +6 -0
- regscope/__main__.py +5 -0
- regscope/api/__init__.py +6 -0
- regscope/api/collectors.py +50 -0
- regscope/api/config.py +27 -0
- regscope/api/decorators.py +227 -0
- regscope/cli/__init__.py +5 -0
- regscope/cli/main.py +80 -0
- regscope/cli/trend.py +22 -0
- regscope/collectors/__init__.py +14 -0
- regscope/collectors/call_graph.py +93 -0
- regscope/collectors/http.py +76 -0
- regscope/collectors/memory.py +41 -0
- regscope/collectors/redis.py +76 -0
- regscope/collectors/sqlalchemy.py +57 -0
- regscope/core/__init__.py +1 -0
- regscope/core/comparison.py +99 -0
- regscope/core/runtime.py +49 -0
- regscope/errors.py +9 -0
- regscope/models.py +112 -0
- regscope/py.typed +0 -0
- regscope/pytest_plugin.py +74 -0
- regscope/storage/__init__.py +5 -0
- regscope/storage/json_store.py +121 -0
- regscope/trends/__init__.py +5 -0
- regscope/trends/history.py +137 -0
- regscope-0.1.0.dist-info/METADATA +214 -0
- regscope-0.1.0.dist-info/RECORD +31 -0
- regscope-0.1.0.dist-info/WHEEL +5 -0
- regscope-0.1.0.dist-info/entry_points.txt +5 -0
- regscope-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Optional count-only instrumentation for redis-py commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from functools import wraps
|
|
6
|
+
from threading import Lock
|
|
7
|
+
from typing import Any, Callable, ClassVar, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RedisCollector:
|
|
11
|
+
"""Count redis-py commands without recording keys, values, or arguments.
|
|
12
|
+
|
|
13
|
+
The redis-py hook is process-global while attached. Do not attach multiple
|
|
14
|
+
Redis collectors concurrently; use one collector per tracked execution.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
_active_owner: ClassVar[Optional["RedisCollector"]] = None
|
|
18
|
+
_ownership_lock: ClassVar[Lock] = Lock()
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self._redis: Optional[Any] = None
|
|
22
|
+
self._original: Optional[Callable[..., Any]] = None
|
|
23
|
+
self._wrapped: Optional[Callable[..., Any]] = None
|
|
24
|
+
self._count = 0
|
|
25
|
+
self._lock = Lock()
|
|
26
|
+
|
|
27
|
+
def attach(self) -> None:
|
|
28
|
+
try:
|
|
29
|
+
import redis
|
|
30
|
+
except ImportError as error:
|
|
31
|
+
raise RuntimeError(
|
|
32
|
+
"Redis tracking requires the 'redis' extra: install regscope[redis]"
|
|
33
|
+
) from error
|
|
34
|
+
with self._ownership_lock:
|
|
35
|
+
if self._original is not None:
|
|
36
|
+
raise RuntimeError("collector is already attached")
|
|
37
|
+
if type(self)._active_owner is not None:
|
|
38
|
+
raise RuntimeError("another Redis collector is already attached")
|
|
39
|
+
|
|
40
|
+
original = redis.Redis.execute_command
|
|
41
|
+
|
|
42
|
+
@wraps(original)
|
|
43
|
+
def counted_command(client: Any, *args: Any, **kwargs: Any) -> Any:
|
|
44
|
+
with self._lock:
|
|
45
|
+
self._count += 1
|
|
46
|
+
return original(client, *args, **kwargs)
|
|
47
|
+
|
|
48
|
+
redis.Redis.execute_command = counted_command
|
|
49
|
+
self._redis = redis
|
|
50
|
+
self._original = original
|
|
51
|
+
self._wrapped = counted_command
|
|
52
|
+
type(self)._active_owner = self
|
|
53
|
+
|
|
54
|
+
def detach(self) -> None:
|
|
55
|
+
if (
|
|
56
|
+
self._redis is not None
|
|
57
|
+
and self._original is not None
|
|
58
|
+
and self._wrapped is not None
|
|
59
|
+
and self._redis.Redis.execute_command is self._wrapped
|
|
60
|
+
):
|
|
61
|
+
self._redis.Redis.execute_command = self._original
|
|
62
|
+
with self._ownership_lock:
|
|
63
|
+
if type(self)._active_owner is self:
|
|
64
|
+
type(self)._active_owner = None
|
|
65
|
+
self._redis = None
|
|
66
|
+
self._original = None
|
|
67
|
+
self._wrapped = None
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def command_count(self) -> int:
|
|
71
|
+
with self._lock:
|
|
72
|
+
return self._count
|
|
73
|
+
|
|
74
|
+
def reset(self) -> None:
|
|
75
|
+
with self._lock:
|
|
76
|
+
self._count = 0
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Optional SQLAlchemy query-count collector.
|
|
2
|
+
|
|
3
|
+
Only statement counts are recorded. SQL text, parameters, and result data are
|
|
4
|
+
intentionally never retained.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from threading import Lock
|
|
10
|
+
from typing import Any, Callable, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SQLAlchemyCollector:
|
|
14
|
+
"""Count SQLAlchemy cursor executions for one engine."""
|
|
15
|
+
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
self._event: Optional[Any] = None
|
|
18
|
+
self._engine: Optional[Any] = None
|
|
19
|
+
self._count = 0
|
|
20
|
+
self._lock = Lock()
|
|
21
|
+
|
|
22
|
+
def attach(self, engine: Any) -> None:
|
|
23
|
+
"""Attach to an SQLAlchemy engine, importing SQLAlchemy lazily."""
|
|
24
|
+
try:
|
|
25
|
+
from sqlalchemy import event
|
|
26
|
+
except ImportError as error:
|
|
27
|
+
raise RuntimeError(
|
|
28
|
+
"SQLAlchemy query tracking requires the 'db' extra: "
|
|
29
|
+
"install regscope[db]"
|
|
30
|
+
) from error
|
|
31
|
+
if self._engine is not None:
|
|
32
|
+
raise RuntimeError("collector is already attached")
|
|
33
|
+
event.listen(engine, "before_cursor_execute", self._before_cursor_execute)
|
|
34
|
+
self._event = event
|
|
35
|
+
self._engine = engine
|
|
36
|
+
|
|
37
|
+
def detach(self) -> None:
|
|
38
|
+
"""Remove the event listener if this collector is attached."""
|
|
39
|
+
if self._engine is not None and self._event is not None:
|
|
40
|
+
self._event.remove(
|
|
41
|
+
self._engine, "before_cursor_execute", self._before_cursor_execute
|
|
42
|
+
)
|
|
43
|
+
self._engine = None
|
|
44
|
+
self._event = None
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def query_count(self) -> int:
|
|
48
|
+
with self._lock:
|
|
49
|
+
return self._count
|
|
50
|
+
|
|
51
|
+
def reset(self) -> None:
|
|
52
|
+
with self._lock:
|
|
53
|
+
self._count = 0
|
|
54
|
+
|
|
55
|
+
def _before_cursor_execute(self, *args: Any, **kwargs: Any) -> None:
|
|
56
|
+
with self._lock:
|
|
57
|
+
self._count += 1
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core execution and comparison primitives."""
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Noise-aware comparison of observed behavior profiles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from statistics import median
|
|
7
|
+
from typing import Dict, Iterable, List, Mapping, Optional
|
|
8
|
+
|
|
9
|
+
from ..models import Baseline, BehaviorProfile
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
DEFAULT_THRESHOLD = 0.20
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class MetricComparison:
|
|
17
|
+
metric: str
|
|
18
|
+
current: float
|
|
19
|
+
baseline_median: float
|
|
20
|
+
delta: float
|
|
21
|
+
change_ratio: float
|
|
22
|
+
regression: bool
|
|
23
|
+
threshold: float
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ComparisonResult:
|
|
28
|
+
function: str
|
|
29
|
+
metrics: List[MetricComparison]
|
|
30
|
+
status: str = "compared"
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def regression(self) -> bool:
|
|
34
|
+
return any(metric.regression for metric in self.metrics)
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def risk(self) -> str:
|
|
38
|
+
regressions = sum(metric.regression for metric in self.metrics)
|
|
39
|
+
if regressions == 0:
|
|
40
|
+
return "none"
|
|
41
|
+
if any(metric.metric == "exceptions" and metric.regression for metric in self.metrics):
|
|
42
|
+
return "high"
|
|
43
|
+
return "medium" if regressions > 1 else "low"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def compare(
|
|
47
|
+
current: BehaviorProfile,
|
|
48
|
+
baseline: Baseline,
|
|
49
|
+
threshold: float = DEFAULT_THRESHOLD,
|
|
50
|
+
thresholds: Optional[Mapping[str, float]] = None,
|
|
51
|
+
) -> ComparisonResult:
|
|
52
|
+
"""Compare ``current`` with the baseline's observed metric distribution."""
|
|
53
|
+
if not 0 <= threshold < 1:
|
|
54
|
+
raise ValueError("threshold must be between 0 and 1")
|
|
55
|
+
if current.function != baseline.function:
|
|
56
|
+
raise ValueError("profile function does not match baseline function")
|
|
57
|
+
if not baseline.runs:
|
|
58
|
+
return ComparisonResult(function=current.function, metrics=[], status="baseline_missing")
|
|
59
|
+
|
|
60
|
+
metrics = []
|
|
61
|
+
for name in ("duration_ns", "call_count", "exceptions"):
|
|
62
|
+
current_value = float(getattr(current, name))
|
|
63
|
+
baseline_values = [float(getattr(run, name)) for run in baseline.runs]
|
|
64
|
+
baseline_median = float(median(baseline_values))
|
|
65
|
+
delta = current_value - baseline_median
|
|
66
|
+
change_ratio = _change_ratio(current_value, baseline_median)
|
|
67
|
+
metric_threshold = (thresholds or {}).get(name, threshold)
|
|
68
|
+
if not 0 <= metric_threshold < 1:
|
|
69
|
+
raise ValueError("metric thresholds must be between 0 and 1")
|
|
70
|
+
metrics.append(
|
|
71
|
+
MetricComparison(
|
|
72
|
+
metric=name,
|
|
73
|
+
current=current_value,
|
|
74
|
+
baseline_median=baseline_median,
|
|
75
|
+
delta=delta,
|
|
76
|
+
change_ratio=change_ratio,
|
|
77
|
+
regression=_is_regression(
|
|
78
|
+
current_value, baseline_median, metric_threshold, name
|
|
79
|
+
),
|
|
80
|
+
threshold=metric_threshold,
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
return ComparisonResult(function=current.function, metrics=metrics)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _change_ratio(current: float, baseline: float) -> float:
|
|
87
|
+
if baseline == 0:
|
|
88
|
+
return 0.0 if current == 0 else float("inf")
|
|
89
|
+
return (current - baseline) / baseline
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _is_regression(
|
|
93
|
+
current: float, baseline: float, threshold: float, metric: str
|
|
94
|
+
) -> bool:
|
|
95
|
+
if metric == "exceptions" and baseline == 0:
|
|
96
|
+
return current > 0
|
|
97
|
+
if baseline == 0:
|
|
98
|
+
return current > 0
|
|
99
|
+
return current > baseline * (1 + threshold)
|
regscope/core/runtime.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Low-level synchronous execution measurement."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Callable, Optional, Tuple, TypeVar
|
|
7
|
+
|
|
8
|
+
from ..models import BehaviorProfile
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def measure_sync(
|
|
15
|
+
function: Callable[..., T],
|
|
16
|
+
*args: Any,
|
|
17
|
+
profile_sink: Optional[Callable[[BehaviorProfile], None]] = None,
|
|
18
|
+
**kwargs: Any,
|
|
19
|
+
) -> Tuple[T, BehaviorProfile]:
|
|
20
|
+
"""Call ``function`` and return its result together with an observation.
|
|
21
|
+
|
|
22
|
+
Exceptions are counted and re-raised unchanged. ``profile_sink`` receives
|
|
23
|
+
the profile for both successful and exceptional calls, allowing callers to
|
|
24
|
+
persist the observation without changing the function's semantics.
|
|
25
|
+
"""
|
|
26
|
+
started = time.perf_counter_ns()
|
|
27
|
+
exception_count = 0
|
|
28
|
+
try:
|
|
29
|
+
result = function(*args, **kwargs)
|
|
30
|
+
except BaseException:
|
|
31
|
+
exception_count = 1
|
|
32
|
+
raise
|
|
33
|
+
finally:
|
|
34
|
+
duration_ns = time.perf_counter_ns() - started
|
|
35
|
+
observed = BehaviorProfile(
|
|
36
|
+
function=_function_name(function),
|
|
37
|
+
duration_ns=duration_ns,
|
|
38
|
+
exceptions=exception_count,
|
|
39
|
+
)
|
|
40
|
+
if profile_sink is not None:
|
|
41
|
+
profile_sink(observed)
|
|
42
|
+
|
|
43
|
+
return result, observed
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _function_name(function: Callable[..., Any]) -> str:
|
|
47
|
+
module = getattr(function, "__module__", "__main__")
|
|
48
|
+
qualified_name = getattr(function, "__qualname__", getattr(function, "__name__", "<callable>"))
|
|
49
|
+
return f"{module}.{qualified_name}"
|
regscope/errors.py
ADDED
regscope/models.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Structured records produced by RegScope collectors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import hashlib
|
|
7
|
+
from dataclasses import asdict, dataclass, field
|
|
8
|
+
from typing import Any, Dict, List, Mapping
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
SCHEMA_VERSION = 1
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _validate_schema(version: int) -> None:
|
|
15
|
+
if version > SCHEMA_VERSION:
|
|
16
|
+
raise ValueError(
|
|
17
|
+
f"unsupported schema version {version}; maximum supported is {SCHEMA_VERSION}"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _canonical_json(value: Mapping[str, Any]) -> str:
|
|
22
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class BehaviorProfile:
|
|
27
|
+
"""One observed execution of a tracked function."""
|
|
28
|
+
|
|
29
|
+
function: str
|
|
30
|
+
duration_ns: int
|
|
31
|
+
call_count: int = 0
|
|
32
|
+
exceptions: int = 0
|
|
33
|
+
call_graph: Dict[str, int] = field(default_factory=dict)
|
|
34
|
+
metrics: Dict[str, int] = field(default_factory=dict)
|
|
35
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
36
|
+
schema_version: int = SCHEMA_VERSION
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
39
|
+
"""Return a JSON-compatible structured representation."""
|
|
40
|
+
return asdict(self)
|
|
41
|
+
|
|
42
|
+
def to_json(self) -> str:
|
|
43
|
+
"""Serialize this profile deterministically."""
|
|
44
|
+
return _canonical_json(self.to_dict())
|
|
45
|
+
|
|
46
|
+
def fingerprint(self) -> str:
|
|
47
|
+
"""Return a derived SHA-256 checksum of the structured profile."""
|
|
48
|
+
return hashlib.sha256(self.to_json().encode("utf-8")).hexdigest()
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def from_dict(cls, data: Mapping[str, Any]) -> "BehaviorProfile":
|
|
52
|
+
"""Build a profile from a decoded JSON object."""
|
|
53
|
+
schema_version = int(data.get("schema_version", SCHEMA_VERSION))
|
|
54
|
+
_validate_schema(schema_version)
|
|
55
|
+
return cls(
|
|
56
|
+
function=str(data["function"]),
|
|
57
|
+
duration_ns=int(data["duration_ns"]),
|
|
58
|
+
call_count=int(data.get("call_count", 0)),
|
|
59
|
+
exceptions=int(data.get("exceptions", 0)),
|
|
60
|
+
call_graph={str(k): int(v) for k, v in data.get("call_graph", {}).items()},
|
|
61
|
+
metrics={str(k): int(v) for k, v in data.get("metrics", {}).items()},
|
|
62
|
+
metadata=dict(data.get("metadata", {})),
|
|
63
|
+
schema_version=schema_version,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def from_json(cls, value: str) -> "BehaviorProfile":
|
|
68
|
+
return cls.from_dict(json.loads(value))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class Baseline:
|
|
73
|
+
"""A bounded collection of observed profiles for one tracked function."""
|
|
74
|
+
|
|
75
|
+
function: str
|
|
76
|
+
runs: List[BehaviorProfile] = field(default_factory=list)
|
|
77
|
+
max_runs: int = 5
|
|
78
|
+
schema_version: int = SCHEMA_VERSION
|
|
79
|
+
|
|
80
|
+
def add(self, profile: BehaviorProfile) -> None:
|
|
81
|
+
if profile.function != self.function:
|
|
82
|
+
raise ValueError("profile function does not match baseline function")
|
|
83
|
+
if self.max_runs < 1:
|
|
84
|
+
raise ValueError("max_runs must be at least 1")
|
|
85
|
+
self.runs.append(profile)
|
|
86
|
+
del self.runs[:-self.max_runs]
|
|
87
|
+
|
|
88
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
89
|
+
return {
|
|
90
|
+
"function": self.function,
|
|
91
|
+
"runs": [profile.to_dict() for profile in self.runs],
|
|
92
|
+
"max_runs": self.max_runs,
|
|
93
|
+
"schema_version": self.schema_version,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
def to_json(self) -> str:
|
|
97
|
+
return _canonical_json(self.to_dict())
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def from_dict(cls, data: Mapping[str, Any]) -> "Baseline":
|
|
101
|
+
schema_version = int(data.get("schema_version", SCHEMA_VERSION))
|
|
102
|
+
_validate_schema(schema_version)
|
|
103
|
+
return cls(
|
|
104
|
+
function=str(data["function"]),
|
|
105
|
+
runs=[BehaviorProfile.from_dict(item) for item in data.get("runs", [])],
|
|
106
|
+
max_runs=int(data.get("max_runs", 5)),
|
|
107
|
+
schema_version=schema_version,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def from_json(cls, value: str) -> "Baseline":
|
|
112
|
+
return cls.from_dict(json.loads(value))
|
regscope/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Optional pytest integration for baseline comparisons."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Callable
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from .core.comparison import DEFAULT_THRESHOLD, ComparisonResult, compare
|
|
12
|
+
from .models import BehaviorProfile
|
|
13
|
+
from .storage import BaselineStore
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
17
|
+
group = parser.getgroup("regscope")
|
|
18
|
+
group.addoption(
|
|
19
|
+
"--regscope-baseline-dir",
|
|
20
|
+
action="store",
|
|
21
|
+
default=".regscope",
|
|
22
|
+
help="directory containing RegScope JSON baselines",
|
|
23
|
+
)
|
|
24
|
+
group.addoption(
|
|
25
|
+
"--regscope-threshold",
|
|
26
|
+
action="store",
|
|
27
|
+
type=float,
|
|
28
|
+
default=DEFAULT_THRESHOLD,
|
|
29
|
+
help="allowed relative increase before a regression is reported",
|
|
30
|
+
)
|
|
31
|
+
group.addoption(
|
|
32
|
+
"--regscope-update-baseline",
|
|
33
|
+
action="store_true",
|
|
34
|
+
help="update baselines only when REGSCOPE_TRUSTED_BASELINE=1",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.fixture
|
|
39
|
+
def regscope_compare(request: pytest.FixtureRequest) -> Callable[[BehaviorProfile], ComparisonResult]:
|
|
40
|
+
"""Return a helper that compares a profile and fails on regression."""
|
|
41
|
+
directory = Path(request.config.getoption("--regscope-baseline-dir"))
|
|
42
|
+
threshold = request.config.getoption("--regscope-threshold")
|
|
43
|
+
|
|
44
|
+
def compare_profile(profile: BehaviorProfile) -> ComparisonResult:
|
|
45
|
+
baseline = BaselineStore(directory).load(profile.function)
|
|
46
|
+
result = compare(profile, baseline, threshold=threshold)
|
|
47
|
+
if result.regression:
|
|
48
|
+
pytest.fail(f"RegScope behavioral regression detected for {profile.function}")
|
|
49
|
+
return result
|
|
50
|
+
|
|
51
|
+
return compare_profile
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.fixture
|
|
55
|
+
def regscope_record(request: pytest.FixtureRequest) -> Callable[[BehaviorProfile], ComparisonResult]:
|
|
56
|
+
"""Compare a profile and optionally record it in a trusted update run."""
|
|
57
|
+
directory = Path(request.config.getoption("--regscope-baseline-dir"))
|
|
58
|
+
threshold = request.config.getoption("--regscope-threshold")
|
|
59
|
+
update_requested = request.config.getoption("--regscope-update-baseline")
|
|
60
|
+
trusted = os.environ.get("REGSCOPE_TRUSTED_BASELINE") == "1"
|
|
61
|
+
store = BaselineStore(directory)
|
|
62
|
+
|
|
63
|
+
def record_profile(profile: BehaviorProfile) -> ComparisonResult:
|
|
64
|
+
baseline = store.load(profile.function)
|
|
65
|
+
result = compare(profile, baseline, threshold=threshold)
|
|
66
|
+
if result.status == "baseline_missing" and not (update_requested and trusted):
|
|
67
|
+
pytest.fail(f"RegScope baseline missing for {profile.function}")
|
|
68
|
+
if result.regression and not (update_requested and trusted):
|
|
69
|
+
pytest.fail(f"RegScope behavioral regression detected for {profile.function}")
|
|
70
|
+
if update_requested and trusted:
|
|
71
|
+
store.append(profile)
|
|
72
|
+
return result
|
|
73
|
+
|
|
74
|
+
return record_profile
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Atomic JSON persistence for behavior baselines."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
import threading
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import ClassVar, Dict, Iterator, Optional
|
|
12
|
+
|
|
13
|
+
from ..models import Baseline, BehaviorProfile
|
|
14
|
+
from ..errors import MalformedRecordError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BaselineStore:
|
|
18
|
+
"""Store one bounded baseline file per tracked function."""
|
|
19
|
+
|
|
20
|
+
_thread_locks: ClassVar[Dict[str, threading.RLock]] = {}
|
|
21
|
+
_thread_locks_guard: ClassVar[threading.Lock] = threading.Lock()
|
|
22
|
+
|
|
23
|
+
def __init__(self, directory: os.PathLike[str] | str, max_runs: int = 5) -> None:
|
|
24
|
+
if max_runs < 1:
|
|
25
|
+
raise ValueError("max_runs must be at least 1")
|
|
26
|
+
self.directory = Path(directory)
|
|
27
|
+
self.max_runs = max_runs
|
|
28
|
+
|
|
29
|
+
def path_for(self, function: str) -> Path:
|
|
30
|
+
digest = hashlib.sha256(function.encode("utf-8")).hexdigest()[:16]
|
|
31
|
+
return self.directory / f"{digest}.json"
|
|
32
|
+
|
|
33
|
+
def load(self, function: str) -> Baseline:
|
|
34
|
+
path = self.path_for(function)
|
|
35
|
+
if not path.exists():
|
|
36
|
+
return Baseline(function=function, max_runs=self.max_runs)
|
|
37
|
+
try:
|
|
38
|
+
return Baseline.from_json(path.read_text(encoding="utf-8"))
|
|
39
|
+
except (OSError, TypeError, ValueError, KeyError) as error:
|
|
40
|
+
raise MalformedRecordError(
|
|
41
|
+
f"invalid baseline record at {path}: {error}"
|
|
42
|
+
) from error
|
|
43
|
+
|
|
44
|
+
def save(self, baseline: Baseline) -> Path:
|
|
45
|
+
self.directory.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
destination = self.path_for(baseline.function)
|
|
47
|
+
temporary: Optional[str] = None
|
|
48
|
+
try:
|
|
49
|
+
with tempfile.NamedTemporaryFile(
|
|
50
|
+
mode="w", encoding="utf-8", dir=self.directory, delete=False
|
|
51
|
+
) as handle:
|
|
52
|
+
temporary = handle.name
|
|
53
|
+
handle.write(baseline.to_json())
|
|
54
|
+
handle.flush()
|
|
55
|
+
os.fsync(handle.fileno())
|
|
56
|
+
os.replace(temporary, destination)
|
|
57
|
+
finally:
|
|
58
|
+
if temporary is not None and os.path.exists(temporary):
|
|
59
|
+
os.unlink(temporary)
|
|
60
|
+
return destination
|
|
61
|
+
|
|
62
|
+
def append(self, profile: BehaviorProfile) -> Baseline:
|
|
63
|
+
with self._append_lock(profile.function):
|
|
64
|
+
baseline = self.load(profile.function)
|
|
65
|
+
baseline.max_runs = self.max_runs
|
|
66
|
+
baseline.add(profile)
|
|
67
|
+
self.save(baseline)
|
|
68
|
+
return baseline
|
|
69
|
+
|
|
70
|
+
@contextmanager
|
|
71
|
+
def _append_lock(self, function: str) -> Iterator[None]:
|
|
72
|
+
path = self.path_for(function)
|
|
73
|
+
with self._thread_lock(path):
|
|
74
|
+
self.directory.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
lock_path = path.with_suffix(".lock")
|
|
76
|
+
with lock_path.open("a+", encoding="utf-8") as lock_file:
|
|
77
|
+
lock_file.seek(0)
|
|
78
|
+
lock_file.write("0")
|
|
79
|
+
lock_file.flush()
|
|
80
|
+
lock_file.seek(0)
|
|
81
|
+
_lock_file(lock_file)
|
|
82
|
+
try:
|
|
83
|
+
yield
|
|
84
|
+
finally:
|
|
85
|
+
_unlock_file(lock_file)
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
@contextmanager
|
|
89
|
+
def _thread_lock(cls, path: Path) -> Iterator[None]:
|
|
90
|
+
key = str(path)
|
|
91
|
+
with cls._thread_locks_guard:
|
|
92
|
+
lock = cls._thread_locks.setdefault(key, threading.RLock())
|
|
93
|
+
with lock:
|
|
94
|
+
yield
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _lock_file(handle: object) -> None:
|
|
98
|
+
if os.name == "nt":
|
|
99
|
+
import msvcrt
|
|
100
|
+
|
|
101
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
|
|
102
|
+
return
|
|
103
|
+
try:
|
|
104
|
+
import fcntl
|
|
105
|
+
except ImportError:
|
|
106
|
+
return
|
|
107
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _unlock_file(handle: object) -> None:
|
|
111
|
+
if os.name == "nt":
|
|
112
|
+
import msvcrt
|
|
113
|
+
|
|
114
|
+
handle.seek(0)
|
|
115
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
116
|
+
return
|
|
117
|
+
try:
|
|
118
|
+
import fcntl
|
|
119
|
+
except ImportError:
|
|
120
|
+
return
|
|
121
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|