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,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 @@
|
|
|
1
|
+
"""CLI commands subpackage."""
|