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 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())