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,165 @@
1
+ """
2
+ Redaction policies for sensitive data.
3
+
4
+ Removes or masks sensitive headers and body content before storage.
5
+ Uses centralized constants for consistency.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from typing import Any
12
+
13
+ from timetracer.constants import ALLOWED_HEADERS, Redaction
14
+
15
+
16
+ def redact_headers(
17
+ headers: dict[str, str],
18
+ *,
19
+ mode: str = "drop",
20
+ additional_sensitive: set[str] | None = None,
21
+ ) -> dict[str, str]:
22
+ """
23
+ Redact sensitive headers.
24
+
25
+ Args:
26
+ headers: Original headers dict.
27
+ mode: "drop" to remove sensitive headers, "mask" to replace values.
28
+ additional_sensitive: Additional header names to treat as sensitive.
29
+
30
+ Returns:
31
+ New dict with sensitive headers removed or masked.
32
+ """
33
+ sensitive = Redaction.SENSITIVE_HEADERS
34
+ if additional_sensitive:
35
+ sensitive = sensitive | {h.lower() for h in additional_sensitive}
36
+
37
+ result = {}
38
+ for key, value in headers.items():
39
+ key_lower = key.lower()
40
+
41
+ if key_lower in sensitive:
42
+ if mode == "mask":
43
+ result[key] = Redaction.REDACTED_VALUE
44
+ # else: drop (don't include)
45
+ else:
46
+ result[key] = value
47
+
48
+ return result
49
+
50
+
51
+ def redact_headers_allowlist(
52
+ headers: dict[str, str],
53
+ allowed: frozenset[str] | None = None,
54
+ ) -> dict[str, str]:
55
+ """
56
+ Keep only allowed headers (allowlist approach).
57
+
58
+ This is safer than blocklist - only explicitly allowed headers are kept.
59
+
60
+ Args:
61
+ headers: Original headers dict.
62
+ allowed: Set of allowed header names (lowercase). Defaults to ALLOWED_HEADERS.
63
+
64
+ Returns:
65
+ New dict with only allowed headers.
66
+ """
67
+ if allowed is None:
68
+ allowed = ALLOWED_HEADERS
69
+
70
+ return {
71
+ key: value
72
+ for key, value in headers.items()
73
+ if key.lower() in allowed
74
+ }
75
+
76
+
77
+ def redact_body(
78
+ body: Any,
79
+ *,
80
+ additional_sensitive_keys: set[str] | None = None,
81
+ ) -> Any:
82
+ """
83
+ Redact sensitive keys in a body object.
84
+
85
+ Recursively processes dicts and lists.
86
+
87
+ Args:
88
+ body: The body data (usually a dict or list).
89
+ additional_sensitive_keys: Additional keys to redact.
90
+
91
+ Returns:
92
+ New object with sensitive values masked.
93
+ """
94
+ if body is None:
95
+ return None
96
+
97
+ sensitive_keys = Redaction.SENSITIVE_BODY_KEYS
98
+ if additional_sensitive_keys:
99
+ sensitive_keys = sensitive_keys | {k.lower() for k in additional_sensitive_keys}
100
+
101
+ return _redact_recursive(body, sensitive_keys)
102
+
103
+
104
+ def _redact_recursive(obj: Any, sensitive_keys: frozenset[str]) -> Any:
105
+ """Recursively redact sensitive keys."""
106
+ if isinstance(obj, dict):
107
+ result = {}
108
+ for key, value in obj.items():
109
+ if _is_sensitive_key(key, sensitive_keys):
110
+ result[key] = Redaction.REDACTED_VALUE
111
+ else:
112
+ result[key] = _redact_recursive(value, sensitive_keys)
113
+ return result
114
+
115
+ elif isinstance(obj, list):
116
+ return [_redact_recursive(item, sensitive_keys) for item in obj]
117
+
118
+ elif isinstance(obj, str):
119
+ # Optionally mask token-like strings
120
+ return _mask_token_like(obj)
121
+
122
+ else:
123
+ return obj
124
+
125
+
126
+ def _is_sensitive_key(key: str, sensitive_keys: frozenset[str]) -> bool:
127
+ """Check if a key is sensitive (case-insensitive substring match)."""
128
+ key_lower = key.lower()
129
+
130
+ # Exact match
131
+ if key_lower in sensitive_keys:
132
+ return True
133
+
134
+ # Substring match for compound keys
135
+ for sensitive in sensitive_keys:
136
+ if sensitive in key_lower:
137
+ return True
138
+
139
+ return False
140
+
141
+
142
+ def _mask_token_like(value: str) -> str:
143
+ """
144
+ Mask token-like strings in values.
145
+
146
+ Patterns:
147
+ - JWT tokens (eyJ...)
148
+ - Bearer tokens
149
+ - API keys (long alphanumeric strings)
150
+ """
151
+ # JWT pattern
152
+ if value.startswith("eyJ") and value.count(".") == 2:
153
+ return Redaction.REDACTED_VALUE
154
+
155
+ # Bearer prefix
156
+ if value.lower().startswith("bearer "):
157
+ return f"Bearer {Redaction.REDACTED_VALUE}"
158
+
159
+ # Very long alphanumeric strings (likely API keys)
160
+ if len(value) > 32 and re.match(r"^[a-zA-Z0-9_-]+$", value):
161
+ # Only mask if it looks random (has mixed case or underscores)
162
+ if re.search(r"[a-z]", value) and re.search(r"[A-Z0-9_-]", value):
163
+ return Redaction.REDACTED_VALUE
164
+
165
+ return value
@@ -0,0 +1,6 @@
1
+ """Replay module for cassette playback."""
2
+
3
+ from timetracer.replay.engine import ReplayEngine
4
+ from timetracer.replay.errors import ReplayMismatchError
5
+
6
+ __all__ = ["ReplayMismatchError", "ReplayEngine"]
@@ -0,0 +1,75 @@
1
+ """
2
+ Replay engine for Timetracer.
3
+
4
+ The ReplayEngine manages cassette playback and event matching.
5
+ Most functionality is in ReplaySession (session.py), this provides
6
+ additional utilities.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING
12
+
13
+ from timetracer.cassette import read_cassette
14
+ from timetracer.constants import EventType
15
+ from timetracer.session import ReplaySession
16
+
17
+ if TYPE_CHECKING:
18
+ from timetracer.types import Cassette, DependencyEvent
19
+
20
+
21
+ class ReplayEngine:
22
+ """
23
+ Engine for replaying cassettes.
24
+
25
+ This is a convenience wrapper around ReplaySession for programmatic use.
26
+ Most applications will use ReplaySession directly via the middleware.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ cassette_path: str,
32
+ strict: bool = True,
33
+ ) -> None:
34
+ """
35
+ Initialize replay engine.
36
+
37
+ Args:
38
+ cassette_path: Path to the cassette file.
39
+ strict: If True, raise on mismatches.
40
+ """
41
+ self.cassette_path = cassette_path
42
+ self.strict = strict
43
+ self._cassette: Cassette | None = None
44
+ self._session: ReplaySession | None = None
45
+
46
+ def load(self) -> None:
47
+ """Load the cassette from disk."""
48
+ self._cassette = read_cassette(self.cassette_path)
49
+ self._session = ReplaySession(
50
+ cassette=self._cassette,
51
+ cassette_path=self.cassette_path,
52
+ strict=self.strict,
53
+ )
54
+
55
+ @property
56
+ def cassette(self) -> Cassette:
57
+ """Get loaded cassette."""
58
+ if self._cassette is None:
59
+ raise RuntimeError("Cassette not loaded. Call load() first.")
60
+ return self._cassette
61
+
62
+ @property
63
+ def session(self) -> ReplaySession:
64
+ """Get replay session."""
65
+ if self._session is None:
66
+ raise RuntimeError("Session not created. Call load() first.")
67
+ return self._session
68
+
69
+ def get_events_by_type(self, event_type: EventType) -> list[DependencyEvent]:
70
+ """Get all events of a specific type."""
71
+ return [e for e in self.cassette.events if e.event_type == event_type]
72
+
73
+ def get_http_events(self) -> list[DependencyEvent]:
74
+ """Get all HTTP client events."""
75
+ return self.get_events_by_type(EventType.HTTP_CLIENT)
@@ -0,0 +1,9 @@
1
+ """
2
+ Replay errors for Timetracer.
3
+
4
+ Re-exports from the main exceptions module for convenience.
5
+ """
6
+
7
+ from timetracer.exceptions import ReplayMismatchError
8
+
9
+ __all__ = ["ReplayMismatchError"]
@@ -0,0 +1,83 @@
1
+ """
2
+ Signature matching for replay.
3
+
4
+ Provides utilities for comparing recorded and actual call signatures.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+ from urllib.parse import parse_qs, urlparse
11
+
12
+ from timetracer.types import EventSignature
13
+
14
+
15
+ def normalize_url(url: str) -> str:
16
+ """
17
+ Normalize a URL for comparison.
18
+
19
+ Removes query string, normalizes scheme/host/path.
20
+ """
21
+ parsed = urlparse(url)
22
+ return f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
23
+
24
+
25
+ def normalize_query(query_string: str) -> dict[str, Any]:
26
+ """Normalize query string to sorted dict."""
27
+ parsed = parse_qs(query_string)
28
+ # Flatten single-value lists
29
+ return {k: v[0] if len(v) == 1 else sorted(v) for k, v in sorted(parsed.items())}
30
+
31
+
32
+ def signatures_match(
33
+ expected: EventSignature,
34
+ actual: dict[str, Any],
35
+ *,
36
+ check_body_hash: bool = False,
37
+ ) -> tuple[bool, list[str]]:
38
+ """
39
+ Check if two signatures match.
40
+
41
+ Args:
42
+ expected: The recorded signature.
43
+ actual: The actual call signature dict.
44
+ check_body_hash: Whether to compare body hashes.
45
+
46
+ Returns:
47
+ Tuple of (matches, list of mismatch reasons).
48
+ """
49
+ mismatches: list[str] = []
50
+
51
+ # Check method
52
+ if expected.method != actual.get("method"):
53
+ mismatches.append(
54
+ f"method: expected {expected.method}, got {actual.get('method')}"
55
+ )
56
+
57
+ # Check URL
58
+ expected_url = normalize_url(expected.url) if expected.url else None
59
+ actual_url = normalize_url(actual.get("url", "")) if actual.get("url") else None
60
+
61
+ if expected_url != actual_url:
62
+ mismatches.append(
63
+ f"url: expected {expected_url}, got {actual_url}"
64
+ )
65
+
66
+ # Check body hash (optional)
67
+ if check_body_hash and expected.body_hash:
68
+ if expected.body_hash != actual.get("body_hash"):
69
+ mismatches.append(
70
+ f"body_hash: expected {expected.body_hash[:20]}..., got {actual.get('body_hash', 'none')[:20]}..."
71
+ )
72
+
73
+ return len(mismatches) == 0, mismatches
74
+
75
+
76
+ def create_signature_summary(sig: EventSignature) -> str:
77
+ """Create a human-readable summary of a signature."""
78
+ parts = [sig.method]
79
+ if sig.url:
80
+ parts.append(sig.url)
81
+ if sig.query:
82
+ parts.append(f"?{len(sig.query)} params")
83
+ return " ".join(parts)