restless-sdk 0.1.0__tar.gz

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 (34) hide show
  1. restless_sdk-0.1.0/LICENSE +18 -0
  2. restless_sdk-0.1.0/PKG-INFO +125 -0
  3. restless_sdk-0.1.0/README.md +112 -0
  4. restless_sdk-0.1.0/pyproject.toml +29 -0
  5. restless_sdk-0.1.0/setup.cfg +4 -0
  6. restless_sdk-0.1.0/src/restless/__init__.py +161 -0
  7. restless_sdk-0.1.0/src/restless/_caches.py +128 -0
  8. restless_sdk-0.1.0/src/restless/_capture.py +274 -0
  9. restless_sdk-0.1.0/src/restless/_conformance/__init__.py +0 -0
  10. restless_sdk-0.1.0/src/restless/_conformance/__main__.py +125 -0
  11. restless_sdk-0.1.0/src/restless/_fingerprint.py +342 -0
  12. restless_sdk-0.1.0/src/restless/_har.py +133 -0
  13. restless_sdk-0.1.0/src/restless/_injection.py +114 -0
  14. restless_sdk-0.1.0/src/restless/_mask.py +58 -0
  15. restless_sdk-0.1.0/src/restless/_redact.py +338 -0
  16. restless_sdk-0.1.0/src/restless/_request.py +136 -0
  17. restless_sdk-0.1.0/src/restless/_request_id.py +64 -0
  18. restless_sdk-0.1.0/src/restless/_settings.py +91 -0
  19. restless_sdk-0.1.0/src/restless/_text.py +63 -0
  20. restless_sdk-0.1.0/src/restless/_uploader.py +247 -0
  21. restless_sdk-0.1.0/src/restless/_version.py +15 -0
  22. restless_sdk-0.1.0/src/restless/adapters/__init__.py +0 -0
  23. restless_sdk-0.1.0/src/restless/adapters/asgi.py +232 -0
  24. restless_sdk-0.1.0/src/restless/adapters/wsgi.py +268 -0
  25. restless_sdk-0.1.0/src/restless/install.md +427 -0
  26. restless_sdk-0.1.0/src/restless/py.typed +0 -0
  27. restless_sdk-0.1.0/src/restless_sdk.egg-info/PKG-INFO +125 -0
  28. restless_sdk-0.1.0/src/restless_sdk.egg-info/SOURCES.txt +32 -0
  29. restless_sdk-0.1.0/src/restless_sdk.egg-info/dependency_links.txt +1 -0
  30. restless_sdk-0.1.0/src/restless_sdk.egg-info/top_level.txt +1 -0
  31. restless_sdk-0.1.0/tests/test_redact_unicode_names.py +56 -0
  32. restless_sdk-0.1.0/tests/test_request_info.py +187 -0
  33. restless_sdk-0.1.0/tests/test_stack_frames.py +135 -0
  34. restless_sdk-0.1.0/tests/test_vectors.py +102 -0
