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,137 @@
1
+ """Append-only historical trend records for observed profiles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from dataclasses import dataclass
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from statistics import median
11
+ from typing import List, Optional, Union
12
+
13
+ from ..models import BehaviorProfile, SCHEMA_VERSION
14
+ from ..errors import MalformedRecordError
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class TrendPoint:
19
+ function: str
20
+ recorded_at: str
21
+ duration_ns: int
22
+ call_count: int
23
+ exceptions: int
24
+ fingerprint: str
25
+ schema_version: int = 1
26
+
27
+ @classmethod
28
+ def from_profile(
29
+ cls, profile: BehaviorProfile, recorded_at: Optional[str] = None
30
+ ) -> "TrendPoint":
31
+ timestamp = recorded_at or datetime.now(timezone.utc).isoformat()
32
+ return cls(
33
+ function=profile.function,
34
+ recorded_at=timestamp,
35
+ duration_ns=profile.duration_ns,
36
+ call_count=profile.call_count,
37
+ exceptions=profile.exceptions,
38
+ fingerprint=profile.fingerprint(),
39
+ )
40
+
41
+ def to_json(self) -> str:
42
+ return json.dumps(self.__dict__, sort_keys=True, separators=(",", ":"))
43
+
44
+ @classmethod
45
+ def from_json(cls, value: str) -> "TrendPoint":
46
+ data = json.loads(value)
47
+ data.setdefault("schema_version", 1)
48
+ if data["schema_version"] > SCHEMA_VERSION:
49
+ raise ValueError(f"unsupported history schema version {data['schema_version']}")
50
+ return cls(**data)
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class TrendSummary:
55
+ function: str
56
+ samples: int
57
+ duration_min_ns: int
58
+ duration_median_ns: int
59
+ duration_max_ns: int
60
+ duration_first_ns: int
61
+ duration_latest_ns: int
62
+ duration_delta_ns: int
63
+ duration_change_ratio: float
64
+ exception_samples: int
65
+ exception_rate: float
66
+
67
+
68
+ class HistoryStore:
69
+ """Store append-only trend points in one JSONL file per function."""
70
+
71
+ def __init__(self, directory: Union[str, Path], max_points: Optional[int] = None) -> None:
72
+ if max_points is not None and max_points < 1:
73
+ raise ValueError("max_points must be at least 1")
74
+ self.directory = Path(directory)
75
+ self.max_points = max_points
76
+
77
+ def path_for(self, function: str) -> Path:
78
+ digest = hashlib.sha256(function.encode("utf-8")).hexdigest()[:16]
79
+ return self.directory / f"{digest}.jsonl"
80
+
81
+ def record(self, profile: BehaviorProfile, recorded_at: Optional[str] = None) -> TrendPoint:
82
+ self.directory.mkdir(parents=True, exist_ok=True)
83
+ point = TrendPoint.from_profile(profile, recorded_at=recorded_at)
84
+ points = self.load(profile.function)
85
+ points.append(point)
86
+ if self.max_points is not None:
87
+ points = points[-self.max_points :]
88
+ temporary = self.path_for(profile.function).with_suffix(".tmp")
89
+ temporary.write_text(
90
+ "".join(item.to_json() + "\n" for item in points), encoding="utf-8"
91
+ )
92
+ temporary.replace(self.path_for(profile.function))
93
+ return point
94
+
95
+ def load(self, function: str) -> List[TrendPoint]:
96
+ path = self.path_for(function)
97
+ if not path.exists():
98
+ return []
99
+ try:
100
+ return [
101
+ TrendPoint.from_json(line)
102
+ for line in path.read_text(encoding="utf-8").splitlines()
103
+ if line.strip()
104
+ ]
105
+ except (OSError, TypeError, ValueError, KeyError) as error:
106
+ raise MalformedRecordError(
107
+ f"invalid history record at {path}: {error}"
108
+ ) from error
109
+
110
+ def summarize(self, function: str) -> TrendSummary:
111
+ points = self.load(function)
112
+ if not points:
113
+ raise ValueError("no historical samples for function")
114
+ durations = [point.duration_ns for point in points]
115
+ first_duration = durations[0]
116
+ latest_duration = durations[-1]
117
+ change_ratio = (
118
+ 0.0
119
+ if first_duration == 0 and latest_duration == 0
120
+ else float("inf")
121
+ if first_duration == 0
122
+ else (latest_duration - first_duration) / first_duration
123
+ )
124
+ exception_samples = sum(point.exceptions for point in points)
125
+ return TrendSummary(
126
+ function=function,
127
+ samples=len(points),
128
+ duration_min_ns=min(durations),
129
+ duration_median_ns=int(median(durations)),
130
+ duration_max_ns=max(durations),
131
+ duration_first_ns=first_duration,
132
+ duration_latest_ns=latest_duration,
133
+ duration_delta_ns=latest_duration - first_duration,
134
+ duration_change_ratio=change_ratio,
135
+ exception_samples=exception_samples,
136
+ exception_rate=exception_samples / len(points),
137
+ )
@@ -0,0 +1,214 @@
1
+ Metadata-Version: 2.4
2
+ Name: regscope
3
+ Version: 0.1.0
4
+ Summary: Observed behavioral diffing for Python functions
5
+ Author: RegScope contributors
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/AdeelMalik22/regscope
8
+ Project-URL: Repository, https://github.com/AdeelMalik22/regscope
9
+ Project-URL: Issues, https://github.com/AdeelMalik22/regscope/issues
10
+ Keywords: profiling,regression-testing,performance,observability
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Testing
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ Provides-Extra: dev
23
+ Requires-Dist: build; extra == "dev"
24
+ Requires-Dist: pytest; extra == "dev"
25
+ Provides-Extra: db
26
+ Requires-Dist: SQLAlchemy>=1.4; extra == "db"
27
+ Provides-Extra: http
28
+ Requires-Dist: requests>=2.28; extra == "http"
29
+ Provides-Extra: redis
30
+ Requires-Dist: redis>=4.0; extra == "redis"
31
+ Provides-Extra: integration
32
+ Requires-Dist: SQLAlchemy>=1.4; extra == "integration"
33
+ Requires-Dist: requests>=2.28; extra == "integration"
34
+ Requires-Dist: redis>=4.0; extra == "integration"
35
+
36
+ # RegScope
37
+
38
+ RegScope is a behavioral diffing library for Python functions. It records what
39
+ a function did during a controlled test run so behavior can be compared across
40
+ code versions.
41
+
42
+ RegScope does not prove that two functions are mathematically equivalent. It
43
+ reports differences in behavior observed during the runs that were recorded.
44
+
45
+ ## In plain language
46
+
47
+ RegScope is like a before-and-after checkup for a Python function. You place
48
+ `@track` above a function, run your normal tests, and RegScope quietly records
49
+ useful facts about that run: how long the function took, whether it failed,
50
+ which Python functions it called, and—when configured—how many database,
51
+ HTTP, or Redis operations it made.
52
+
53
+ When the function runs again after a code change, RegScope compares the new
54
+ checkup with earlier runs. It can warn you that a function became slower,
55
+ started raising errors, made more database queries, or used more memory. In
56
+ CI, that warning can fail the build so a performance or behavior regression is
57
+ noticed before the change is released.
58
+
59
+ It does not change what your function returns, and it does not decide whether
60
+ two implementations are mathematically identical. It compares what happened
61
+ during the test cases you actually ran. It also does not save function
62
+ arguments, return values, SQL text, URLs, request bodies, Redis keys, or Redis
63
+ values.
64
+
65
+ ## Current status
66
+
67
+ The early v0.1 core supports:
68
+
69
+ - Synchronous and asynchronous `@track` decoration
70
+ - Execution duration measurement
71
+ - Exception counting while preserving the original exception
72
+ - Flat call-graph counts using `sys.setprofile()`
73
+ - Structured JSON behavior profiles
74
+ - N-run JSON baselines with atomic file replacement
75
+ - Configurable baseline storage through `baseline_dir`
76
+ - Zero required runtime dependencies
77
+
78
+ ## Quick start
79
+
80
+ ```python
81
+ from regscope import track
82
+
83
+
84
+ @track
85
+ def calculate_total(values: list[int]) -> int:
86
+ return sum(values)
87
+
88
+
89
+ total = calculate_total([1, 2, 3])
90
+ profile = calculate_total.last_profile
91
+ print(total)
92
+ print(profile.duration_ns)
93
+ ```
94
+
95
+ Use `warmup_runs=N` to execute and report the first N calls without adding
96
+ them to the baseline or historical trend. This is useful when the first call
97
+ opens connections, imports modules, or initializes caches:
98
+
99
+ ```python
100
+ @track(baseline_dir=".regscope", warmup_runs=1)
101
+ def load_dashboard() -> int:
102
+ return 42
103
+ ```
104
+
105
+ The decorator supports both `@track` and `@track(...)`. Async functions are
106
+ supported transparently:
107
+
108
+ ```python
109
+ from regscope import track
110
+
111
+
112
+ @track
113
+ async def fetch_value() -> int:
114
+ return 42
115
+ ```
116
+
117
+ After a call, `function.get_current_profile()` and
118
+ `function.get_current_comparison()` return values stored in the current
119
+ `contextvars` context. These accessors are task-safe for concurrent async
120
+ invocations. The legacy `function.last_profile` and
121
+ `function.last_comparison` attributes remain available as compatibility
122
+ snapshots, but can be overwritten by another concurrent invocation.
123
+
124
+ Each tracked function writes a bounded JSON baseline to the configured
125
+ directory. The default directory is `.regscope`. Profiles contain structured
126
+ metrics and call counts; they do not capture function arguments or sensitive
127
+ external data.
128
+
129
+ ## CI regression checks
130
+
131
+ The repository workflow keeps trusted baselines outside Git. A push to
132
+ `master` runs `tests/ci_targets`, records the baseline in `.regscope`, and
133
+ uploads it as the `regscope-baseline-master` artifact. Pull-request jobs
134
+ download the latest successful artifact from `master` and compare the same
135
+ targets against it. A comparison that exceeds the configured threshold fails
136
+ the job.
137
+
138
+ Baseline artifacts are retained for 30 days. The workflow includes hidden
139
+ files when uploading because `.regscope` is a dot-directory. The baseline
140
+ publisher is restricted to trusted `master` pushes; pull requests cannot
141
+ replace the trusted artifact, including pull requests from forks.
142
+
143
+ To reproduce the comparison locally, first generate a trusted baseline and
144
+ then run the targets without the update flag:
145
+
146
+ ```bash
147
+ REGSCOPE_CI_BASELINE=1 REGSCOPE_TRUSTED_BASELINE=1 \
148
+ .venv/bin/python -m pytest tests/ci_targets \
149
+ --regscope-baseline-dir .regscope --regscope-update-baseline
150
+
151
+ REGSCOPE_CI_BASELINE=1 .venv/bin/python -m pytest tests/ci_targets \
152
+ --regscope-baseline-dir .regscope
153
+ ```
154
+
155
+ If no artifact has been published yet, the pull-request job reports that the
156
+ trusted baseline is unavailable and the comparison target fails rather than
157
+ silently treating the missing baseline as a pass.
158
+
159
+ ## Limitations
160
+
161
+ - `sys.setprofile()` has one active profiler per current thread. RegScope
162
+ restores the profiler observed at entry, and independent threads have
163
+ independent collection contexts. RegScope does not arbitrate nested owners
164
+ in one thread or an external profiler that replaces its hook during an
165
+ execution. Run tracked profiling in an isolated test context when coverage,
166
+ a debugger, or another profiler must remain active.
167
+ - HTTP and Redis collectors temporarily replace process-global library hooks.
168
+ They restore the hook that was present when attached, are thread-safe for
169
+ counting calls, and leave a newer hook installed by another tool untouched.
170
+ Only one RegScope collector may own each library hook at a time; a
171
+ conflicting attachment fails clearly instead of stacking wrappers.
172
+ - Profiles describe observed executions, not all possible behavior.
173
+ - The CI artifact workflow is validated on trusted `master` runs; a real
174
+ pull-request event is still required to exercise GitHub's fork permissions
175
+ and artifact-download path end to end.
176
+
177
+ ## Development
178
+
179
+ Install development dependencies and run the tests:
180
+
181
+ ```bash
182
+ .venv/bin/python -m pip install ".[dev]"
183
+ .venv/bin/python -m pytest
184
+ ```
185
+
186
+ The implementation roadmap is in [IMPLEMENTATION_PLAN.md](IMPLEMENTATION_PLAN.md).
187
+ Release history is in [CHANGELOG.md](CHANGELOG.md), with the release policy in
188
+ [VERSIONING.md](VERSIONING.md).
189
+ Performance and threshold guidance is in [docs/performance.md](docs/performance.md).
190
+
191
+ ## Privacy and measurements
192
+
193
+ Profiles contain aggregate timing, call, exception, collector, and memory
194
+ metrics. RegScope does not capture function arguments, return values, SQL
195
+ text, bound parameters, URLs, headers, request bodies, Redis keys, or Redis
196
+ values. The SHA-256 fingerprint is derived from the structured profile and is
197
+ not a substitute for the profile itself.
198
+
199
+ The core call profiler adds measurable overhead, especially for functions with
200
+ large call graphs. Run the local benchmark with:
201
+
202
+ ```bash
203
+ .venv/bin/python -m benchmarks.overhead
204
+ ```
205
+
206
+ Benchmark results depend on the machine and Python version. Use them to tune
207
+ thresholds for a project rather than treating the sample output as universal.
208
+
209
+ ## Schema stability
210
+
211
+ Structured profiles, baselines, and historical trend points currently use
212
+ schema version `1`. Older records that omit a schema field are read as version
213
+ 1. Records from a newer unsupported schema are rejected explicitly so they
214
+ cannot be silently misinterpreted.
@@ -0,0 +1,31 @@
1
+ regscope/__init__.py,sha256=yTEyGGXnVqKFz3ayzr9JMXaHPKUNc6Is2ktDM3Pth78,182
2
+ regscope/__main__.py,sha256=PSQ4rpL0dG6f-qH4N7H-gD9igQkdHzH4yVZDcW8lfZo,80
3
+ regscope/errors.py,sha256=QPc5aPyH6Ol3zyMuw4uUkPmoK61Yc-p-zsXt1_7xk7k,256
4
+ regscope/models.py,sha256=D6auI1c2x84cAFDkVjEY-rgwgnBV-kQX5rq07GzrKKw,3817
5
+ regscope/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ regscope/pytest_plugin.py,sha256=PKo4HS4VCtr6fxOVu4zcffi76V7yS65E1wULqtKcSew,2828
7
+ regscope/api/__init__.py,sha256=J_YBGRQots7HJefLK9fluSWIQ7iTm981MKq8SVqEhqs,127
8
+ regscope/api/collectors.py,sha256=URtYDr7nBPwlR9yAyiOCT1WdfnfatwDvpC2D4mruiME,1874
9
+ regscope/api/config.py,sha256=4t08tMDqnZvUA7acfsorv3rZVc9NAK-a9_2b2ftkTR0,828
10
+ regscope/api/decorators.py,sha256=wi0YrDHEQajqy2qUKn4YsGYKlu_q5dxdA-bubk-c7cw,8483
11
+ regscope/cli/__init__.py,sha256=wB9zjByokbjGKlUiJlvjfxD4EExlt4fPUbmgcvF9Shk,87
12
+ regscope/cli/main.py,sha256=NnFi0MUmuHmt0GEMPfhWYFVOH8eSILVR2EwONWsK9Lk,3307
13
+ regscope/cli/trend.py,sha256=9RGoWP8LORwk2pm2XBR_fN4izrBPWgoO8Hq7fDcEmEQ,835
14
+ regscope/collectors/__init__.py,sha256=l6dtxGUTWpXLOCM_5HKf1a7AZkhrpNV2oVPMMR35d8Q,336
15
+ regscope/collectors/call_graph.py,sha256=DZYT9oXDeHVqnogAM8l0wlQhygO52DAU8C0sDWpKiwQ,3225
16
+ regscope/collectors/http.py,sha256=fHLeV3aLycmpvI2MaJ7BhtkePGKftl-jRbuTJOyPWtQ,2608
17
+ regscope/collectors/memory.py,sha256=AKVu8gtBl2cTDwEd5Nz_h-usZGDMHMUlr1S8--afaZs,1183
18
+ regscope/collectors/redis.py,sha256=SspuaeP-nUjDEi6ViWQOBGtP5837-qFqDwI_6r0zoas,2579
19
+ regscope/collectors/sqlalchemy.py,sha256=lKWC6SdIA5nLNamXq4PzkAhypD9ErxBRjrjfoQdVhx8,1811
20
+ regscope/core/__init__.py,sha256=Y85Zqm_sVCvOk-hFcNIUvpiEou12Q5PBkKyfCnTjWx8,48
21
+ regscope/core/comparison.py,sha256=HnCefJIdY1TQjgRMYIoRU_v6BmAOJDOMwdRoXE6QFKM,3234
22
+ regscope/core/runtime.py,sha256=zBLv8zM58TNpjUKySFcFG4YrbiWml--V2zm2rgWWZD8,1495
23
+ regscope/storage/__init__.py,sha256=xI7bMZh2vC-tDarYzXhLsG2yGShW1omDLO7a-z8geBk,127
24
+ regscope/storage/json_store.py,sha256=g1QoSlxyltvcC5W0ttEtxUoFPY0FT7rKX_-LsDC9LZ8,3969
25
+ regscope/trends/__init__.py,sha256=3POzv_DpGyB8bzFftIYb5jPKZPSmthy6hjqkWxyVY5o,174
26
+ regscope/trends/history.py,sha256=sRXnTYGmLTt095XQrtaSfZSrbntkl9D2oWD6Z2s260s,4797
27
+ regscope-0.1.0.dist-info/METADATA,sha256=kbxyBZ4fqTHj8_CMp9nu8urnfI-3TEDARLMDjULQdaU,8416
28
+ regscope-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
29
+ regscope-0.1.0.dist-info/entry_points.txt,sha256=V9KbhoDNLgfrZ_oEjPFJWK2QWH9kCqcGDjB-mcMZ0SE,93
30
+ regscope-0.1.0.dist-info/top_level.txt,sha256=5IbDjUFWa_h70lGCByl23GIS1C5Y1CExkqWsKtIDZxo,9
31
+ regscope-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ regscope = regscope.cli:main
3
+
4
+ [pytest11]
5
+ regscope = regscope.pytest_plugin
@@ -0,0 +1 @@
1
+ regscope