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
regscope/__init__.py
ADDED
regscope/__main__.py
ADDED
regscope/api/__init__.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Optional collector lifecycle for tracked executions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import ExitStack, contextmanager
|
|
6
|
+
from typing import Any, Dict, Iterator
|
|
7
|
+
|
|
8
|
+
from ..collectors import HTTPCollector, MemoryCollector, RedisCollector, SQLAlchemyCollector
|
|
9
|
+
from .config import TrackConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@contextmanager
|
|
13
|
+
def active_collectors(config: TrackConfig, metrics: Dict[str, int]) -> Iterator[None]:
|
|
14
|
+
"""Attach configured collectors and publish their metrics on exit."""
|
|
15
|
+
with ExitStack() as stack:
|
|
16
|
+
if config.sqlalchemy_engine is not None:
|
|
17
|
+
collector = SQLAlchemyCollector()
|
|
18
|
+
collector.attach(config.sqlalchemy_engine)
|
|
19
|
+
stack.callback(collector.detach)
|
|
20
|
+
metrics["db_queries"] = 0
|
|
21
|
+
stack.callback(lambda: metrics.update(db_queries=collector.query_count))
|
|
22
|
+
|
|
23
|
+
if config.collect_http:
|
|
24
|
+
collector = HTTPCollector()
|
|
25
|
+
collector.attach()
|
|
26
|
+
stack.callback(collector.detach)
|
|
27
|
+
metrics["http_requests"] = 0
|
|
28
|
+
stack.callback(lambda: metrics.update(http_requests=collector.request_count))
|
|
29
|
+
|
|
30
|
+
if config.collect_redis:
|
|
31
|
+
collector = RedisCollector()
|
|
32
|
+
collector.attach()
|
|
33
|
+
stack.callback(collector.detach)
|
|
34
|
+
metrics["redis_commands"] = 0
|
|
35
|
+
stack.callback(lambda: metrics.update(redis_commands=collector.command_count))
|
|
36
|
+
|
|
37
|
+
memory = None
|
|
38
|
+
if config.collect_memory:
|
|
39
|
+
memory = MemoryCollector()
|
|
40
|
+
stack.callback(
|
|
41
|
+
lambda: metrics.update(
|
|
42
|
+
memory_current_delta=memory.last_profile.current_delta,
|
|
43
|
+
memory_peak=memory.last_profile.peak,
|
|
44
|
+
)
|
|
45
|
+
if memory.last_profile is not None
|
|
46
|
+
else None
|
|
47
|
+
)
|
|
48
|
+
stack.enter_context(memory.measure())
|
|
49
|
+
|
|
50
|
+
yield
|
regscope/api/config.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Validated public configuration for tracked functions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from os import PathLike
|
|
7
|
+
from typing import Any, Union
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class TrackConfig:
|
|
12
|
+
"""Configuration shared by a tracked function's collectors and storage."""
|
|
13
|
+
|
|
14
|
+
baseline_dir: Union[PathLike[str], str] = ".regscope"
|
|
15
|
+
history_dir: Union[PathLike[str], str] = ".regscope"
|
|
16
|
+
max_runs: int = 5
|
|
17
|
+
warmup_runs: int = 0
|
|
18
|
+
sqlalchemy_engine: Any = None
|
|
19
|
+
collect_http: bool = False
|
|
20
|
+
collect_redis: bool = False
|
|
21
|
+
collect_memory: bool = False
|
|
22
|
+
|
|
23
|
+
def __post_init__(self) -> None:
|
|
24
|
+
if self.max_runs < 1:
|
|
25
|
+
raise ValueError("max_runs must be at least 1")
|
|
26
|
+
if self.warmup_runs < 0:
|
|
27
|
+
raise ValueError("warmup_runs must not be negative")
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Decorators for collecting observed function behavior."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from contextvars import ContextVar
|
|
7
|
+
from inspect import iscoroutinefunction
|
|
8
|
+
from functools import wraps
|
|
9
|
+
from os import PathLike
|
|
10
|
+
from typing import Any, Callable, Optional, TypeVar, Union, cast
|
|
11
|
+
from threading import Lock
|
|
12
|
+
|
|
13
|
+
from ..collectors.call_graph import collect_async_call_graph, collect_call_graph
|
|
14
|
+
from ..core.comparison import ComparisonResult, compare
|
|
15
|
+
from ..models import BehaviorProfile
|
|
16
|
+
from ..storage import BaselineStore
|
|
17
|
+
from ..trends import HistoryStore
|
|
18
|
+
from .config import TrackConfig
|
|
19
|
+
from .collectors import active_collectors
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
T = TypeVar("T")
|
|
23
|
+
Function = Callable[..., T]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def track(
|
|
27
|
+
function: Optional[Function[T]] = None,
|
|
28
|
+
*,
|
|
29
|
+
baseline_dir: Union[PathLike[str], str] = ".regscope",
|
|
30
|
+
history_dir: Union[PathLike[str], str] = ".regscope",
|
|
31
|
+
max_runs: int = 5,
|
|
32
|
+
warmup_runs: int = 0,
|
|
33
|
+
sqlalchemy_engine: Any = None,
|
|
34
|
+
collect_http: bool = False,
|
|
35
|
+
collect_redis: bool = False,
|
|
36
|
+
collect_memory: bool = False,
|
|
37
|
+
) -> Any:
|
|
38
|
+
"""Decorate a function with runtime collection and baseline persistence."""
|
|
39
|
+
config = TrackConfig(
|
|
40
|
+
baseline_dir=baseline_dir,
|
|
41
|
+
history_dir=history_dir,
|
|
42
|
+
max_runs=max_runs,
|
|
43
|
+
warmup_runs=warmup_runs,
|
|
44
|
+
sqlalchemy_engine=sqlalchemy_engine,
|
|
45
|
+
collect_http=collect_http,
|
|
46
|
+
collect_redis=collect_redis,
|
|
47
|
+
collect_memory=collect_memory,
|
|
48
|
+
)
|
|
49
|
+
if function is None:
|
|
50
|
+
return lambda wrapped: track(
|
|
51
|
+
wrapped,
|
|
52
|
+
baseline_dir=config.baseline_dir,
|
|
53
|
+
history_dir=config.history_dir,
|
|
54
|
+
max_runs=config.max_runs,
|
|
55
|
+
warmup_runs=config.warmup_runs,
|
|
56
|
+
sqlalchemy_engine=config.sqlalchemy_engine,
|
|
57
|
+
collect_http=config.collect_http,
|
|
58
|
+
collect_redis=config.collect_redis,
|
|
59
|
+
collect_memory=config.collect_memory,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if iscoroutinefunction(function):
|
|
63
|
+
return _track_async(function, config)
|
|
64
|
+
|
|
65
|
+
store = BaselineStore(config.baseline_dir, max_runs=config.max_runs)
|
|
66
|
+
history = HistoryStore(config.history_dir)
|
|
67
|
+
current_profile: ContextVar[Optional[BehaviorProfile]] = ContextVar(
|
|
68
|
+
"regscope_current_profile", default=None
|
|
69
|
+
)
|
|
70
|
+
current_comparison: ContextVar[Any] = ContextVar(
|
|
71
|
+
"regscope_current_comparison", default=None
|
|
72
|
+
)
|
|
73
|
+
warmups_remaining = config.warmup_runs
|
|
74
|
+
warmup_lock = Lock()
|
|
75
|
+
|
|
76
|
+
def consume_warmup() -> bool:
|
|
77
|
+
nonlocal warmups_remaining
|
|
78
|
+
with warmup_lock:
|
|
79
|
+
if warmups_remaining == 0:
|
|
80
|
+
return False
|
|
81
|
+
warmups_remaining -= 1
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
@wraps(function)
|
|
85
|
+
def wrapper(*args: Any, **kwargs: Any) -> T:
|
|
86
|
+
started = time.perf_counter_ns()
|
|
87
|
+
exception_count = 0
|
|
88
|
+
failure: Optional[BaseException] = None
|
|
89
|
+
graph = {}
|
|
90
|
+
metrics = {}
|
|
91
|
+
try:
|
|
92
|
+
try:
|
|
93
|
+
with active_collectors(config, metrics):
|
|
94
|
+
try:
|
|
95
|
+
result, graph = collect_call_graph(function, *args, **kwargs)
|
|
96
|
+
return result
|
|
97
|
+
except BaseException as error:
|
|
98
|
+
failure = error
|
|
99
|
+
raise
|
|
100
|
+
except BaseException as error:
|
|
101
|
+
if failure is None:
|
|
102
|
+
failure = error
|
|
103
|
+
raise
|
|
104
|
+
if failure is not None and error is not failure:
|
|
105
|
+
raise failure from error
|
|
106
|
+
raise
|
|
107
|
+
except BaseException as error:
|
|
108
|
+
exception_count = 1
|
|
109
|
+
failure = error
|
|
110
|
+
raise
|
|
111
|
+
finally:
|
|
112
|
+
try:
|
|
113
|
+
wrapper.last_profile = BehaviorProfile(
|
|
114
|
+
function=f"{function.__module__}.{function.__qualname__}",
|
|
115
|
+
duration_ns=time.perf_counter_ns() - started,
|
|
116
|
+
exceptions=exception_count,
|
|
117
|
+
call_graph=graph,
|
|
118
|
+
metrics=metrics,
|
|
119
|
+
)
|
|
120
|
+
current_profile.set(wrapper.last_profile)
|
|
121
|
+
if consume_warmup():
|
|
122
|
+
wrapper.last_comparison = ComparisonResult(
|
|
123
|
+
function=wrapper.last_profile.function,
|
|
124
|
+
metrics=[],
|
|
125
|
+
status="warmup",
|
|
126
|
+
)
|
|
127
|
+
else:
|
|
128
|
+
wrapper.last_comparison = compare(
|
|
129
|
+
wrapper.last_profile, store.load(wrapper.last_profile.function)
|
|
130
|
+
)
|
|
131
|
+
current_comparison.set(wrapper.last_comparison)
|
|
132
|
+
if wrapper.last_comparison.status != "warmup":
|
|
133
|
+
store.append(wrapper.last_profile)
|
|
134
|
+
history.record(wrapper.last_profile)
|
|
135
|
+
except BaseException:
|
|
136
|
+
if failure is None:
|
|
137
|
+
raise
|
|
138
|
+
|
|
139
|
+
wrapper.last_profile = None # type: ignore[attr-defined]
|
|
140
|
+
wrapper.last_comparison = None # type: ignore[attr-defined]
|
|
141
|
+
wrapper.get_current_profile = current_profile.get # type: ignore[attr-defined]
|
|
142
|
+
wrapper.get_current_comparison = current_comparison.get # type: ignore[attr-defined]
|
|
143
|
+
return cast(Function[T], wrapper)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _track_async(
|
|
147
|
+
function: Function[T], config: TrackConfig
|
|
148
|
+
) -> Any:
|
|
149
|
+
store = BaselineStore(config.baseline_dir, max_runs=config.max_runs)
|
|
150
|
+
history = HistoryStore(config.history_dir)
|
|
151
|
+
current_profile: ContextVar[Optional[BehaviorProfile]] = ContextVar(
|
|
152
|
+
"regscope_current_profile", default=None
|
|
153
|
+
)
|
|
154
|
+
current_comparison: ContextVar[Any] = ContextVar(
|
|
155
|
+
"regscope_current_comparison", default=None
|
|
156
|
+
)
|
|
157
|
+
warmups_remaining = config.warmup_runs
|
|
158
|
+
warmup_lock = Lock()
|
|
159
|
+
|
|
160
|
+
def consume_warmup() -> bool:
|
|
161
|
+
nonlocal warmups_remaining
|
|
162
|
+
with warmup_lock:
|
|
163
|
+
if warmups_remaining == 0:
|
|
164
|
+
return False
|
|
165
|
+
warmups_remaining -= 1
|
|
166
|
+
return True
|
|
167
|
+
|
|
168
|
+
@wraps(function)
|
|
169
|
+
async def wrapper(*args: Any, **kwargs: Any) -> T:
|
|
170
|
+
started = time.perf_counter_ns()
|
|
171
|
+
exception_count = 0
|
|
172
|
+
failure: Optional[BaseException] = None
|
|
173
|
+
graph = {}
|
|
174
|
+
metrics = {}
|
|
175
|
+
try:
|
|
176
|
+
try:
|
|
177
|
+
with active_collectors(config, metrics):
|
|
178
|
+
try:
|
|
179
|
+
result, graph = await collect_async_call_graph(function, *args, **kwargs)
|
|
180
|
+
return result
|
|
181
|
+
except BaseException as error:
|
|
182
|
+
failure = error
|
|
183
|
+
raise
|
|
184
|
+
except BaseException as error:
|
|
185
|
+
if failure is None:
|
|
186
|
+
failure = error
|
|
187
|
+
raise
|
|
188
|
+
if failure is not None and error is not failure:
|
|
189
|
+
raise failure from error
|
|
190
|
+
raise
|
|
191
|
+
except BaseException as error:
|
|
192
|
+
exception_count = 1
|
|
193
|
+
failure = error
|
|
194
|
+
raise
|
|
195
|
+
finally:
|
|
196
|
+
try:
|
|
197
|
+
wrapper.last_profile = BehaviorProfile(
|
|
198
|
+
function=f"{function.__module__}.{function.__qualname__}",
|
|
199
|
+
duration_ns=time.perf_counter_ns() - started,
|
|
200
|
+
exceptions=exception_count,
|
|
201
|
+
call_graph=graph,
|
|
202
|
+
metrics=metrics,
|
|
203
|
+
)
|
|
204
|
+
current_profile.set(wrapper.last_profile)
|
|
205
|
+
if consume_warmup():
|
|
206
|
+
wrapper.last_comparison = ComparisonResult(
|
|
207
|
+
function=wrapper.last_profile.function,
|
|
208
|
+
metrics=[],
|
|
209
|
+
status="warmup",
|
|
210
|
+
)
|
|
211
|
+
else:
|
|
212
|
+
wrapper.last_comparison = compare(
|
|
213
|
+
wrapper.last_profile, store.load(wrapper.last_profile.function)
|
|
214
|
+
)
|
|
215
|
+
current_comparison.set(wrapper.last_comparison)
|
|
216
|
+
if wrapper.last_comparison.status != "warmup":
|
|
217
|
+
store.append(wrapper.last_profile)
|
|
218
|
+
history.record(wrapper.last_profile)
|
|
219
|
+
except BaseException:
|
|
220
|
+
if failure is None:
|
|
221
|
+
raise
|
|
222
|
+
|
|
223
|
+
wrapper.last_profile = None # type: ignore[attr-defined]
|
|
224
|
+
wrapper.last_comparison = None # type: ignore[attr-defined]
|
|
225
|
+
wrapper.get_current_profile = current_profile.get # type: ignore[attr-defined]
|
|
226
|
+
wrapper.get_current_comparison = current_comparison.get # type: ignore[attr-defined]
|
|
227
|
+
return cast(Function[T], wrapper)
|
regscope/cli/__init__.py
ADDED
regscope/cli/main.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Argparse-based RegScope command line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import asdict
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import List, Optional, Sequence
|
|
10
|
+
|
|
11
|
+
from ..core.comparison import DEFAULT_THRESHOLD, compare
|
|
12
|
+
from ..models import Baseline, BehaviorProfile
|
|
13
|
+
from .trend import report
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
17
|
+
parser = argparse.ArgumentParser(prog="regscope")
|
|
18
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
19
|
+
compare_parser = subparsers.add_parser("compare", help="compare a profile to a baseline")
|
|
20
|
+
compare_parser.add_argument("--baseline", required=True, type=Path)
|
|
21
|
+
compare_parser.add_argument("--current", required=True, type=Path)
|
|
22
|
+
compare_parser.add_argument("--threshold", type=float, default=DEFAULT_THRESHOLD)
|
|
23
|
+
compare_parser.add_argument("--duration-threshold", type=float)
|
|
24
|
+
compare_parser.add_argument("--call-count-threshold", type=float)
|
|
25
|
+
compare_parser.add_argument("--exceptions-threshold", type=float)
|
|
26
|
+
compare_parser.add_argument("--json", action="store_true", dest="json_output")
|
|
27
|
+
trend_parser = subparsers.add_parser("trend", help="report historical behavior trends")
|
|
28
|
+
trend_parser.add_argument("--directory", type=Path, default=Path(".regscope"))
|
|
29
|
+
trend_parser.add_argument("--function", required=True)
|
|
30
|
+
trend_parser.add_argument("--json", action="store_true", dest="json_output")
|
|
31
|
+
return parser
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
35
|
+
args = build_parser().parse_args(argv)
|
|
36
|
+
if args.command == "compare":
|
|
37
|
+
thresholds = {
|
|
38
|
+
name: value
|
|
39
|
+
for name, value in {
|
|
40
|
+
"duration_ns": args.duration_threshold,
|
|
41
|
+
"call_count": args.call_count_threshold,
|
|
42
|
+
"exceptions": args.exceptions_threshold,
|
|
43
|
+
}.items()
|
|
44
|
+
if value is not None
|
|
45
|
+
}
|
|
46
|
+
return _compare(args.baseline, args.current, args.threshold, thresholds, args.json_output)
|
|
47
|
+
if args.command == "trend":
|
|
48
|
+
return report(args.directory, args.function, args.json_output)
|
|
49
|
+
return 2
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _compare(baseline_path: Path, current_path: Path, threshold: float, thresholds, json_output: bool) -> int:
|
|
53
|
+
baseline = Baseline.from_json(baseline_path.read_text(encoding="utf-8"))
|
|
54
|
+
current = BehaviorProfile.from_json(current_path.read_text(encoding="utf-8"))
|
|
55
|
+
result = compare(current, baseline, threshold=threshold, thresholds=thresholds)
|
|
56
|
+
|
|
57
|
+
if json_output:
|
|
58
|
+
output = asdict(result)
|
|
59
|
+
output["regression"] = result.regression
|
|
60
|
+
output["risk"] = result.risk
|
|
61
|
+
print(json.dumps(output, sort_keys=True))
|
|
62
|
+
return 1 if result.regression else 0
|
|
63
|
+
|
|
64
|
+
print("RegScope Comparison")
|
|
65
|
+
print()
|
|
66
|
+
print(result.function)
|
|
67
|
+
print(f" Status: {result.status}")
|
|
68
|
+
print(f" Risk: {result.risk}")
|
|
69
|
+
for metric in result.metrics:
|
|
70
|
+
marker = " REGRESSION" if metric.regression else ""
|
|
71
|
+
print(
|
|
72
|
+
f" {metric.metric}: {metric.baseline_median:g} -> "
|
|
73
|
+
f"{metric.current:g} ({metric.change_ratio:+.1%}){marker}"
|
|
74
|
+
)
|
|
75
|
+
print()
|
|
76
|
+
if result.regression:
|
|
77
|
+
print("Result: behavioral regression detected")
|
|
78
|
+
return 1
|
|
79
|
+
print("Result: no behavioral regression detected")
|
|
80
|
+
return 0
|
regscope/cli/trend.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Historical trend reporting for RegScope."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import asdict
|
|
6
|
+
|
|
7
|
+
from ..trends import HistoryStore
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def report(directory: Path, function: str, json_output: bool = False) -> int:
|
|
11
|
+
summary = HistoryStore(directory).summarize(function)
|
|
12
|
+
if json_output:
|
|
13
|
+
print(json.dumps(asdict(summary), sort_keys=True))
|
|
14
|
+
return 0
|
|
15
|
+
print("RegScope Trend")
|
|
16
|
+
print(function)
|
|
17
|
+
print(f" Samples: {summary.samples}")
|
|
18
|
+
print(f" Duration: {summary.duration_min_ns} -> {summary.duration_median_ns} -> {summary.duration_max_ns} ns")
|
|
19
|
+
print(f" Exception samples: {summary.exception_samples}")
|
|
20
|
+
print(f" Duration change: {summary.duration_delta_ns:+d} ns ({summary.duration_change_ratio:+.1%})")
|
|
21
|
+
print(f" Exception rate: {summary.exception_rate:.1%}")
|
|
22
|
+
return 0
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Optional and core behavior collectors."""
|
|
2
|
+
|
|
3
|
+
from .sqlalchemy import SQLAlchemyCollector
|
|
4
|
+
from .http import HTTPCollector
|
|
5
|
+
from .redis import RedisCollector
|
|
6
|
+
from .memory import MemoryCollector, MemoryProfile
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"HTTPCollector",
|
|
10
|
+
"MemoryCollector",
|
|
11
|
+
"MemoryProfile",
|
|
12
|
+
"RedisCollector",
|
|
13
|
+
"SQLAlchemyCollector",
|
|
14
|
+
]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Call-count collection using Python's process profiler hook."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from inspect import iscoroutinefunction
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from types import FrameType
|
|
9
|
+
from typing import Any, Callable, Dict, Optional, Tuple, TypeVar
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
T = TypeVar("T")
|
|
13
|
+
ProfileFunction = Callable[[FrameType, str, Any], Optional[Callable[..., Any]]]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def collect_call_graph(
|
|
17
|
+
function: Callable[..., T], *args: Any, **kwargs: Any
|
|
18
|
+
) -> Tuple[T, Dict[str, int]]:
|
|
19
|
+
"""Execute ``function`` while counting Python function-call events.
|
|
20
|
+
|
|
21
|
+
Any profiler that was installed before collection is called for each
|
|
22
|
+
event, and the previous profiler is restored even when execution raises.
|
|
23
|
+
The returned mapping is flat and keyed by qualified function name.
|
|
24
|
+
"""
|
|
25
|
+
counts: Counter[str] = Counter()
|
|
26
|
+
previous = sys.getprofile()
|
|
27
|
+
local_callbacks: Dict[int, ProfileFunction] = {}
|
|
28
|
+
|
|
29
|
+
def profiler(frame: FrameType, event: str, arg: Any) -> None:
|
|
30
|
+
if previous is not None:
|
|
31
|
+
if event == "call":
|
|
32
|
+
callback = previous(frame, event, arg)
|
|
33
|
+
if callback is not None:
|
|
34
|
+
local_callbacks[id(frame)] = callback
|
|
35
|
+
else:
|
|
36
|
+
callback = local_callbacks.get(id(frame))
|
|
37
|
+
if callback is not None:
|
|
38
|
+
callback(frame, event, arg)
|
|
39
|
+
if event == "call":
|
|
40
|
+
counts[_frame_name(frame)] += 1
|
|
41
|
+
elif event == "return":
|
|
42
|
+
local_callbacks.pop(id(frame), None)
|
|
43
|
+
|
|
44
|
+
sys.setprofile(profiler)
|
|
45
|
+
try:
|
|
46
|
+
result = function(*args, **kwargs)
|
|
47
|
+
finally:
|
|
48
|
+
if sys.getprofile() is profiler:
|
|
49
|
+
sys.setprofile(previous)
|
|
50
|
+
|
|
51
|
+
return result, dict(sorted(counts.items()))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def collect_async_call_graph(
|
|
55
|
+
function: Callable[..., T], *args: Any, **kwargs: Any
|
|
56
|
+
) -> Tuple[T, Dict[str, int]]:
|
|
57
|
+
"""Async equivalent of :func:`collect_call_graph`."""
|
|
58
|
+
if not iscoroutinefunction(function):
|
|
59
|
+
raise TypeError("collect_async_call_graph requires an async function")
|
|
60
|
+
|
|
61
|
+
counts: Counter[str] = Counter()
|
|
62
|
+
previous = sys.getprofile()
|
|
63
|
+
local_callbacks: Dict[int, ProfileFunction] = {}
|
|
64
|
+
|
|
65
|
+
def profiler(frame: FrameType, event: str, arg: Any) -> None:
|
|
66
|
+
if previous is not None:
|
|
67
|
+
if event == "call":
|
|
68
|
+
callback = previous(frame, event, arg)
|
|
69
|
+
if callback is not None:
|
|
70
|
+
local_callbacks[id(frame)] = callback
|
|
71
|
+
else:
|
|
72
|
+
callback = local_callbacks.get(id(frame))
|
|
73
|
+
if callback is not None:
|
|
74
|
+
callback(frame, event, arg)
|
|
75
|
+
if event == "call":
|
|
76
|
+
counts[_frame_name(frame)] += 1
|
|
77
|
+
elif event == "return":
|
|
78
|
+
local_callbacks.pop(id(frame), None)
|
|
79
|
+
|
|
80
|
+
sys.setprofile(profiler)
|
|
81
|
+
try:
|
|
82
|
+
result = await function(*args, **kwargs)
|
|
83
|
+
finally:
|
|
84
|
+
if sys.getprofile() is profiler:
|
|
85
|
+
sys.setprofile(previous)
|
|
86
|
+
|
|
87
|
+
return result, dict(sorted(counts.items()))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _frame_name(frame: FrameType) -> str:
|
|
91
|
+
module = frame.f_globals.get("__name__", "__main__")
|
|
92
|
+
qualified_name = getattr(frame.f_code, "co_qualname", frame.f_code.co_name)
|
|
93
|
+
return f"{module}.{qualified_name}"
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Optional count-only HTTP instrumentation for requests."""
|
|
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 HTTPCollector:
|
|
11
|
+
"""Count outgoing ``requests`` calls without recording request data.
|
|
12
|
+
|
|
13
|
+
The requests hook is process-global while attached. Do not attach multiple
|
|
14
|
+
HTTP collectors concurrently; use one collector per tracked execution.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
_active_owner: ClassVar[Optional["HTTPCollector"]] = None
|
|
18
|
+
_ownership_lock: ClassVar[Lock] = Lock()
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self._requests: 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 requests
|
|
30
|
+
except ImportError as error:
|
|
31
|
+
raise RuntimeError(
|
|
32
|
+
"HTTP tracking requires the 'http' extra: install regscope[http]"
|
|
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 HTTP collector is already attached")
|
|
39
|
+
|
|
40
|
+
original = requests.sessions.Session.request
|
|
41
|
+
|
|
42
|
+
@wraps(original)
|
|
43
|
+
def counted_request(session: Any, *args: Any, **kwargs: Any) -> Any:
|
|
44
|
+
with self._lock:
|
|
45
|
+
self._count += 1
|
|
46
|
+
return original(session, *args, **kwargs)
|
|
47
|
+
|
|
48
|
+
requests.sessions.Session.request = counted_request
|
|
49
|
+
self._requests = requests
|
|
50
|
+
self._original = original
|
|
51
|
+
self._wrapped = counted_request
|
|
52
|
+
type(self)._active_owner = self
|
|
53
|
+
|
|
54
|
+
def detach(self) -> None:
|
|
55
|
+
if (
|
|
56
|
+
self._requests is not None
|
|
57
|
+
and self._original is not None
|
|
58
|
+
and self._wrapped is not None
|
|
59
|
+
and self._requests.sessions.Session.request is self._wrapped
|
|
60
|
+
):
|
|
61
|
+
self._requests.sessions.Session.request = self._original
|
|
62
|
+
with self._ownership_lock:
|
|
63
|
+
if type(self)._active_owner is self:
|
|
64
|
+
type(self)._active_owner = None
|
|
65
|
+
self._requests = None
|
|
66
|
+
self._original = None
|
|
67
|
+
self._wrapped = None
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def request_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,41 @@
|
|
|
1
|
+
"""Memory measurements using the Python standard library."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tracemalloc
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Iterator, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class MemoryProfile:
|
|
13
|
+
"""Traced memory values in bytes for one measured region."""
|
|
14
|
+
|
|
15
|
+
current_delta: int
|
|
16
|
+
peak: int
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MemoryCollector:
|
|
20
|
+
"""Measure traced current allocation changes and peak memory."""
|
|
21
|
+
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
self.last_profile: Optional[MemoryProfile] = None
|
|
24
|
+
|
|
25
|
+
@contextmanager
|
|
26
|
+
def measure(self) -> Iterator[None]:
|
|
27
|
+
"""Measure allocations made inside the context."""
|
|
28
|
+
already_tracing = tracemalloc.is_tracing()
|
|
29
|
+
if not already_tracing:
|
|
30
|
+
tracemalloc.start()
|
|
31
|
+
started_current, _ = tracemalloc.get_traced_memory()
|
|
32
|
+
try:
|
|
33
|
+
yield
|
|
34
|
+
finally:
|
|
35
|
+
current, peak = tracemalloc.get_traced_memory()
|
|
36
|
+
self.last_profile = MemoryProfile(
|
|
37
|
+
current_delta=current - started_current,
|
|
38
|
+
peak=peak,
|
|
39
|
+
)
|
|
40
|
+
if not already_tracing:
|
|
41
|
+
tracemalloc.stop()
|