restless-sdk 0.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.
- restless/__init__.py +161 -0
- restless/_caches.py +128 -0
- restless/_capture.py +274 -0
- restless/_conformance/__init__.py +0 -0
- restless/_conformance/__main__.py +125 -0
- restless/_fingerprint.py +342 -0
- restless/_har.py +133 -0
- restless/_injection.py +114 -0
- restless/_mask.py +58 -0
- restless/_redact.py +338 -0
- restless/_request.py +136 -0
- restless/_request_id.py +64 -0
- restless/_settings.py +91 -0
- restless/_text.py +63 -0
- restless/_uploader.py +247 -0
- restless/_version.py +15 -0
- restless/adapters/__init__.py +0 -0
- restless/adapters/asgi.py +232 -0
- restless/adapters/wsgi.py +268 -0
- restless/install.md +427 -0
- restless/py.typed +0 -0
- restless_sdk-0.1.0.dist-info/METADATA +125 -0
- restless_sdk-0.1.0.dist-info/RECORD +26 -0
- restless_sdk-0.1.0.dist-info/WHEEL +5 -0
- restless_sdk-0.1.0.dist-info/licenses/LICENSE +18 -0
- restless_sdk-0.1.0.dist-info/top_level.txt +1 -0
restless/__init__.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Restless Python SDK.
|
|
2
|
+
|
|
3
|
+
Capture your API traffic and send it to Restless.
|
|
4
|
+
|
|
5
|
+
import restless
|
|
6
|
+
|
|
7
|
+
client = restless.Restless(os.environ["RESTLESS_KEY"])
|
|
8
|
+
|
|
9
|
+
@client.setup
|
|
10
|
+
def _(request):
|
|
11
|
+
return {
|
|
12
|
+
"api_key": client.mask(request.headers.get("authorization")),
|
|
13
|
+
"owner": {
|
|
14
|
+
"id": current_workspace_id(),
|
|
15
|
+
"enrich": lambda owner_id: {"label": lookup_name(owner_id)},
|
|
16
|
+
},
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
app.wsgi_app = client.wsgi(app.wsgi_app)
|
|
20
|
+
|
|
21
|
+
Conformance: implements spec version 1.0.0 of the Restless SDK Contract at
|
|
22
|
+
level L2. Run the shared harness against ``python -m restless._conformance``
|
|
23
|
+
to verify. See CONFORMANCE.md.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
import sys
|
|
28
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
29
|
+
|
|
30
|
+
from ._capture import CaptureEngine
|
|
31
|
+
from ._mask import mask
|
|
32
|
+
from ._request import Headers, RequestInfo
|
|
33
|
+
from ._request_id import (
|
|
34
|
+
format_request_id,
|
|
35
|
+
is_valid_request_id,
|
|
36
|
+
new_request_id,
|
|
37
|
+
strip_request_id_prefix,
|
|
38
|
+
)
|
|
39
|
+
from ._settings import load_settings, resolve_api
|
|
40
|
+
from ._uploader import is_test_run, resolve_base_url
|
|
41
|
+
from ._version import CONFORMANCE_LEVEL, SDK_NAME, SDK_VERSION, SPEC_VERSION
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"Restless",
|
|
45
|
+
"restless",
|
|
46
|
+
"mask",
|
|
47
|
+
"RequestInfo",
|
|
48
|
+
"Headers",
|
|
49
|
+
"new_request_id",
|
|
50
|
+
"format_request_id",
|
|
51
|
+
"strip_request_id_prefix",
|
|
52
|
+
"is_valid_request_id",
|
|
53
|
+
"SDK_NAME",
|
|
54
|
+
"SDK_VERSION",
|
|
55
|
+
"SPEC_VERSION",
|
|
56
|
+
"CONFORMANCE_LEVEL",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
__version__ = SDK_VERSION
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Restless:
|
|
63
|
+
"""A Restless client.
|
|
64
|
+
|
|
65
|
+
Naming here is idiomatic Python rather than a transliteration of the Node
|
|
66
|
+
SDK. CONTRACT.md section 14 makes the public surface explicitly
|
|
67
|
+
per-language: what is normative is the SEMANTIC surface (construct with
|
|
68
|
+
an explicit key, register a per-request callback, mask a key, flush), not
|
|
69
|
+
the method names.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
api_key: Optional[str] = None,
|
|
75
|
+
api: Optional[str] = None,
|
|
76
|
+
redact: Optional[Dict[str, List[str]]] = None,
|
|
77
|
+
base_url: Optional[str] = None,
|
|
78
|
+
transport: Optional[Callable] = None,
|
|
79
|
+
):
|
|
80
|
+
# CONFIG-001. Explicit key wins, then RESTLESS_KEY, then README_API_KEY.
|
|
81
|
+
resolved = (
|
|
82
|
+
api_key
|
|
83
|
+
or os.environ.get("RESTLESS_KEY")
|
|
84
|
+
or os.environ.get("README_API_KEY")
|
|
85
|
+
or ""
|
|
86
|
+
)
|
|
87
|
+
# CONFIG-002. Capture still runs; only upload is disabled.
|
|
88
|
+
if not resolved and not is_test_run():
|
|
89
|
+
print(
|
|
90
|
+
"[restless] no API key found - set RESTLESS_KEY in your environment "
|
|
91
|
+
"or pass it to Restless(). Captured requests will not be uploaded.",
|
|
92
|
+
file=sys.stderr,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
request_id_prefix = None
|
|
96
|
+
settings_redact = None
|
|
97
|
+
entry = resolve_api(load_settings(), api) # CONFIG-013/014 may raise
|
|
98
|
+
if entry:
|
|
99
|
+
request_id_prefix = entry.get("requestIdPrefix")
|
|
100
|
+
settings_redact = entry.get("redact")
|
|
101
|
+
|
|
102
|
+
# REDACT-015. Both sources are additive on top of the defaults.
|
|
103
|
+
merged: Dict[str, List[str]] = {}
|
|
104
|
+
for field in ("headers", "bodyKeys", "queryParams"):
|
|
105
|
+
merged[field] = list((settings_redact or {}).get(field) or []) + list(
|
|
106
|
+
(redact or {}).get(field) or []
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
self.engine = CaptureEngine(
|
|
110
|
+
api_key=resolved,
|
|
111
|
+
base_url=resolve_base_url(base_url),
|
|
112
|
+
request_id_prefix=request_id_prefix,
|
|
113
|
+
redact=merged,
|
|
114
|
+
transport=transport,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# ------------------------------------------------------------ public API
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def mask(api_key: Optional[str]) -> Optional[str]:
|
|
121
|
+
"""Mask an end-user API key (MASK-001).
|
|
122
|
+
|
|
123
|
+
Pass the raw header value straight through. Do NOT substitute a
|
|
124
|
+
placeholder like ``"anonymous"``: its last 4 characters would become
|
|
125
|
+
the tail and cluster unrelated callers together.
|
|
126
|
+
"""
|
|
127
|
+
return mask(api_key)
|
|
128
|
+
|
|
129
|
+
def setup(self, callback: Callable) -> Callable:
|
|
130
|
+
"""Register the per-request callback. Usable as a decorator.
|
|
131
|
+
|
|
132
|
+
The callback receives the framework-native request object and returns
|
|
133
|
+
a dict: ``api_key`` (masked), ``owner`` (``id`` plus ``enrich``), and
|
|
134
|
+
optionally ``block``.
|
|
135
|
+
"""
|
|
136
|
+
self.engine.set_callback(callback)
|
|
137
|
+
return callback
|
|
138
|
+
|
|
139
|
+
def flush(self) -> None:
|
|
140
|
+
"""Force-upload the queued batch. Call before process exit if you
|
|
141
|
+
care about in-flight captures."""
|
|
142
|
+
self.engine.flush()
|
|
143
|
+
|
|
144
|
+
# ------------------------------------------------------------- adapters
|
|
145
|
+
|
|
146
|
+
def wsgi(self, app: Callable) -> Callable:
|
|
147
|
+
"""Wrap a WSGI app (Flask, Django, Pyramid, Bottle)."""
|
|
148
|
+
from .adapters.wsgi import wrap_wsgi
|
|
149
|
+
|
|
150
|
+
return wrap_wsgi(app, self.engine)
|
|
151
|
+
|
|
152
|
+
def asgi(self, app: Callable) -> Callable:
|
|
153
|
+
"""Wrap an ASGI app (FastAPI, Starlette, Quart)."""
|
|
154
|
+
from .adapters.asgi import wrap_asgi
|
|
155
|
+
|
|
156
|
+
return wrap_asgi(app, self.engine)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def restless(api_key: Optional[str] = None, **kwargs: Any) -> Restless:
|
|
160
|
+
"""Functional constructor, mirroring the Node SDK's ``restless(key)``."""
|
|
161
|
+
return Restless(api_key, **kwargs)
|
restless/_caches.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""In-process caches.
|
|
2
|
+
|
|
3
|
+
Implements CONTRACT.md section 11.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
# CACHE-004, CACHE-014
|
|
10
|
+
_ENRICH_TTL_MS = 60 * 60 * 1000
|
|
11
|
+
_RECOVERY_TTL_MS = 60 * 60 * 1000
|
|
12
|
+
_RECOVERY_NEGATIVE_TTL_MS = 5 * 60 * 1000
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _now_ms() -> float:
|
|
16
|
+
return time.monotonic() * 1000.0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EnrichCache:
|
|
20
|
+
"""CACHE-001..007.
|
|
21
|
+
|
|
22
|
+
Stores the enriched VALUE, not merely a freshness flag (CACHE-003). Every
|
|
23
|
+
upload has to carry owner metadata, including uploads that skipped the
|
|
24
|
+
callback, because the ingest cannot backfill it: without the value, every
|
|
25
|
+
request after the first would land in the dashboard as unauthenticated.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, ttl_ms: float = _ENRICH_TTL_MS):
|
|
29
|
+
self._cache: Dict[str, Any] = {}
|
|
30
|
+
self._ttl_ms = ttl_ms
|
|
31
|
+
|
|
32
|
+
def get(self, key: str) -> Optional[Dict[str, Any]]:
|
|
33
|
+
entry = self._cache.get(key)
|
|
34
|
+
if entry is None:
|
|
35
|
+
return None
|
|
36
|
+
value, ts = entry
|
|
37
|
+
if _now_ms() - ts > self._ttl_ms:
|
|
38
|
+
self._cache.pop(key, None)
|
|
39
|
+
return None
|
|
40
|
+
return value
|
|
41
|
+
|
|
42
|
+
def set(self, key: str, value: Dict[str, Any]) -> None:
|
|
43
|
+
self._cache[key] = (value, _now_ms())
|
|
44
|
+
|
|
45
|
+
def invalidate(self, key: str) -> None:
|
|
46
|
+
"""CACHE-006. Next request from this key re-runs enrich."""
|
|
47
|
+
self._cache.pop(key, None)
|
|
48
|
+
|
|
49
|
+
def clear(self) -> None:
|
|
50
|
+
self._cache.clear()
|
|
51
|
+
|
|
52
|
+
def size(self) -> int:
|
|
53
|
+
return len(self._cache)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class RecoveryCache:
|
|
57
|
+
"""CACHE-010..015. Fingerprint key to recovery message.
|
|
58
|
+
|
|
59
|
+
Every read is synchronous and in-process. The lookup sits on the hot path
|
|
60
|
+
of every 4xx/5xx, so it never performs I/O and never blocks the response;
|
|
61
|
+
a cold miss simply injects nothing.
|
|
62
|
+
|
|
63
|
+
"No message for this fingerprint" is itself cached (as ``None``) so a cold
|
|
64
|
+
miss does not stay a cold miss on every subsequent request. The negative
|
|
65
|
+
TTL is shorter so a freshly-attached dashboard message starts working
|
|
66
|
+
within minutes.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
ttl_ms: float = _RECOVERY_TTL_MS,
|
|
72
|
+
negative_ttl_ms: float = _RECOVERY_NEGATIVE_TTL_MS,
|
|
73
|
+
):
|
|
74
|
+
self._cache: Dict[str, Any] = {}
|
|
75
|
+
self._ttl_ms = ttl_ms
|
|
76
|
+
self._negative_ttl_ms = negative_ttl_ms
|
|
77
|
+
|
|
78
|
+
_MISS = object()
|
|
79
|
+
|
|
80
|
+
def get(self, key: str) -> Any:
|
|
81
|
+
"""Returns the message, ``None`` for a confirmed absence, or ``_MISS``.
|
|
82
|
+
|
|
83
|
+
The three-way result matters: ``None`` means the server told us there
|
|
84
|
+
is no message (do not ask again yet), while ``_MISS`` means we have
|
|
85
|
+
never asked.
|
|
86
|
+
"""
|
|
87
|
+
entry = self._cache.get(key)
|
|
88
|
+
if entry is None:
|
|
89
|
+
return self._MISS
|
|
90
|
+
message, ts = entry
|
|
91
|
+
ttl = self._negative_ttl_ms if message is None else self._ttl_ms
|
|
92
|
+
if _now_ms() - ts > ttl:
|
|
93
|
+
self._cache.pop(key, None)
|
|
94
|
+
return self._MISS
|
|
95
|
+
return message
|
|
96
|
+
|
|
97
|
+
def set(self, key: str, message: Optional[str]) -> None:
|
|
98
|
+
self._cache[key] = (message, _now_ms())
|
|
99
|
+
|
|
100
|
+
def lookup(self, key: str) -> Optional[str]:
|
|
101
|
+
"""CACHE-010. The hot-path read: a message to inject, or None."""
|
|
102
|
+
value = self.get(key)
|
|
103
|
+
return value if isinstance(value, str) else None
|
|
104
|
+
|
|
105
|
+
def invalidate(self, key: str) -> None:
|
|
106
|
+
self._cache.pop(key, None)
|
|
107
|
+
|
|
108
|
+
def clear(self) -> None:
|
|
109
|
+
self._cache.clear()
|
|
110
|
+
|
|
111
|
+
def size(self) -> int:
|
|
112
|
+
return len(self._cache)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Blocklist:
|
|
116
|
+
"""Masked keys to reject. O(1) lookup, per-process state."""
|
|
117
|
+
|
|
118
|
+
def __init__(self) -> None:
|
|
119
|
+
self._blocked = set()
|
|
120
|
+
|
|
121
|
+
def replace(self, masked_keys) -> None:
|
|
122
|
+
self._blocked = set(masked_keys)
|
|
123
|
+
|
|
124
|
+
def has(self, masked_key: Optional[str]) -> bool:
|
|
125
|
+
return bool(masked_key) and masked_key in self._blocked
|
|
126
|
+
|
|
127
|
+
def size(self) -> int:
|
|
128
|
+
return len(self._blocked)
|
restless/_capture.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""The capture engine: the single choke point every adapter goes through.
|
|
2
|
+
|
|
3
|
+
Implements CONTRACT.md sections 10, 11 and 13.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import datetime
|
|
7
|
+
import inspect
|
|
8
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from ._caches import Blocklist, EnrichCache, RecoveryCache
|
|
11
|
+
from ._fingerprint import Fingerprint, fingerprint
|
|
12
|
+
from ._injection import apply_internal_body_mods, build_debug_injection
|
|
13
|
+
from ._redact import redact_body, redact_headers, redact_url, truncate_body
|
|
14
|
+
from ._uploader import Uploader, _debug
|
|
15
|
+
|
|
16
|
+
#: REDACT-030. 256 KiB, in UTF-8 bytes.
|
|
17
|
+
MAX_BODY_BYTES = 256 * 1024
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def now_iso() -> str:
|
|
21
|
+
"""PRIM-040. Exactly three fractional digits and a literal Z.
|
|
22
|
+
|
|
23
|
+
NOT ``datetime.isoformat()``, which emits microseconds and ``+00:00``.
|
|
24
|
+
The ingest parses this permissively and silently falls back to server
|
|
25
|
+
time when it cannot, so a wrong format loses real request timing with no
|
|
26
|
+
error anywhere in the system.
|
|
27
|
+
"""
|
|
28
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
29
|
+
return "{}.{:03d}Z".format(now.strftime("%Y-%m-%dT%H:%M:%S"), now.microsecond // 1000)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CaptureEngine:
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
api_key: str,
|
|
36
|
+
base_url: str,
|
|
37
|
+
request_id_prefix: Optional[str] = None,
|
|
38
|
+
redact: Optional[Dict[str, List[str]]] = None,
|
|
39
|
+
transport: Optional[Callable] = None,
|
|
40
|
+
):
|
|
41
|
+
self.blocklist = Blocklist()
|
|
42
|
+
self.enrich_cache = EnrichCache()
|
|
43
|
+
self.recovery_cache = RecoveryCache()
|
|
44
|
+
self._redact = redact or {}
|
|
45
|
+
self._callback: Optional[Callable] = None
|
|
46
|
+
self._docs_url: Optional[str] = None
|
|
47
|
+
self.uploader = Uploader(
|
|
48
|
+
api_key=api_key,
|
|
49
|
+
base_url=base_url,
|
|
50
|
+
request_id_prefix=request_id_prefix,
|
|
51
|
+
on_response=self._handle_server_response,
|
|
52
|
+
transport=transport,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------- setup
|
|
56
|
+
|
|
57
|
+
def set_callback(self, callback: Callable) -> None:
|
|
58
|
+
self._callback = callback
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def docs_url(self) -> Optional[str]:
|
|
62
|
+
"""INJECT-006. Server-learned origin for injected log links."""
|
|
63
|
+
return self._docs_url
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def request_id_prefix(self) -> Optional[str]:
|
|
67
|
+
return self.uploader.request_id_prefix
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def base_url(self) -> str:
|
|
71
|
+
return self.uploader.base_url
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def has_api_key(self) -> bool:
|
|
75
|
+
return self.uploader.has_api_key
|
|
76
|
+
|
|
77
|
+
# ------------------------------------------------------- server signals
|
|
78
|
+
|
|
79
|
+
def _handle_server_response(self, body: Any, batch_fingerprints: List[str]) -> None:
|
|
80
|
+
"""WIRE-021..023, CACHE-006, CACHE-012, CACHE-013."""
|
|
81
|
+
if not isinstance(body, dict):
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
needs = body.get("needsEnrichment")
|
|
85
|
+
if isinstance(needs, list):
|
|
86
|
+
for key in needs:
|
|
87
|
+
if isinstance(key, str):
|
|
88
|
+
self.enrich_cache.invalidate(key)
|
|
89
|
+
|
|
90
|
+
docs = body.get("docsUrl")
|
|
91
|
+
if isinstance(docs, str) and docs:
|
|
92
|
+
self._docs_url = docs.rstrip("/")
|
|
93
|
+
|
|
94
|
+
messages = body.get("recoveryMessages")
|
|
95
|
+
if not isinstance(messages, dict):
|
|
96
|
+
messages = {}
|
|
97
|
+
for key in batch_fingerprints:
|
|
98
|
+
value = messages.get(key, ...)
|
|
99
|
+
if isinstance(value, str):
|
|
100
|
+
self.recovery_cache.set(key, value)
|
|
101
|
+
elif value is None and key in messages:
|
|
102
|
+
self.recovery_cache.set(key, None)
|
|
103
|
+
elif self.recovery_cache.get(key) is RecoveryCache._MISS:
|
|
104
|
+
# CACHE-012 with CACHE-013: negative-cache what the server
|
|
105
|
+
# did not answer, but never clobber a positive entry.
|
|
106
|
+
self.recovery_cache.set(key, None)
|
|
107
|
+
|
|
108
|
+
def lookup_recovery(self, fingerprint_key: str) -> Optional[str]:
|
|
109
|
+
"""CACHE-010. Synchronous, in-process, never touches the network."""
|
|
110
|
+
return self.recovery_cache.lookup(fingerprint_key)
|
|
111
|
+
|
|
112
|
+
# ----------------------------------------------------------- per-request
|
|
113
|
+
|
|
114
|
+
def compute_fingerprint(self, captured: Dict[str, Any]) -> Optional[Fingerprint]:
|
|
115
|
+
"""FP-002. Errors only."""
|
|
116
|
+
response = captured["response"]
|
|
117
|
+
if response["status"] < 400:
|
|
118
|
+
return None
|
|
119
|
+
parsed: Any = response.get("body")
|
|
120
|
+
if isinstance(parsed, str):
|
|
121
|
+
try:
|
|
122
|
+
import json
|
|
123
|
+
|
|
124
|
+
parsed = json.loads(parsed)
|
|
125
|
+
except ValueError:
|
|
126
|
+
pass # fingerprint() handles a raw string too
|
|
127
|
+
return fingerprint(
|
|
128
|
+
status=response["status"],
|
|
129
|
+
method=captured["request"]["method"],
|
|
130
|
+
route=captured.get("routePattern"),
|
|
131
|
+
response_headers=response.get("headers"),
|
|
132
|
+
response_body=parsed,
|
|
133
|
+
stack_trace=captured.get("stackTrace"),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def resolve(self, request: Any) -> Dict[str, Any]:
|
|
137
|
+
"""SETUP-001..005, CACHE-001..007, SAFETY-002."""
|
|
138
|
+
if self._callback is None:
|
|
139
|
+
return {}
|
|
140
|
+
try:
|
|
141
|
+
result = self._callback(request) or {}
|
|
142
|
+
except Exception: # noqa: BLE001 - SAFETY-002
|
|
143
|
+
return {}
|
|
144
|
+
if not isinstance(result, dict):
|
|
145
|
+
return {}
|
|
146
|
+
|
|
147
|
+
rest = {k: v for k, v in result.items() if k not in ("owner", "project")}
|
|
148
|
+
raw = result.get("owner") or result.get("project")
|
|
149
|
+
if not raw:
|
|
150
|
+
return rest
|
|
151
|
+
|
|
152
|
+
# SETUP-003. `id` is the only field carried off the raw owner; all
|
|
153
|
+
# other metadata flows through enrich.
|
|
154
|
+
owner_id = raw.get("id")
|
|
155
|
+
enrich = raw.get("enrich")
|
|
156
|
+
base = {"id": owner_id} if owner_id is not None else {}
|
|
157
|
+
cache_key = owner_id or rest.get("apiKey")
|
|
158
|
+
|
|
159
|
+
if callable(enrich) and owner_id and cache_key:
|
|
160
|
+
cached = self.enrich_cache.get(cache_key)
|
|
161
|
+
if cached is not None:
|
|
162
|
+
merged = dict(base)
|
|
163
|
+
merged.update(cached)
|
|
164
|
+
return dict(rest, project=merged)
|
|
165
|
+
try:
|
|
166
|
+
enriched = enrich(owner_id)
|
|
167
|
+
if inspect.isawaitable(enriched): # pragma: no cover
|
|
168
|
+
enriched = None # async enrich is resolved by the ASGI adapter
|
|
169
|
+
if isinstance(enriched, dict):
|
|
170
|
+
self.enrich_cache.set(cache_key, enriched)
|
|
171
|
+
merged = dict(base)
|
|
172
|
+
merged.update(enriched)
|
|
173
|
+
return dict(rest, project=merged)
|
|
174
|
+
except Exception: # noqa: BLE001 - CACHE-005, SAFETY-003
|
|
175
|
+
pass # failures are NOT cached; the next request retries
|
|
176
|
+
|
|
177
|
+
# CACHE-007. Ship the bare id so the dashboard can still group by it.
|
|
178
|
+
return dict(rest, project=base)
|
|
179
|
+
|
|
180
|
+
def record(self, captured: Dict[str, Any]) -> None:
|
|
181
|
+
"""The single choke point. Redact, truncate, fingerprint, enqueue."""
|
|
182
|
+
request = captured["request"]
|
|
183
|
+
response = captured["response"]
|
|
184
|
+
redact_opts = self._redact
|
|
185
|
+
|
|
186
|
+
sanitized = dict(captured)
|
|
187
|
+
sanitized["request"] = dict(
|
|
188
|
+
request,
|
|
189
|
+
url=redact_url(request["url"], redact_opts.get("queryParams")),
|
|
190
|
+
headers=redact_headers(request.get("headers") or {}, redact_opts.get("headers")),
|
|
191
|
+
# REDACT-033: truncation runs AFTER redaction, so a secret cannot
|
|
192
|
+
# survive by sitting past the byte limit.
|
|
193
|
+
body=truncate_body(
|
|
194
|
+
redact_body(
|
|
195
|
+
request.get("body"),
|
|
196
|
+
(request.get("headers") or {}).get("content-type"),
|
|
197
|
+
redact_opts.get("bodyKeys"),
|
|
198
|
+
),
|
|
199
|
+
MAX_BODY_BYTES,
|
|
200
|
+
),
|
|
201
|
+
)
|
|
202
|
+
sanitized["response"] = dict(
|
|
203
|
+
response,
|
|
204
|
+
headers=redact_headers(response.get("headers") or {}, redact_opts.get("headers")),
|
|
205
|
+
body=truncate_body(
|
|
206
|
+
redact_body(
|
|
207
|
+
response.get("body"),
|
|
208
|
+
(response.get("headers") or {}).get("content-type"),
|
|
209
|
+
redact_opts.get("bodyKeys"),
|
|
210
|
+
),
|
|
211
|
+
MAX_BODY_BYTES,
|
|
212
|
+
),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# INJECT-010. Reuse the fingerprint the adapter already computed for
|
|
216
|
+
# its recovery lookup rather than repeating the work.
|
|
217
|
+
if not sanitized.get("errorFingerprint") and response["status"] >= 400:
|
|
218
|
+
fp = self.compute_fingerprint(sanitized)
|
|
219
|
+
if fp is not None:
|
|
220
|
+
sanitized["errorFingerprint"] = fp.to_wire()
|
|
221
|
+
sanitized.pop("stackTrace", None)
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
self.uploader.push(sanitized)
|
|
225
|
+
except Exception as err: # noqa: BLE001 - SAFETY-001
|
|
226
|
+
_debug("record failed:", err)
|
|
227
|
+
|
|
228
|
+
def flush(self) -> None:
|
|
229
|
+
self.uploader.flush()
|
|
230
|
+
|
|
231
|
+
# ------------------------------------------------------------ injection
|
|
232
|
+
|
|
233
|
+
def build_injection(
|
|
234
|
+
self,
|
|
235
|
+
status: int,
|
|
236
|
+
request_id: str,
|
|
237
|
+
method: Optional[str],
|
|
238
|
+
route: Optional[str],
|
|
239
|
+
raw_body: Optional[str],
|
|
240
|
+
content_type: Optional[str],
|
|
241
|
+
response_headers: Dict[str, str],
|
|
242
|
+
):
|
|
243
|
+
"""INJECT-001..009. Returns (headers, new_body, fingerprint_wire).
|
|
244
|
+
|
|
245
|
+
The fingerprint is computed against the customer's RAW response,
|
|
246
|
+
before any injected header or body field is layered on (INJECT-009).
|
|
247
|
+
"""
|
|
248
|
+
if status < 400:
|
|
249
|
+
return {}, raw_body, None
|
|
250
|
+
|
|
251
|
+
fp = self.compute_fingerprint(
|
|
252
|
+
{
|
|
253
|
+
"request": {"method": method or "GET", "url": "", "headers": {}},
|
|
254
|
+
"response": {
|
|
255
|
+
"status": status,
|
|
256
|
+
"headers": response_headers,
|
|
257
|
+
"body": raw_body,
|
|
258
|
+
},
|
|
259
|
+
"routePattern": route,
|
|
260
|
+
}
|
|
261
|
+
)
|
|
262
|
+
recovery = self.lookup_recovery(fp.key) if fp else None
|
|
263
|
+
headers, mutate = build_debug_injection(
|
|
264
|
+
status=status,
|
|
265
|
+
request_id=request_id,
|
|
266
|
+
base_url=self.base_url,
|
|
267
|
+
prefix=self.request_id_prefix,
|
|
268
|
+
recovery=recovery,
|
|
269
|
+
method=method,
|
|
270
|
+
path=route,
|
|
271
|
+
docs_url=self.docs_url,
|
|
272
|
+
)
|
|
273
|
+
new_body = apply_internal_body_mods(raw_body, content_type, mutate)
|
|
274
|
+
return headers, new_body, (fp.to_wire() if fp else None)
|
|
File without changes
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Python conformance driver. See node-sdk/spec/driver/PROTOCOL.md.
|
|
2
|
+
|
|
3
|
+
Dev-only: not part of the public API, not imported by customer code.
|
|
4
|
+
|
|
5
|
+
python -m restless._conformance
|
|
6
|
+
|
|
7
|
+
Reads JSON Lines on stdin, writes JSON Lines on stdout. The body is a thin
|
|
8
|
+
shell over the real modules in ``restless``; anything it reimplemented would
|
|
9
|
+
be testing itself instead of the SDK.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import sys
|
|
15
|
+
from typing import Any, Dict
|
|
16
|
+
|
|
17
|
+
from .._fingerprint import (
|
|
18
|
+
fingerprint,
|
|
19
|
+
normalize_message,
|
|
20
|
+
normalize_route,
|
|
21
|
+
project_relative,
|
|
22
|
+
)
|
|
23
|
+
from .._har import to_har_entry
|
|
24
|
+
from .._injection import recovery_slug
|
|
25
|
+
from .._mask import mask
|
|
26
|
+
from .._redact import (
|
|
27
|
+
redact_body,
|
|
28
|
+
redact_headers,
|
|
29
|
+
redact_url,
|
|
30
|
+
redact_value,
|
|
31
|
+
truncate_body,
|
|
32
|
+
)
|
|
33
|
+
from .._request_id import (
|
|
34
|
+
format_request_id,
|
|
35
|
+
request_id_response_headers,
|
|
36
|
+
strip_request_id_prefix,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# FP-044. The stack strategy's frame format is language-specific: only the
|
|
40
|
+
# OUTPUT shape is contract surface. The shared vectors carry v8-shaped
|
|
41
|
+
# stacks, which a Python traceback parser cannot read, so we report those
|
|
42
|
+
# cases as an unsupported dialect. The harness records them as SKIPPED
|
|
43
|
+
# rather than failed, and the SDK covers Python tracebacks in its own
|
|
44
|
+
# tests (tests/test_stack_frames.py).
|
|
45
|
+
_V8_FRAME_RE = re.compile(r"^\s*at\s+\S", re.MULTILINE)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class UnsupportedDialect(Exception):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _op_fingerprint(i: Dict[str, Any]) -> Dict[str, str]:
|
|
53
|
+
stack = i.get("stackTrace")
|
|
54
|
+
if stack:
|
|
55
|
+
text = stack if isinstance(stack, str) else "\n".join(str(s) for s in stack)
|
|
56
|
+
if _V8_FRAME_RE.search(text):
|
|
57
|
+
raise UnsupportedDialect(
|
|
58
|
+
"unsupported stack dialect: v8 (this SDK parses Python tracebacks; FP-044)"
|
|
59
|
+
)
|
|
60
|
+
fp = fingerprint(
|
|
61
|
+
status=i["status"],
|
|
62
|
+
method=i.get("method"),
|
|
63
|
+
route=i.get("route"),
|
|
64
|
+
response_headers=i.get("responseHeaders"),
|
|
65
|
+
response_body=i.get("responseBody"),
|
|
66
|
+
stack_trace=stack,
|
|
67
|
+
)
|
|
68
|
+
# FP-003: `reason` is prose, not contract surface.
|
|
69
|
+
return {"strategy": fp.strategy, "key": fp.key}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
OPS = {
|
|
73
|
+
"mask": lambda i: mask(i.get("apiKey")),
|
|
74
|
+
"redactValue": lambda i: redact_value(i["value"]),
|
|
75
|
+
"redactHeaders": lambda i: redact_headers(i["headers"], i.get("extra")),
|
|
76
|
+
"redactUrl": lambda i: redact_url(i["url"], i.get("extra")),
|
|
77
|
+
"redactBody": lambda i: redact_body(
|
|
78
|
+
i.get("body"), i.get("contentType"), i.get("extra")
|
|
79
|
+
),
|
|
80
|
+
"truncateBody": lambda i: truncate_body(i.get("body"), i["maxBytes"]),
|
|
81
|
+
"fingerprint": _op_fingerprint,
|
|
82
|
+
"normalizeRoute": lambda i: normalize_route(i.get("route")),
|
|
83
|
+
"normalizeMessage": lambda i: normalize_message(i.get("message")),
|
|
84
|
+
"projectRelative": lambda i: project_relative(i["file"]),
|
|
85
|
+
"formatRequestId": lambda i: format_request_id(i["rawId"], i.get("prefix")),
|
|
86
|
+
"stripRequestIdPrefix": lambda i: strip_request_id_prefix(i["requestId"]),
|
|
87
|
+
"requestIdHeaders": lambda i: request_id_response_headers(
|
|
88
|
+
i["ourId"], i.get("incomingHeaders") or {}, i.get("prefix"), i["hasApiKey"]
|
|
89
|
+
),
|
|
90
|
+
"recoverySlug": lambda i: recovery_slug(i.get("method"), i.get("path")),
|
|
91
|
+
"harEntry": lambda i: to_har_entry(i["captured"]),
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def main() -> int:
|
|
96
|
+
for line in sys.stdin:
|
|
97
|
+
line = line.strip()
|
|
98
|
+
if not line:
|
|
99
|
+
continue
|
|
100
|
+
req_id = None
|
|
101
|
+
try:
|
|
102
|
+
req = json.loads(line)
|
|
103
|
+
req_id = req.get("id")
|
|
104
|
+
op = req.get("op")
|
|
105
|
+
fn = OPS.get(op)
|
|
106
|
+
if fn is None:
|
|
107
|
+
raise KeyError("unknown op: {}".format(op))
|
|
108
|
+
out = {"id": req_id, "result": fn(req.get("input") or {})}
|
|
109
|
+
except Exception as err: # noqa: BLE001 - the protocol reports errors
|
|
110
|
+
out = {"id": req_id, "error": "{}".format(err)}
|
|
111
|
+
# ensure_ascii=True here, deliberately. This is the DRIVER's own
|
|
112
|
+
# transport, not a captured body: PRIM-032's "emit UTF-8 literally"
|
|
113
|
+
# governs payloads, while stdout has to survive values the fuzzer
|
|
114
|
+
# generates - an unpaired surrogate cannot be UTF-8 encoded at all
|
|
115
|
+
# and would kill the process mid-run. JSON \uXXXX escapes carry it
|
|
116
|
+
# losslessly and the harness decodes it back.
|
|
117
|
+
sys.stdout.write(
|
|
118
|
+
json.dumps(out, separators=(",", ":"), ensure_ascii=True) + "\n"
|
|
119
|
+
)
|
|
120
|
+
sys.stdout.flush()
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
if __name__ == "__main__":
|
|
125
|
+
sys.exit(main())
|