@@ -0,0 +1,18 @@
1
+ Copyright © 2026 Restless
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
4
+ this software and associated documentation files (the “Software”), to deal in
5
+ the Software without restriction, including without limitation the rights to
6
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
7
+ the Software, and to permit persons to whom the Software is furnished to do so,
8
+ subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
15
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
16
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
17
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, OUT OF OR IN CONNECTION WITH THE
18
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.4
2
+ Name: restless-sdk
3
+ Version: 0.1.0
4
+ Summary: Capture API traffic and send it to Restless. WSGI and ASGI: Flask, Django, FastAPI, Starlette.
5
+ Author: Restless
6
+ License: MIT
7
+ Project-URL: Homepage, https://restless.ai
8
+ Keywords: restless,api,logging,observability,wsgi,asgi,flask,fastapi
9
+ Requires-Python: >=3.8
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Dynamic: license-file
13
+
14
+ # restless-sdk
15
+
16
+ Capture your API traffic and send it to [Restless](https://restless.ai).
17
+
18
+ Works with any **WSGI** app (Flask, Django, Pyramid, Bottle) or **ASGI** app
19
+ (FastAPI, Starlette, Quart). Python 3.8+. No dependencies.
20
+
21
+ ## Install
22
+
23
+ ```sh
24
+ pip install restless-sdk
25
+ ```
26
+
27
+ ## Use
28
+
29
+ ```python
30
+ import os
31
+ import restless
32
+
33
+ client = restless.Restless(os.environ["RESTLESS_KEY"])
34
+
35
+ @client.setup
36
+ def _(request):
37
+ return {
38
+ "api_key": client.mask(request.headers.get("authorization")),
39
+ "owner": {
40
+ # Permanent and immutable. A workspace id or database primary
41
+ # key, never an API key, email, or anything else that rotates:
42
+ # the dashboard pins a project's whole log history to this.
43
+ "id": workspace_id_for(request),
44
+ # Runs once per owner id, then caches. Put the expensive lookup
45
+ # here, not in the fields above.
46
+ "enrich": lambda owner_id: {
47
+ "label": db.workspaces.get(owner_id).name,
48
+ "email": db.workspaces.get(owner_id).admin_emails,
49
+ },
50
+ },
51
+ }
52
+ ```
53
+
54
+ Then wrap your app:
55
+
56
+ ```python
57
+ # Flask / Django / any WSGI
58
+ app.wsgi_app = client.wsgi(app.wsgi_app)
59
+
60
+ # FastAPI / Starlette / any ASGI
61
+ app = client.asgi(app)
62
+ ```
63
+
64
+ ## The request view
65
+
66
+ The callback receives a read-only `RequestInfo`, identical under WSGI and
67
+ ASGI, so the same callback works whichever you are on.
68
+
69
+ | accessor | what |
70
+ |---|---|
71
+ | `request.header(name)` | One header, case-insensitive. `request["authorization"]` is an alias. |
72
+ | `request.headers` | All of them, as a case-insensitive mapping. |
73
+ | `request.method` | `"GET"`, `"POST"`, ... |
74
+ | `request.path` | Path, including any mount prefix. |
75
+ | `request.query_string` | Raw query string, without the `?`. |
76
+ | `request.url` | Full URL, as the capture records it. |
77
+ | `request.environ` / `request.scope` | The raw WSGI environ or ASGI scope; the other is `None`. |
78
+
79
+ Nothing is hidden by the wrapper: anything it does not model is still on
80
+ `request.environ` / `request.scope`.
81
+
82
+ `request.route` is normally `None` here. The callback runs before your
83
+ application, so no router has matched yet. The captured log still gets the
84
+ real route, read after the response.
85
+
86
+ ## What you get
87
+
88
+ - **Lazy owner enrichment.** The `enrich` callback runs on the first request
89
+ from each owner id and then caches, so 100 requests from one workspace do
90
+ not mean 100 database lookups.
91
+ - **Safe by default.** `Authorization`, `Cookie`, `password`, `token`, `ssn`
92
+ and friends are redacted before anything leaves your process. Bodies with
93
+ nothing to redact are passed through untouched, so your payloads are not
94
+ reserialized on the way out.
95
+ - **Error triage.** 4xx/5xx responses get `x-log-url` and `x-debug` headers
96
+ and a `debug` block in the JSON body. If someone attaches a "next steps"
97
+ message to an error in the dashboard, the SDK injects it as
98
+ `debug.recovery`, read synchronously from an in-process cache, never
99
+ blocking the response on a network call.
100
+ - **Blocking.** Return `{"block": True}` from the setup callback to reject a
101
+ request before your handler runs.
102
+
103
+ ## Never breaks your API
104
+
105
+ Observability must not take down a production request path. Upload failures,
106
+ callback exceptions, unserializable bodies and malformed input are all caught
107
+ and swallowed (surfaced only under `DEBUG=restless`). Uploads happen on a
108
+ background thread.
109
+
110
+ ## Environment variables
111
+
112
+ | variable | purpose |
113
+ |---|---|
114
+ | `RESTLESS_KEY` | Your project API key, if you do not pass one explicitly. |
115
+ | `RESTLESS_BASE_URL` | Override the ingest URL (self-hosted or staging). |
116
+ | `DEBUG=restless` | Print upload diagnostics to stderr. |
117
+
118
+ ## Conformance
119
+
120
+ This SDK implements version 1.0.0 of the Restless SDK Contract at level L2,
121
+ and is verified against the shared cross-language conformance vectors. See
122
+ [CONFORMANCE.md](./CONFORMANCE.md).
123
+
124
+ ## License
125
+ MIT
@@ -0,0 +1,112 @@
1
+ # restless-sdk
2
+
3
+ Capture your API traffic and send it to [Restless](https://restless.ai).
4
+
5
+ Works with any **WSGI** app (Flask, Django, Pyramid, Bottle) or **ASGI** app
6
+ (FastAPI, Starlette, Quart). Python 3.8+. No dependencies.
7
+
8
+ ## Install
9
+
10
+ ```sh
11
+ pip install restless-sdk
12
+ ```
13
+
14
+ ## Use
15
+
16
+ ```python
17
+ import os
18
+ import restless
19
+
20
+ client = restless.Restless(os.environ["RESTLESS_KEY"])
21
+
22
+ @client.setup
23
+ def _(request):
24
+ return {
25
+ "api_key": client.mask(request.headers.get("authorization")),
26
+ "owner": {
27
+ # Permanent and immutable. A workspace id or database primary
28
+ # key, never an API key, email, or anything else that rotates:
29
+ # the dashboard pins a project's whole log history to this.
30
+ "id": workspace_id_for(request),
31
+ # Runs once per owner id, then caches. Put the expensive lookup
32
+ # here, not in the fields above.
33
+ "enrich": lambda owner_id: {
34
+ "label": db.workspaces.get(owner_id).name,
35
+ "email": db.workspaces.get(owner_id).admin_emails,
36
+ },
37
+ },
38
+ }
39
+ ```
40
+
41
+ Then wrap your app:
42
+
43
+ ```python
44
+ # Flask / Django / any WSGI
45
+ app.wsgi_app = client.wsgi(app.wsgi_app)
46
+
47
+ # FastAPI / Starlette / any ASGI
48
+ app = client.asgi(app)
49
+ ```
50
+
51
+ ## The request view
52
+
53
+ The callback receives a read-only `RequestInfo`, identical under WSGI and
54
+ ASGI, so the same callback works whichever you are on.
55
+
56
+ | accessor | what |
57
+ |---|---|
58
+ | `request.header(name)` | One header, case-insensitive. `request["authorization"]` is an alias. |
59
+ | `request.headers` | All of them, as a case-insensitive mapping. |
60
+ | `request.method` | `"GET"`, `"POST"`, ... |
61
+ | `request.path` | Path, including any mount prefix. |
62
+ | `request.query_string` | Raw query string, without the `?`. |
63
+ | `request.url` | Full URL, as the capture records it. |
64
+ | `request.environ` / `request.scope` | The raw WSGI environ or ASGI scope; the other is `None`. |
65
+
66
+ Nothing is hidden by the wrapper: anything it does not model is still on
67
+ `request.environ` / `request.scope`.
68
+
69
+ `request.route` is normally `None` here. The callback runs before your
70
+ application, so no router has matched yet. The captured log still gets the
71
+ real route, read after the response.
72
+
73
+ ## What you get
74
+
75
+ - **Lazy owner enrichment.** The `enrich` callback runs on the first request
76
+ from each owner id and then caches, so 100 requests from one workspace do
77
+ not mean 100 database lookups.
78
+ - **Safe by default.** `Authorization`, `Cookie`, `password`, `token`, `ssn`
79
+ and friends are redacted before anything leaves your process. Bodies with
80
+ nothing to redact are passed through untouched, so your payloads are not
81
+ reserialized on the way out.
82
+ - **Error triage.** 4xx/5xx responses get `x-log-url` and `x-debug` headers
83
+ and a `debug` block in the JSON body. If someone attaches a "next steps"
84
+ message to an error in the dashboard, the SDK injects it as
85
+ `debug.recovery`, read synchronously from an in-process cache, never
86
+ blocking the response on a network call.
87
+ - **Blocking.** Return `{"block": True}` from the setup callback to reject a
88
+ request before your handler runs.
89
+
90
+ ## Never breaks your API
91
+
92
+ Observability must not take down a production request path. Upload failures,
93
+ callback exceptions, unserializable bodies and malformed input are all caught
94
+ and swallowed (surfaced only under `DEBUG=restless`). Uploads happen on a
95
+ background thread.
96
+
97
+ ## Environment variables
98
+
99
+ | variable | purpose |
100
+ |---|---|
101
+ | `RESTLESS_KEY` | Your project API key, if you do not pass one explicitly. |
102
+ | `RESTLESS_BASE_URL` | Override the ingest URL (self-hosted or staging). |
103
+ | `DEBUG=restless` | Print upload diagnostics to stderr. |
104
+
105
+ ## Conformance
106
+
107
+ This SDK implements version 1.0.0 of the Restless SDK Contract at level L2,
108
+ and is verified against the shared cross-language conformance vectors. See
109
+ [CONFORMANCE.md](./CONFORMANCE.md).
110
+
111
+ ## License
112
+ MIT
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "restless-sdk"
7
+ version = "0.1.0"
8
+ description = "Capture API traffic and send it to Restless. WSGI and ASGI: Flask, Django, FastAPI, Starlette."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Restless" }]
13
+ keywords = ["restless", "api", "logging", "observability", "wsgi", "asgi", "flask", "fastapi"]
14
+ dependencies = []
15
+
16
+ [project.urls]
17
+ Homepage = "https://restless.ai"
18
+
19
+ [tool.setuptools.packages.find]
20
+ where = ["src"]
21
+
22
+ [tool.setuptools.package-data]
23
+ # install.md ships INSIDE the package, not just in the sdist. The `api` CLI
24
+ # points installing agents at it with
25
+ # python -c "import restless, pathlib; print(pathlib.Path(restless.__file__).parent)"
26
+ # and pip installs the wheel, so a root-level file (or a MANIFEST.in entry,
27
+ # which only reaches the sdist) would leave that pointer resolving to nothing.
28
+ # The Node SDK ships its equivalent the same way, via package.json `files`.
29
+ restless = ["py.typed", "install.md"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)
@@ -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)