timetracer 1.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.
- timetracer/__init__.py +29 -0
- timetracer/cassette/__init__.py +6 -0
- timetracer/cassette/io.py +421 -0
- timetracer/cassette/naming.py +69 -0
- timetracer/catalog/__init__.py +288 -0
- timetracer/cli/__init__.py +5 -0
- timetracer/cli/commands/__init__.py +1 -0
- timetracer/cli/main.py +692 -0
- timetracer/config.py +297 -0
- timetracer/constants.py +129 -0
- timetracer/context.py +93 -0
- timetracer/dashboard/__init__.py +14 -0
- timetracer/dashboard/generator.py +229 -0
- timetracer/dashboard/server.py +244 -0
- timetracer/dashboard/template.py +874 -0
- timetracer/diff/__init__.py +6 -0
- timetracer/diff/engine.py +311 -0
- timetracer/diff/report.py +113 -0
- timetracer/exceptions.py +113 -0
- timetracer/integrations/__init__.py +27 -0
- timetracer/integrations/fastapi.py +537 -0
- timetracer/integrations/flask.py +507 -0
- timetracer/plugins/__init__.py +42 -0
- timetracer/plugins/base.py +73 -0
- timetracer/plugins/httpx_plugin.py +413 -0
- timetracer/plugins/redis_plugin.py +297 -0
- timetracer/plugins/requests_plugin.py +333 -0
- timetracer/plugins/sqlalchemy_plugin.py +280 -0
- timetracer/policies/__init__.py +16 -0
- timetracer/policies/capture.py +64 -0
- timetracer/policies/redaction.py +165 -0
- timetracer/replay/__init__.py +6 -0
- timetracer/replay/engine.py +75 -0
- timetracer/replay/errors.py +9 -0
- timetracer/replay/matching.py +83 -0
- timetracer/session.py +390 -0
- timetracer/storage/__init__.py +18 -0
- timetracer/storage/s3.py +364 -0
- timetracer/timeline/__init__.py +6 -0
- timetracer/timeline/generator.py +150 -0
- timetracer/timeline/template.py +370 -0
- timetracer/types.py +197 -0
- timetracer/utils/__init__.py +6 -0
- timetracer/utils/hashing.py +68 -0
- timetracer/utils/time.py +106 -0
- timetracer-1.1.0.dist-info/METADATA +286 -0
- timetracer-1.1.0.dist-info/RECORD +51 -0
- timetracer-1.1.0.dist-info/WHEEL +5 -0
- timetracer-1.1.0.dist-info/entry_points.txt +2 -0
- timetracer-1.1.0.dist-info/licenses/LICENSE +21 -0
- timetracer-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Diff engine for comparing cassettes.
|
|
3
|
+
|
|
4
|
+
Compares two cassettes and produces a detailed report of differences.
|
|
5
|
+
Useful for regression detection and debugging.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from timetracer.cassette import read_cassette
|
|
14
|
+
from timetracer.types import Cassette, DependencyEvent
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class EventDiff:
|
|
19
|
+
"""Difference between two events."""
|
|
20
|
+
event_index: int
|
|
21
|
+
event_type: str
|
|
22
|
+
|
|
23
|
+
# What changed
|
|
24
|
+
status_changed: bool = False
|
|
25
|
+
old_status: int | None = None
|
|
26
|
+
new_status: int | None = None
|
|
27
|
+
|
|
28
|
+
duration_changed: bool = False
|
|
29
|
+
old_duration_ms: float = 0.0
|
|
30
|
+
new_duration_ms: float = 0.0
|
|
31
|
+
duration_delta_ms: float = 0.0
|
|
32
|
+
duration_delta_pct: float = 0.0
|
|
33
|
+
|
|
34
|
+
url_changed: bool = False
|
|
35
|
+
old_url: str | None = None
|
|
36
|
+
new_url: str | None = None
|
|
37
|
+
|
|
38
|
+
body_changed: bool = False
|
|
39
|
+
|
|
40
|
+
# Is this a critical diff?
|
|
41
|
+
is_critical: bool = False
|
|
42
|
+
summary: str = ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class ResponseDiff:
|
|
47
|
+
"""Difference between response data."""
|
|
48
|
+
status_changed: bool = False
|
|
49
|
+
old_status: int = 0
|
|
50
|
+
new_status: int = 0
|
|
51
|
+
|
|
52
|
+
duration_changed: bool = False
|
|
53
|
+
old_duration_ms: float = 0.0
|
|
54
|
+
new_duration_ms: float = 0.0
|
|
55
|
+
duration_delta_ms: float = 0.0
|
|
56
|
+
duration_delta_pct: float = 0.0
|
|
57
|
+
|
|
58
|
+
body_changed: bool = False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class DiffReport:
|
|
63
|
+
"""Complete diff report between two cassettes."""
|
|
64
|
+
cassette_a_path: str
|
|
65
|
+
cassette_b_path: str
|
|
66
|
+
|
|
67
|
+
# Request info
|
|
68
|
+
method: str = ""
|
|
69
|
+
path: str = ""
|
|
70
|
+
|
|
71
|
+
# Overall result
|
|
72
|
+
has_differences: bool = False
|
|
73
|
+
is_regression: bool = False
|
|
74
|
+
|
|
75
|
+
# Response diff
|
|
76
|
+
response_diff: ResponseDiff = field(default_factory=ResponseDiff)
|
|
77
|
+
|
|
78
|
+
# Event diffs
|
|
79
|
+
event_count_a: int = 0
|
|
80
|
+
event_count_b: int = 0
|
|
81
|
+
event_count_changed: bool = False
|
|
82
|
+
event_diffs: list[EventDiff] = field(default_factory=list)
|
|
83
|
+
|
|
84
|
+
# Unmatched events
|
|
85
|
+
extra_events_a: list[int] = field(default_factory=list)
|
|
86
|
+
extra_events_b: list[int] = field(default_factory=list)
|
|
87
|
+
|
|
88
|
+
# Summary stats
|
|
89
|
+
total_duration_delta_ms: float = 0.0
|
|
90
|
+
critical_diffs: int = 0
|
|
91
|
+
|
|
92
|
+
def to_dict(self) -> dict[str, Any]:
|
|
93
|
+
"""Convert to dictionary for JSON serialization."""
|
|
94
|
+
return {
|
|
95
|
+
"cassette_a": self.cassette_a_path,
|
|
96
|
+
"cassette_b": self.cassette_b_path,
|
|
97
|
+
"request": {
|
|
98
|
+
"method": self.method,
|
|
99
|
+
"path": self.path,
|
|
100
|
+
},
|
|
101
|
+
"has_differences": self.has_differences,
|
|
102
|
+
"is_regression": self.is_regression,
|
|
103
|
+
"response": {
|
|
104
|
+
"status_changed": self.response_diff.status_changed,
|
|
105
|
+
"old_status": self.response_diff.old_status,
|
|
106
|
+
"new_status": self.response_diff.new_status,
|
|
107
|
+
"duration_delta_ms": self.response_diff.duration_delta_ms,
|
|
108
|
+
"duration_delta_pct": self.response_diff.duration_delta_pct,
|
|
109
|
+
},
|
|
110
|
+
"events": {
|
|
111
|
+
"count_a": self.event_count_a,
|
|
112
|
+
"count_b": self.event_count_b,
|
|
113
|
+
"count_changed": self.event_count_changed,
|
|
114
|
+
"diffs": [
|
|
115
|
+
{
|
|
116
|
+
"index": d.event_index,
|
|
117
|
+
"type": d.event_type,
|
|
118
|
+
"summary": d.summary,
|
|
119
|
+
"is_critical": d.is_critical,
|
|
120
|
+
}
|
|
121
|
+
for d in self.event_diffs
|
|
122
|
+
],
|
|
123
|
+
"extra_in_a": self.extra_events_a,
|
|
124
|
+
"extra_in_b": self.extra_events_b,
|
|
125
|
+
},
|
|
126
|
+
"summary": {
|
|
127
|
+
"total_duration_delta_ms": self.total_duration_delta_ms,
|
|
128
|
+
"critical_diffs": self.critical_diffs,
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def diff_cassettes(
|
|
134
|
+
path_a: str,
|
|
135
|
+
path_b: str,
|
|
136
|
+
*,
|
|
137
|
+
duration_threshold_pct: float = 20.0,
|
|
138
|
+
) -> DiffReport:
|
|
139
|
+
"""
|
|
140
|
+
Compare two cassettes and produce a diff report.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
path_a: Path to first cassette (baseline).
|
|
144
|
+
path_b: Path to second cassette (comparison).
|
|
145
|
+
duration_threshold_pct: Percentage change to flag as significant.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
DiffReport with all differences.
|
|
149
|
+
"""
|
|
150
|
+
cassette_a = read_cassette(path_a)
|
|
151
|
+
cassette_b = read_cassette(path_b)
|
|
152
|
+
|
|
153
|
+
report = DiffReport(
|
|
154
|
+
cassette_a_path=path_a,
|
|
155
|
+
cassette_b_path=path_b,
|
|
156
|
+
method=cassette_a.request.method,
|
|
157
|
+
path=cassette_a.request.path,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# Compare response
|
|
161
|
+
_compare_response(cassette_a, cassette_b, report, duration_threshold_pct)
|
|
162
|
+
|
|
163
|
+
# Compare events
|
|
164
|
+
_compare_events(cassette_a, cassette_b, report, duration_threshold_pct)
|
|
165
|
+
|
|
166
|
+
# Determine overall status
|
|
167
|
+
report.has_differences = (
|
|
168
|
+
report.response_diff.status_changed
|
|
169
|
+
or report.response_diff.duration_changed
|
|
170
|
+
or report.event_count_changed
|
|
171
|
+
or len(report.event_diffs) > 0
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# Is it a regression? (status worsened or significant slowdown)
|
|
175
|
+
report.is_regression = (
|
|
176
|
+
(report.response_diff.status_changed and report.response_diff.new_status >= 400)
|
|
177
|
+
or report.response_diff.duration_delta_pct > duration_threshold_pct
|
|
178
|
+
or report.critical_diffs > 0
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
return report
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _compare_response(
|
|
185
|
+
a: Cassette,
|
|
186
|
+
b: Cassette,
|
|
187
|
+
report: DiffReport,
|
|
188
|
+
threshold_pct: float,
|
|
189
|
+
) -> None:
|
|
190
|
+
"""Compare response data."""
|
|
191
|
+
res_a = a.response
|
|
192
|
+
res_b = b.response
|
|
193
|
+
|
|
194
|
+
diff = report.response_diff
|
|
195
|
+
|
|
196
|
+
# Status
|
|
197
|
+
if res_a.status != res_b.status:
|
|
198
|
+
diff.status_changed = True
|
|
199
|
+
diff.old_status = res_a.status
|
|
200
|
+
diff.new_status = res_b.status
|
|
201
|
+
|
|
202
|
+
# Duration
|
|
203
|
+
diff.old_duration_ms = res_a.duration_ms
|
|
204
|
+
diff.new_duration_ms = res_b.duration_ms
|
|
205
|
+
diff.duration_delta_ms = res_b.duration_ms - res_a.duration_ms
|
|
206
|
+
|
|
207
|
+
if res_a.duration_ms > 0:
|
|
208
|
+
diff.duration_delta_pct = (diff.duration_delta_ms / res_a.duration_ms) * 100
|
|
209
|
+
|
|
210
|
+
if abs(diff.duration_delta_pct) > threshold_pct:
|
|
211
|
+
diff.duration_changed = True
|
|
212
|
+
|
|
213
|
+
# Track total duration delta
|
|
214
|
+
report.total_duration_delta_ms = diff.duration_delta_ms
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _compare_events(
|
|
218
|
+
a: Cassette,
|
|
219
|
+
b: Cassette,
|
|
220
|
+
report: DiffReport,
|
|
221
|
+
threshold_pct: float,
|
|
222
|
+
) -> None:
|
|
223
|
+
"""Compare dependency events."""
|
|
224
|
+
events_a = a.events
|
|
225
|
+
events_b = b.events
|
|
226
|
+
|
|
227
|
+
report.event_count_a = len(events_a)
|
|
228
|
+
report.event_count_b = len(events_b)
|
|
229
|
+
report.event_count_changed = len(events_a) != len(events_b)
|
|
230
|
+
|
|
231
|
+
# Compare events pairwise
|
|
232
|
+
min_count = min(len(events_a), len(events_b))
|
|
233
|
+
|
|
234
|
+
for i in range(min_count):
|
|
235
|
+
event_a = events_a[i]
|
|
236
|
+
event_b = events_b[i]
|
|
237
|
+
|
|
238
|
+
diff = _compare_single_event(i, event_a, event_b, threshold_pct)
|
|
239
|
+
if diff:
|
|
240
|
+
report.event_diffs.append(diff)
|
|
241
|
+
if diff.is_critical:
|
|
242
|
+
report.critical_diffs += 1
|
|
243
|
+
|
|
244
|
+
# Track extra events
|
|
245
|
+
if len(events_a) > min_count:
|
|
246
|
+
report.extra_events_a = list(range(min_count, len(events_a)))
|
|
247
|
+
if len(events_b) > min_count:
|
|
248
|
+
report.extra_events_b = list(range(min_count, len(events_b)))
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _compare_single_event(
|
|
252
|
+
index: int,
|
|
253
|
+
a: DependencyEvent,
|
|
254
|
+
b: DependencyEvent,
|
|
255
|
+
threshold_pct: float,
|
|
256
|
+
) -> EventDiff | None:
|
|
257
|
+
"""Compare two events at the same index."""
|
|
258
|
+
diff = EventDiff(
|
|
259
|
+
event_index=index,
|
|
260
|
+
event_type=a.event_type.value,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
has_diff = False
|
|
264
|
+
summaries = []
|
|
265
|
+
|
|
266
|
+
# Status change
|
|
267
|
+
if a.result.status != b.result.status:
|
|
268
|
+
diff.status_changed = True
|
|
269
|
+
diff.old_status = a.result.status
|
|
270
|
+
diff.new_status = b.result.status
|
|
271
|
+
has_diff = True
|
|
272
|
+
summaries.append(f"status: {a.result.status} → {b.result.status}")
|
|
273
|
+
|
|
274
|
+
# Critical if went from success to error
|
|
275
|
+
if (a.result.status or 0) < 400 and (b.result.status or 0) >= 400:
|
|
276
|
+
diff.is_critical = True
|
|
277
|
+
|
|
278
|
+
# Duration change
|
|
279
|
+
diff.old_duration_ms = a.duration_ms
|
|
280
|
+
diff.new_duration_ms = b.duration_ms
|
|
281
|
+
diff.duration_delta_ms = b.duration_ms - a.duration_ms
|
|
282
|
+
|
|
283
|
+
if a.duration_ms > 0:
|
|
284
|
+
diff.duration_delta_pct = (diff.duration_delta_ms / a.duration_ms) * 100
|
|
285
|
+
|
|
286
|
+
if abs(diff.duration_delta_pct) > threshold_pct:
|
|
287
|
+
diff.duration_changed = True
|
|
288
|
+
has_diff = True
|
|
289
|
+
direction = "slower" if diff.duration_delta_ms > 0 else "faster"
|
|
290
|
+
summaries.append(f"{abs(diff.duration_delta_pct):.0f}% {direction}")
|
|
291
|
+
|
|
292
|
+
# URL change
|
|
293
|
+
if a.signature.url != b.signature.url:
|
|
294
|
+
diff.url_changed = True
|
|
295
|
+
diff.old_url = a.signature.url
|
|
296
|
+
diff.new_url = b.signature.url
|
|
297
|
+
diff.is_critical = True
|
|
298
|
+
has_diff = True
|
|
299
|
+
summaries.append("URL changed")
|
|
300
|
+
|
|
301
|
+
# Body change (by hash)
|
|
302
|
+
if a.signature.body_hash != b.signature.body_hash:
|
|
303
|
+
diff.body_changed = True
|
|
304
|
+
has_diff = True
|
|
305
|
+
summaries.append("request body changed")
|
|
306
|
+
|
|
307
|
+
if not has_diff:
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
diff.summary = "; ".join(summaries)
|
|
311
|
+
return diff
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Diff report formatting.
|
|
3
|
+
|
|
4
|
+
Provides human-readable output for diff reports.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from timetracer.diff.engine import DiffReport
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def format_diff_report(report: DiffReport, use_color: bool = True) -> str:
|
|
13
|
+
"""
|
|
14
|
+
Format a diff report as human-readable text.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
report: The diff report to format.
|
|
18
|
+
use_color: Whether to include ANSI color codes.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
Formatted string.
|
|
22
|
+
"""
|
|
23
|
+
lines = []
|
|
24
|
+
|
|
25
|
+
# Header
|
|
26
|
+
lines.append("")
|
|
27
|
+
lines.append("=" * 70)
|
|
28
|
+
lines.append("timetracer DIFF REPORT")
|
|
29
|
+
lines.append("=" * 70)
|
|
30
|
+
lines.append("")
|
|
31
|
+
|
|
32
|
+
# Files
|
|
33
|
+
lines.append(f"Baseline: {report.cassette_a_path}")
|
|
34
|
+
lines.append(f"Comparison: {report.cassette_b_path}")
|
|
35
|
+
lines.append("")
|
|
36
|
+
|
|
37
|
+
# Request
|
|
38
|
+
lines.append(f"Request: {report.method} {report.path}")
|
|
39
|
+
lines.append("")
|
|
40
|
+
|
|
41
|
+
# Overall result
|
|
42
|
+
if report.is_regression:
|
|
43
|
+
icon = "[FAIL]"
|
|
44
|
+
status = "REGRESSION DETECTED"
|
|
45
|
+
elif report.has_differences:
|
|
46
|
+
icon = "[WARN]"
|
|
47
|
+
status = "DIFFERENCES FOUND"
|
|
48
|
+
else:
|
|
49
|
+
icon = "[OK]"
|
|
50
|
+
status = "NO DIFFERENCES"
|
|
51
|
+
|
|
52
|
+
lines.append(f"{icon} {status}")
|
|
53
|
+
lines.append("")
|
|
54
|
+
|
|
55
|
+
# Response diff
|
|
56
|
+
lines.append("-" * 40)
|
|
57
|
+
lines.append("RESPONSE")
|
|
58
|
+
lines.append("-" * 40)
|
|
59
|
+
|
|
60
|
+
rd = report.response_diff
|
|
61
|
+
|
|
62
|
+
if rd.status_changed:
|
|
63
|
+
lines.append(f" Status: {rd.old_status} → {rd.new_status}")
|
|
64
|
+
else:
|
|
65
|
+
lines.append(f" Status: {rd.old_status} (unchanged)")
|
|
66
|
+
|
|
67
|
+
if rd.duration_changed:
|
|
68
|
+
direction = "(slower)" if rd.duration_delta_ms > 0 else "(faster)"
|
|
69
|
+
lines.append(
|
|
70
|
+
f" Duration: {rd.old_duration_ms:.0f}ms → {rd.new_duration_ms:.0f}ms "
|
|
71
|
+
f"({direction} {abs(rd.duration_delta_pct):.1f}%)"
|
|
72
|
+
)
|
|
73
|
+
else:
|
|
74
|
+
lines.append(f" Duration: {rd.old_duration_ms:.0f}ms → {rd.new_duration_ms:.0f}ms")
|
|
75
|
+
|
|
76
|
+
lines.append("")
|
|
77
|
+
|
|
78
|
+
# Events diff
|
|
79
|
+
lines.append("-" * 40)
|
|
80
|
+
lines.append("EVENTS")
|
|
81
|
+
lines.append("-" * 40)
|
|
82
|
+
|
|
83
|
+
if report.event_count_changed:
|
|
84
|
+
lines.append(f" Count: {report.event_count_a} → {report.event_count_b}")
|
|
85
|
+
else:
|
|
86
|
+
lines.append(f" Count: {report.event_count_a} (unchanged)")
|
|
87
|
+
|
|
88
|
+
if report.event_diffs:
|
|
89
|
+
lines.append("")
|
|
90
|
+
lines.append(" Changed events:")
|
|
91
|
+
for diff in report.event_diffs:
|
|
92
|
+
icon = "[FAIL]" if diff.is_critical else "[WARN]"
|
|
93
|
+
lines.append(f" {icon} #{diff.event_index} [{diff.event_type}]: {diff.summary}")
|
|
94
|
+
|
|
95
|
+
if report.extra_events_a:
|
|
96
|
+
lines.append("")
|
|
97
|
+
lines.append(f" Events only in baseline: {report.extra_events_a}")
|
|
98
|
+
|
|
99
|
+
if report.extra_events_b:
|
|
100
|
+
lines.append("")
|
|
101
|
+
lines.append(f" Events only in comparison: {report.extra_events_b}")
|
|
102
|
+
|
|
103
|
+
lines.append("")
|
|
104
|
+
|
|
105
|
+
# Summary
|
|
106
|
+
lines.append("-" * 40)
|
|
107
|
+
lines.append("SUMMARY")
|
|
108
|
+
lines.append("-" * 40)
|
|
109
|
+
lines.append(f" Total duration change: {report.total_duration_delta_ms:+.0f}ms")
|
|
110
|
+
lines.append(f" Critical differences: {report.critical_diffs}")
|
|
111
|
+
lines.append("")
|
|
112
|
+
|
|
113
|
+
return "\n".join(lines)
|
timetracer/exceptions.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Centralized exceptions for Timetracer.
|
|
3
|
+
|
|
4
|
+
All custom exceptions are defined here to ensure consistent error handling.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TimetracerError(Exception):
|
|
11
|
+
"""Base exception for all Timetracer errors."""
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CassetteError(TimetracerError):
|
|
16
|
+
"""Base exception for cassette-related errors."""
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CassetteNotFoundError(CassetteError):
|
|
21
|
+
"""Raised when a cassette file cannot be found."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, path: str):
|
|
24
|
+
self.path = path
|
|
25
|
+
super().__init__(f"Cassette not found: {path}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CassetteSchemaError(CassetteError):
|
|
29
|
+
"""Raised when a cassette has an invalid or incompatible schema."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, path: str, expected_version: str, actual_version: str | None):
|
|
32
|
+
self.path = path
|
|
33
|
+
self.expected_version = expected_version
|
|
34
|
+
self.actual_version = actual_version
|
|
35
|
+
super().__init__(
|
|
36
|
+
f"Cassette schema mismatch in {path}: "
|
|
37
|
+
f"expected {expected_version}, got {actual_version}"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ReplayMismatchError(TimetracerError):
|
|
42
|
+
"""
|
|
43
|
+
Raised when a dependency call during replay doesn't match the recorded cassette.
|
|
44
|
+
|
|
45
|
+
This provides detailed information to help debug what changed.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
message: str,
|
|
51
|
+
*,
|
|
52
|
+
cassette_path: str | None = None,
|
|
53
|
+
endpoint: str | None = None,
|
|
54
|
+
event_index: int | None = None,
|
|
55
|
+
expected: dict[str, Any] | None = None,
|
|
56
|
+
actual: dict[str, Any] | None = None,
|
|
57
|
+
hint: str | None = None,
|
|
58
|
+
):
|
|
59
|
+
self.cassette_path = cassette_path
|
|
60
|
+
self.endpoint = endpoint
|
|
61
|
+
self.event_index = event_index
|
|
62
|
+
self.expected = expected or {}
|
|
63
|
+
self.actual = actual or {}
|
|
64
|
+
self.hint = hint
|
|
65
|
+
|
|
66
|
+
# Build detailed error message
|
|
67
|
+
lines = [message, ""]
|
|
68
|
+
|
|
69
|
+
if cassette_path:
|
|
70
|
+
lines.append(f"cassette: {cassette_path}")
|
|
71
|
+
if endpoint:
|
|
72
|
+
lines.append(f"endpoint: {endpoint}")
|
|
73
|
+
if event_index is not None:
|
|
74
|
+
lines.append(f"event index: #{event_index}")
|
|
75
|
+
|
|
76
|
+
if expected:
|
|
77
|
+
lines.append("")
|
|
78
|
+
lines.append("expected:")
|
|
79
|
+
for key, value in expected.items():
|
|
80
|
+
lines.append(f" {key}: {value}")
|
|
81
|
+
|
|
82
|
+
if actual:
|
|
83
|
+
lines.append("")
|
|
84
|
+
lines.append("actual:")
|
|
85
|
+
for key, value in actual.items():
|
|
86
|
+
lines.append(f" {key}: {value}")
|
|
87
|
+
|
|
88
|
+
if hint:
|
|
89
|
+
lines.append("")
|
|
90
|
+
lines.append(f"hint: {hint}")
|
|
91
|
+
|
|
92
|
+
super().__init__("\n".join(lines))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class ConfigurationError(TimetracerError):
|
|
96
|
+
"""Raised when there's an invalid configuration."""
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class PluginError(TimetracerError):
|
|
101
|
+
"""Base exception for plugin-related errors."""
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class PluginNotFoundError(PluginError):
|
|
106
|
+
"""Raised when a requested plugin is not available."""
|
|
107
|
+
|
|
108
|
+
def __init__(self, plugin_name: str):
|
|
109
|
+
self.plugin_name = plugin_name
|
|
110
|
+
super().__init__(
|
|
111
|
+
f"Plugin '{plugin_name}' not found. "
|
|
112
|
+
f"Make sure it's installed: pip install timetracer[{plugin_name}]"
|
|
113
|
+
)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Framework integrations for Timetracer."""
|
|
2
|
+
|
|
3
|
+
from timetracer.integrations.fastapi import TimeTraceMiddleware, auto_setup
|
|
4
|
+
|
|
5
|
+
# Alias for backwards compatibility
|
|
6
|
+
timetracerMiddleware = TimeTraceMiddleware
|
|
7
|
+
|
|
8
|
+
# Flask is optional
|
|
9
|
+
try:
|
|
10
|
+
from timetracer.integrations.flask import TimeTraceMiddleware as FlaskMiddleware
|
|
11
|
+
from timetracer.integrations.flask import auto_setup as flask_auto_setup
|
|
12
|
+
from timetracer.integrations.flask import init_app
|
|
13
|
+
_HAS_FLASK = True
|
|
14
|
+
except ImportError:
|
|
15
|
+
_HAS_FLASK = False
|
|
16
|
+
FlaskMiddleware = None
|
|
17
|
+
init_app = None
|
|
18
|
+
flask_auto_setup = None
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"TimeTraceMiddleware",
|
|
22
|
+
"timetracerMiddleware", # Alias
|
|
23
|
+
"auto_setup",
|
|
24
|
+
"FlaskMiddleware",
|
|
25
|
+
"init_app",
|
|
26
|
+
"flask_auto_setup",
|
|
27
|
+
]
|