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.
Files changed (51) hide show
  1. timetracer/__init__.py +29 -0
  2. timetracer/cassette/__init__.py +6 -0
  3. timetracer/cassette/io.py +421 -0
  4. timetracer/cassette/naming.py +69 -0
  5. timetracer/catalog/__init__.py +288 -0
  6. timetracer/cli/__init__.py +5 -0
  7. timetracer/cli/commands/__init__.py +1 -0
  8. timetracer/cli/main.py +692 -0
  9. timetracer/config.py +297 -0
  10. timetracer/constants.py +129 -0
  11. timetracer/context.py +93 -0
  12. timetracer/dashboard/__init__.py +14 -0
  13. timetracer/dashboard/generator.py +229 -0
  14. timetracer/dashboard/server.py +244 -0
  15. timetracer/dashboard/template.py +874 -0
  16. timetracer/diff/__init__.py +6 -0
  17. timetracer/diff/engine.py +311 -0
  18. timetracer/diff/report.py +113 -0
  19. timetracer/exceptions.py +113 -0
  20. timetracer/integrations/__init__.py +27 -0
  21. timetracer/integrations/fastapi.py +537 -0
  22. timetracer/integrations/flask.py +507 -0
  23. timetracer/plugins/__init__.py +42 -0
  24. timetracer/plugins/base.py +73 -0
  25. timetracer/plugins/httpx_plugin.py +413 -0
  26. timetracer/plugins/redis_plugin.py +297 -0
  27. timetracer/plugins/requests_plugin.py +333 -0
  28. timetracer/plugins/sqlalchemy_plugin.py +280 -0
  29. timetracer/policies/__init__.py +16 -0
  30. timetracer/policies/capture.py +64 -0
  31. timetracer/policies/redaction.py +165 -0
  32. timetracer/replay/__init__.py +6 -0
  33. timetracer/replay/engine.py +75 -0
  34. timetracer/replay/errors.py +9 -0
  35. timetracer/replay/matching.py +83 -0
  36. timetracer/session.py +390 -0
  37. timetracer/storage/__init__.py +18 -0
  38. timetracer/storage/s3.py +364 -0
  39. timetracer/timeline/__init__.py +6 -0
  40. timetracer/timeline/generator.py +150 -0
  41. timetracer/timeline/template.py +370 -0
  42. timetracer/types.py +197 -0
  43. timetracer/utils/__init__.py +6 -0
  44. timetracer/utils/hashing.py +68 -0
  45. timetracer/utils/time.py +106 -0
  46. timetracer-1.1.0.dist-info/METADATA +286 -0
  47. timetracer-1.1.0.dist-info/RECORD +51 -0
  48. timetracer-1.1.0.dist-info/WHEEL +5 -0
  49. timetracer-1.1.0.dist-info/entry_points.txt +2 -0
  50. timetracer-1.1.0.dist-info/licenses/LICENSE +21 -0
  51. timetracer-1.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,6 @@
1
+ """Diff module for cassette comparison."""
2
+
3
+ from timetracer.diff.engine import DiffReport, diff_cassettes
4
+ from timetracer.diff.report import format_diff_report
5
+
6
+ __all__ = ["diff_cassettes", "DiffReport", "format_diff_report"]
@@ -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)
@@ -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
+ ]