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,288 @@
1
+ """
2
+ Cassette catalog and search functionality.
3
+
4
+ Provides indexing and search capabilities for cassettes.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+
16
+ @dataclass
17
+ class CassetteEntry:
18
+ """A single cassette index entry."""
19
+ path: str
20
+ method: str
21
+ endpoint: str
22
+ route_template: str | None
23
+ status: int
24
+ duration_ms: float
25
+ recorded_at: str
26
+ service: str
27
+ env: str
28
+ event_count: int
29
+ has_errors: bool
30
+ size_bytes: int
31
+
32
+ def matches(self, query: "SearchQuery") -> bool:
33
+ """Check if entry matches search query."""
34
+ if query.method and query.method.upper() != self.method.upper():
35
+ return False
36
+
37
+ if query.endpoint and query.endpoint.lower() not in self.endpoint.lower():
38
+ return False
39
+
40
+ if query.status_min and self.status < query.status_min:
41
+ return False
42
+
43
+ if query.status_max and self.status > query.status_max:
44
+ return False
45
+
46
+ if query.errors_only and not self.has_errors:
47
+ return False
48
+
49
+ if query.service and query.service.lower() != self.service.lower():
50
+ return False
51
+
52
+ if query.env and query.env.lower() != self.env.lower():
53
+ return False
54
+
55
+ if query.date_from:
56
+ try:
57
+ recorded = datetime.fromisoformat(self.recorded_at.replace("Z", "+00:00"))
58
+ if recorded < query.date_from:
59
+ return False
60
+ except Exception:
61
+ pass
62
+
63
+ if query.date_to:
64
+ try:
65
+ recorded = datetime.fromisoformat(self.recorded_at.replace("Z", "+00:00"))
66
+ if recorded > query.date_to:
67
+ return False
68
+ except Exception:
69
+ pass
70
+
71
+ return True
72
+
73
+ def to_dict(self) -> dict[str, Any]:
74
+ """Convert to dictionary."""
75
+ return {
76
+ "path": self.path,
77
+ "method": self.method,
78
+ "endpoint": self.endpoint,
79
+ "route_template": self.route_template,
80
+ "status": self.status,
81
+ "duration_ms": self.duration_ms,
82
+ "recorded_at": self.recorded_at,
83
+ "service": self.service,
84
+ "env": self.env,
85
+ "event_count": self.event_count,
86
+ "has_errors": self.has_errors,
87
+ "size_bytes": self.size_bytes,
88
+ }
89
+
90
+
91
+ @dataclass
92
+ class SearchQuery:
93
+ """Search query parameters."""
94
+ method: str | None = None
95
+ endpoint: str | None = None
96
+ status_min: int | None = None
97
+ status_max: int | None = None
98
+ errors_only: bool = False
99
+ service: str | None = None
100
+ env: str | None = None
101
+ date_from: datetime | None = None
102
+ date_to: datetime | None = None
103
+ limit: int = 50
104
+
105
+
106
+ @dataclass
107
+ class CassetteIndex:
108
+ """Index of all cassettes in a directory."""
109
+ entries: list[CassetteEntry] = field(default_factory=list)
110
+ indexed_at: str = ""
111
+ cassette_dir: str = ""
112
+ total_count: int = 0
113
+
114
+ def search(self, query: SearchQuery) -> list[CassetteEntry]:
115
+ """Search cassettes matching query."""
116
+ results = []
117
+ for entry in self.entries:
118
+ if entry.matches(query):
119
+ results.append(entry)
120
+ if len(results) >= query.limit:
121
+ break
122
+ return results
123
+
124
+ def to_dict(self) -> dict[str, Any]:
125
+ """Convert to dictionary."""
126
+ return {
127
+ "indexed_at": self.indexed_at,
128
+ "cassette_dir": self.cassette_dir,
129
+ "total_count": self.total_count,
130
+ "entries": [e.to_dict() for e in self.entries],
131
+ }
132
+
133
+ @classmethod
134
+ def from_dict(cls, data: dict[str, Any]) -> "CassetteIndex":
135
+ """Create from dictionary."""
136
+ entries = [
137
+ CassetteEntry(**e) for e in data.get("entries", [])
138
+ ]
139
+ return cls(
140
+ entries=entries,
141
+ indexed_at=data.get("indexed_at", ""),
142
+ cassette_dir=data.get("cassette_dir", ""),
143
+ total_count=data.get("total_count", len(entries)),
144
+ )
145
+
146
+
147
+ def build_index(
148
+ cassette_dir: str,
149
+ recursive: bool = True,
150
+ ) -> CassetteIndex:
151
+ """
152
+ Build an index of all cassettes in a directory.
153
+
154
+ Args:
155
+ cassette_dir: Directory containing cassettes.
156
+ recursive: Search subdirectories.
157
+
158
+ Returns:
159
+ CassetteIndex with all cassette entries.
160
+ """
161
+ dir_path = Path(cassette_dir)
162
+
163
+ if not dir_path.exists():
164
+ return CassetteIndex(
165
+ cassette_dir=cassette_dir,
166
+ indexed_at=datetime.utcnow().isoformat() + "Z",
167
+ )
168
+
169
+ entries = []
170
+ pattern = "**/*.json" if recursive else "*.json"
171
+
172
+ for json_file in dir_path.glob(pattern):
173
+ try:
174
+ entry = _index_cassette(json_file, dir_path)
175
+ if entry:
176
+ entries.append(entry)
177
+ except Exception:
178
+ # Skip invalid files
179
+ continue
180
+
181
+ # Sort by recorded_at (newest first)
182
+ entries.sort(key=lambda e: e.recorded_at, reverse=True)
183
+
184
+ return CassetteIndex(
185
+ entries=entries,
186
+ indexed_at=datetime.utcnow().isoformat() + "Z",
187
+ cassette_dir=cassette_dir,
188
+ total_count=len(entries),
189
+ )
190
+
191
+
192
+ def _index_cassette(path: Path, base_dir: Path) -> CassetteEntry | None:
193
+ """Extract index entry from a cassette file."""
194
+ try:
195
+ with open(path, "r", encoding="utf-8") as f:
196
+ data = json.load(f)
197
+ except Exception:
198
+ return None
199
+
200
+ # Validate it's a timetrace cassette
201
+ if "schema_version" not in data:
202
+ return None
203
+
204
+ request = data.get("request", {})
205
+ response = data.get("response", {})
206
+ session = data.get("session", {})
207
+ events = data.get("events", [])
208
+
209
+ method = request.get("method", "UNKNOWN")
210
+ endpoint = request.get("path", "/")
211
+ route_template = request.get("route_template")
212
+ status = response.get("status", 0)
213
+ duration_ms = response.get("duration_ms", 0)
214
+ recorded_at = session.get("recorded_at", "")
215
+ service = session.get("service", "")
216
+ env = session.get("env", "")
217
+
218
+ return CassetteEntry(
219
+ path=str(path.relative_to(base_dir)),
220
+ method=method,
221
+ endpoint=endpoint,
222
+ route_template=route_template,
223
+ status=status,
224
+ duration_ms=duration_ms,
225
+ recorded_at=recorded_at,
226
+ service=service,
227
+ env=env,
228
+ event_count=len(events),
229
+ has_errors=status >= 400,
230
+ size_bytes=path.stat().st_size,
231
+ )
232
+
233
+
234
+ def save_index(index: CassetteIndex, output_path: str) -> None:
235
+ """Save index to JSON file."""
236
+ with open(output_path, "w", encoding="utf-8") as f:
237
+ json.dump(index.to_dict(), f, indent=2)
238
+
239
+
240
+ def load_index(path: str) -> CassetteIndex:
241
+ """Load index from JSON file."""
242
+ with open(path, "r", encoding="utf-8") as f:
243
+ data = json.load(f)
244
+ return CassetteIndex.from_dict(data)
245
+
246
+
247
+ def search_cassettes(
248
+ cassette_dir: str,
249
+ method: str | None = None,
250
+ endpoint: str | None = None,
251
+ status_min: int | None = None,
252
+ status_max: int | None = None,
253
+ errors_only: bool = False,
254
+ service: str | None = None,
255
+ env: str | None = None,
256
+ limit: int = 50,
257
+ ) -> list[CassetteEntry]:
258
+ """
259
+ Search cassettes in a directory.
260
+
261
+ Args:
262
+ cassette_dir: Directory containing cassettes.
263
+ method: Filter by HTTP method (GET, POST, etc.).
264
+ endpoint: Filter by endpoint path (partial match).
265
+ status_min: Minimum status code.
266
+ status_max: Maximum status code.
267
+ errors_only: Only return error responses (4xx, 5xx).
268
+ service: Filter by service name.
269
+ env: Filter by environment.
270
+ limit: Maximum results.
271
+
272
+ Returns:
273
+ List of matching CassetteEntry objects.
274
+ """
275
+ index = build_index(cassette_dir)
276
+
277
+ query = SearchQuery(
278
+ method=method,
279
+ endpoint=endpoint,
280
+ status_min=status_min,
281
+ status_max=status_max,
282
+ errors_only=errors_only,
283
+ service=service,
284
+ env=env,
285
+ limit=limit,
286
+ )
287
+
288
+ return index.search(query)
@@ -0,0 +1,5 @@
1
+ """CLI module for Timetracer."""
2
+
3
+ from timetracer.cli.main import main
4
+
5
+ __all__ = ["main"]
@@ -0,0 +1 @@
1
+ """CLI commands subpackage."""