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.
@@ -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)
@@ -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
@@ -0,0 +1,9 @@
1
+ """Public RegScope exception types."""
2
+
3
+
4
+ class RegScopeError(Exception):
5
+ """Base class for expected RegScope errors."""
6
+
7
+
8
+ class MalformedRecordError(RegScopeError, ValueError):
9
+ """A persisted baseline or history record cannot be decoded safely."""
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,5 @@
1
+ """Persistence adapters for RegScope behavior profiles."""
2
+
3
+ from .json_store import BaselineStore
4
+
5
+ __all__ = ["BaselineStore"]
@@ -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)
@@ -0,0 +1,5 @@
1
+ """Historical behavior trend storage and summaries."""
2
+
3
+ from .history import HistoryStore, TrendPoint, TrendSummary
4
+
5
+ __all__ = ["HistoryStore", "TrendPoint", "TrendSummary"]