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 ADDED
@@ -0,0 +1,6 @@
1
+ """RegScope: observed behavioral profiles for Python functions."""
2
+
3
+ from .api import TrackConfig, track
4
+
5
+ __all__ = ["TrackConfig", "__version__", "track"]
6
+ __version__ = "0.1.0.dev1"
regscope/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
@@ -0,0 +1,6 @@
1
+ """Public RegScope APIs."""
2
+
3
+ from .decorators import track
4
+ from .config import TrackConfig
5
+
6
+ __all__ = ["TrackConfig", "track"]
@@ -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)
@@ -0,0 +1,5 @@
1
+ """Command-line interface for RegScope."""
2
+
3
+ from .main import main
4
+
5
+ __all__ = ["main"]
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()