@lovrozagar/honey 0.1.0
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.
- package/README.md +51 -0
- package/docs/api-ref/go.md +399 -0
- package/docs/api-ref/python.md +399 -0
- package/docs/api-ref/rust.md +399 -0
- package/docs/api-ref/typescript.md +399 -0
- package/docs/invalidate-spec.md +303 -0
- package/docs/sdk.md +722 -0
- package/package.json +120 -0
- package/src/accepts.ts +78 -0
- package/src/body-limit.ts +100 -0
- package/src/build/index.ts +174 -0
- package/src/cli-go/auth.go +15 -0
- package/src/cli-go/config.go +165 -0
- package/src/cli-go/errors.go +57 -0
- package/src/cli-go/multipart.go +55 -0
- package/src/cli-go/output.go +109 -0
- package/src/cli-go/root.go +30 -0
- package/src/cli-go/stream.go +88 -0
- package/src/cli-go/version.go +5 -0
- package/src/cli.ts +204 -0
- package/src/client/error.ts +27 -0
- package/src/client/http.ts +502 -0
- package/src/client/index.ts +127 -0
- package/src/client/sdk.ts +265 -0
- package/src/client/sse.ts +102 -0
- package/src/client/types.ts +211 -0
- package/src/client/ws.ts +115 -0
- package/src/client-go/errors.go +207 -0
- package/src/client-go/invalidation.go +373 -0
- package/src/client-go/realtime.go +317 -0
- package/src/client-go/result.go +20 -0
- package/src/client-go/runtime.go +413 -0
- package/src/client-go/sse.go +149 -0
- package/src/client-go/transport.go +452 -0
- package/src/client-go/ws.go +104 -0
- package/src/client-mcp/README.md +10 -0
- package/src/client-mcp/server.ts +94 -0
- package/src/client-mcp/types.ts +19 -0
- package/src/client-python/_errors.py +115 -0
- package/src/client-python/_invalidation.py +578 -0
- package/src/client-python/_realtime.py +156 -0
- package/src/client-python/_runtime.py +469 -0
- package/src/client-python/_sse.py +99 -0
- package/src/client-python/_transport.py +335 -0
- package/src/client-python/_ws.py +131 -0
- package/src/client-python/py.typed +0 -0
- package/src/client-rust/errors.rs +200 -0
- package/src/client-rust/invalidation.rs +441 -0
- package/src/client-rust/invalidation_sync.rs +306 -0
- package/src/client-rust/realtime.rs +695 -0
- package/src/client-rust/result.rs +22 -0
- package/src/client-rust/runtime.rs +547 -0
- package/src/client-rust/runtime_sync.rs +452 -0
- package/src/client-rust/sse.rs +167 -0
- package/src/client-rust/ws.rs +143 -0
- package/src/cloudflare/realtime.ts +236 -0
- package/src/codegen-go-cli.ts +1180 -0
- package/src/codegen-go.ts +1010 -0
- package/src/codegen-hash.ts +8 -0
- package/src/codegen-ir.ts +517 -0
- package/src/codegen-loaders.ts +53 -0
- package/src/codegen-mcp.ts +364 -0
- package/src/codegen-python.ts +1028 -0
- package/src/codegen-rust.ts +1652 -0
- package/src/codegen-sanitize.ts +67 -0
- package/src/codegen-schema-naming.ts +146 -0
- package/src/codegen.ts +5015 -0
- package/src/context.ts +210 -0
- package/src/cookie-sign.ts +58 -0
- package/src/cookie.ts +89 -0
- package/src/cors.ts +96 -0
- package/src/crypto.ts +19 -0
- package/src/csrf.ts +54 -0
- package/src/curl-logger.ts +235 -0
- package/src/detect-runtime.ts +19 -0
- package/src/error.ts +52 -0
- package/src/errors.ts +87 -0
- package/src/etag.ts +74 -0
- package/src/feature-load.ts +23 -0
- package/src/go-type-emitter.ts +404 -0
- package/src/honey-response.ts +354 -0
- package/src/i18n-slot.ts +20 -0
- package/src/i18n.ts +234 -0
- package/src/index.ts +3843 -0
- package/src/init.ts +149 -0
- package/src/input.ts +8 -0
- package/src/ip-restrict.ts +192 -0
- package/src/logger.ts +149 -0
- package/src/middleware.ts +199 -0
- package/src/node-request.ts +240 -0
- package/src/node.ts +212 -0
- package/src/openapi/register.ts +19 -0
- package/src/openapi/scalar.ts +33 -0
- package/src/openapi/spec-factory.ts +46 -0
- package/src/openapi/spec.ts +31 -0
- package/src/openapi/swagger.ts +34 -0
- package/src/plugin.ts +771 -0
- package/src/powered-by.ts +17 -0
- package/src/pretty-json.ts +39 -0
- package/src/proxy.ts +155 -0
- package/src/python-type-emitter.ts +144 -0
- package/src/realtime/buffer.ts +101 -0
- package/src/realtime/bus.ts +93 -0
- package/src/realtime/longpoll.ts +148 -0
- package/src/realtime/protocol.ts +128 -0
- package/src/realtime/route.ts +104 -0
- package/src/realtime/runtime.ts +430 -0
- package/src/request-id.ts +23 -0
- package/src/request-to-curl.ts +34 -0
- package/src/response.ts +414 -0
- package/src/rust-type-emitter.ts +412 -0
- package/src/secure-headers.ts +69 -0
- package/src/serve-register.ts +8 -0
- package/src/serve-slot.ts +20 -0
- package/src/serve.ts +141 -0
- package/src/server-timing.ts +66 -0
- package/src/static.ts +60 -0
- package/src/telemetry/otel.ts +139 -0
- package/src/testing.ts +114 -0
- package/src/timeout.ts +36 -0
- package/src/tree.ts +464 -0
- package/src/ts-type-emitter.ts +106 -0
- package/src/type-emitter.ts +524 -0
- package/src/type-extractor.ts +749 -0
- package/src/types.ts +638 -0
- package/src/validation.ts +300 -0
- package/src/ws/bun.ts +89 -0
- package/src/ws/cloudflare.ts +133 -0
- package/src/ws/deno.ts +109 -0
- package/src/ws/node.ts +161 -0
- package/src/yaml.ts +80 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class APIError(Exception):
|
|
9
|
+
"""Base class for all API errors."""
|
|
10
|
+
|
|
11
|
+
status: int
|
|
12
|
+
body: Any
|
|
13
|
+
data: Any
|
|
14
|
+
response: httpx.Response
|
|
15
|
+
message: str
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
*,
|
|
20
|
+
status: int,
|
|
21
|
+
body: Any,
|
|
22
|
+
data: Any,
|
|
23
|
+
response: httpx.Response,
|
|
24
|
+
message: str,
|
|
25
|
+
) -> None:
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.status = status
|
|
28
|
+
self.body = body
|
|
29
|
+
self.data = data
|
|
30
|
+
self.response = response
|
|
31
|
+
self.message = message
|
|
32
|
+
|
|
33
|
+
def __repr__(self) -> str:
|
|
34
|
+
cls = self.__class__.__name__
|
|
35
|
+
return f"{cls}(status={self.status}, message={self.message!r})"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BadRequestError(APIError):
|
|
39
|
+
"""HTTP 400 Bad Request."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class UnauthorizedError(APIError):
|
|
43
|
+
"""HTTP 401 Unauthorized."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ForbiddenError(APIError):
|
|
47
|
+
"""HTTP 403 Forbidden."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class NotFoundError(APIError):
|
|
51
|
+
"""HTTP 404 Not Found."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ConflictError(APIError):
|
|
55
|
+
"""HTTP 409 Conflict."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class UnprocessableEntityError(APIError):
|
|
59
|
+
"""HTTP 422 Unprocessable Entity."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class RateLimitError(APIError):
|
|
63
|
+
"""HTTP 429 Too Many Requests."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class InternalServerError(APIError):
|
|
67
|
+
"""HTTP 500 Internal Server Error."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class BadGatewayError(APIError):
|
|
71
|
+
"""HTTP 502 Bad Gateway."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ServiceUnavailableError(APIError):
|
|
75
|
+
"""HTTP 503 Service Unavailable."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class GatewayTimeoutError(APIError):
|
|
79
|
+
"""HTTP 504 Gateway Timeout."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class APIStatusError(APIError):
|
|
83
|
+
"""Fallback for any 4xx/5xx status not covered by a named subclass."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
_STATUS_ERROR_MAP: dict[int, type[APIError]] = {
|
|
87
|
+
400: BadRequestError,
|
|
88
|
+
401: UnauthorizedError,
|
|
89
|
+
403: ForbiddenError,
|
|
90
|
+
404: NotFoundError,
|
|
91
|
+
409: ConflictError,
|
|
92
|
+
422: UnprocessableEntityError,
|
|
93
|
+
429: RateLimitError,
|
|
94
|
+
500: InternalServerError,
|
|
95
|
+
502: BadGatewayError,
|
|
96
|
+
503: ServiceUnavailableError,
|
|
97
|
+
504: GatewayTimeoutError,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
__all__ = [
|
|
101
|
+
"APIError",
|
|
102
|
+
"APIStatusError",
|
|
103
|
+
"BadGatewayError",
|
|
104
|
+
"BadRequestError",
|
|
105
|
+
"ConflictError",
|
|
106
|
+
"ForbiddenError",
|
|
107
|
+
"GatewayTimeoutError",
|
|
108
|
+
"InternalServerError",
|
|
109
|
+
"NotFoundError",
|
|
110
|
+
"RateLimitError",
|
|
111
|
+
"ServiceUnavailableError",
|
|
112
|
+
"UnauthorizedError",
|
|
113
|
+
"UnprocessableEntityError",
|
|
114
|
+
"_STATUS_ERROR_MAP",
|
|
115
|
+
]
|
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import re
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from collections import OrderedDict
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _now() -> float:
|
|
14
|
+
return time.monotonic()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_PATH_PARAM_RE = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}|:([a-zA-Z_][a-zA-Z0-9_]*)")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class _StaleEntry:
|
|
22
|
+
by: list[str] = field(default_factory=list)
|
|
23
|
+
seq: int = 0
|
|
24
|
+
until: float = 0.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class _RequestMeta:
|
|
29
|
+
selector: str
|
|
30
|
+
is_stale: bool
|
|
31
|
+
invalidated_by: list[str]
|
|
32
|
+
seq_snapshot: int
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def path_matches_pattern(
|
|
36
|
+
path: str,
|
|
37
|
+
pattern: str,
|
|
38
|
+
_cache: dict[str, re.Pattern[str]] = {},
|
|
39
|
+
) -> bool:
|
|
40
|
+
"""Match *path* against *pattern* containing ``{name}`` / ``:name`` placeholders."""
|
|
41
|
+
if pattern not in _cache:
|
|
42
|
+
escaped_parts: list[str] = []
|
|
43
|
+
last_end = 0
|
|
44
|
+
for match in _PATH_PARAM_RE.finditer(pattern):
|
|
45
|
+
escaped_parts.append(re.escape(pattern[last_end : match.start()]))
|
|
46
|
+
escaped_parts.append("[^/]+")
|
|
47
|
+
last_end = match.end()
|
|
48
|
+
escaped_parts.append(re.escape(pattern[last_end:]))
|
|
49
|
+
_cache[pattern] = re.compile(f"^{''.join(escaped_parts)}$")
|
|
50
|
+
return bool(_cache[pattern].match(path))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def interpolate_path(template: str, params: dict[str, str] | None) -> str:
|
|
54
|
+
"""Substitute ``{name}`` / ``:name`` placeholders in *template* using *params*.
|
|
55
|
+
|
|
56
|
+
Raises ValueError if any placeholder has no matching key in *params*.
|
|
57
|
+
"""
|
|
58
|
+
if not params:
|
|
59
|
+
if _PATH_PARAM_RE.search(template) is not None:
|
|
60
|
+
raise ValueError(f"Missing path params for template {template!r}")
|
|
61
|
+
return template
|
|
62
|
+
|
|
63
|
+
def _sub(match: re.Match[str]) -> str:
|
|
64
|
+
key = match.group(1) or match.group(2)
|
|
65
|
+
if key not in params or params[key] is None:
|
|
66
|
+
raise ValueError(f"Missing path param: {key}")
|
|
67
|
+
return quote(str(params[key]), safe="")
|
|
68
|
+
|
|
69
|
+
return _PATH_PARAM_RE.sub(_sub, template)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _has_unresolved(value: str) -> bool:
|
|
73
|
+
return _PATH_PARAM_RE.search(value) is not None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def resolve_invalidation_targets_for_mutation(
|
|
77
|
+
targets: list[str],
|
|
78
|
+
params: dict[str, str] | None,
|
|
79
|
+
) -> list[str]:
|
|
80
|
+
"""Expand ``METHOD /path/{id}`` template invalidation targets to concrete selectors.
|
|
81
|
+
|
|
82
|
+
Targets without placeholders are passed through. Targets whose placeholders cannot
|
|
83
|
+
be resolved against *params* are dropped (no point storing a half-templated key).
|
|
84
|
+
"""
|
|
85
|
+
resolved: list[str] = []
|
|
86
|
+
for entry in targets:
|
|
87
|
+
space_idx = entry.find(" ")
|
|
88
|
+
if space_idx == -1:
|
|
89
|
+
resolved.append(entry)
|
|
90
|
+
continue
|
|
91
|
+
method = entry[:space_idx]
|
|
92
|
+
template = entry[space_idx + 1 :]
|
|
93
|
+
if not _has_unresolved(template):
|
|
94
|
+
resolved.append(entry)
|
|
95
|
+
continue
|
|
96
|
+
try:
|
|
97
|
+
concrete = interpolate_path(template, params)
|
|
98
|
+
except ValueError:
|
|
99
|
+
continue
|
|
100
|
+
resolved.append(f"{method} {concrete}")
|
|
101
|
+
return resolved
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def resolve_invalidation_targets(
|
|
105
|
+
invalidate: list[str],
|
|
106
|
+
stale_map: "OrderedDict[str, _StaleEntry] | OrderedDict[str, Any]",
|
|
107
|
+
) -> list[str]:
|
|
108
|
+
"""Return *stale_map* keys matching any 'METHOD /pattern' invalidation entry.
|
|
109
|
+
|
|
110
|
+
Kept for back-compat with existing call sites that pass an already-built map.
|
|
111
|
+
"""
|
|
112
|
+
matched: list[str] = []
|
|
113
|
+
for entry in invalidate:
|
|
114
|
+
for key in list(stale_map.keys()):
|
|
115
|
+
parts = key.split(" ", 1)
|
|
116
|
+
if len(parts) != 2:
|
|
117
|
+
continue
|
|
118
|
+
key_method, key_path = parts
|
|
119
|
+
entry_parts = entry.split(" ", 1)
|
|
120
|
+
if len(entry_parts) != 2:
|
|
121
|
+
continue
|
|
122
|
+
entry_method, entry_pattern = entry_parts
|
|
123
|
+
if key_method != entry_method:
|
|
124
|
+
continue
|
|
125
|
+
if path_matches_pattern(key_path, entry_pattern):
|
|
126
|
+
matched.append(key)
|
|
127
|
+
return matched
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def lookup_stale(key: str, stale_map: "OrderedDict[str, Any]") -> bool:
|
|
131
|
+
"""Return True if *key* exists in *stale_map* and has not yet expired."""
|
|
132
|
+
entry = stale_map.get(key)
|
|
133
|
+
if entry is None:
|
|
134
|
+
return False
|
|
135
|
+
until = entry.until if isinstance(entry, _StaleEntry) else entry["until"]
|
|
136
|
+
return _now() < until
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class _StaleTracker:
|
|
140
|
+
"""Async-aware stale tracker backed by ``asyncio.Lock``."""
|
|
141
|
+
|
|
142
|
+
def __init__(
|
|
143
|
+
self,
|
|
144
|
+
stale_time: float,
|
|
145
|
+
stale_max_entries: int = 1000,
|
|
146
|
+
max_sources_per_target: int = 16,
|
|
147
|
+
) -> None:
|
|
148
|
+
""" stale_time == 0 disables the tracker (no stale window applied). """
|
|
149
|
+
self._enabled = stale_time > 0
|
|
150
|
+
self._stale_time = max(stale_time, 0.0)
|
|
151
|
+
self._max_entries = max(stale_max_entries, 1)
|
|
152
|
+
self._max_sources = max(max_sources_per_target, 1)
|
|
153
|
+
self._map: OrderedDict[str, _StaleEntry] = OrderedDict()
|
|
154
|
+
self._lock = asyncio.Lock()
|
|
155
|
+
self._seq = 0
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def enabled(self) -> bool:
|
|
159
|
+
return self._enabled
|
|
160
|
+
|
|
161
|
+
async def invalidate(self, patterns: list[str]) -> None:
|
|
162
|
+
"""Mark every existing key matching *patterns* as freshly stale."""
|
|
163
|
+
if not self._enabled:
|
|
164
|
+
return
|
|
165
|
+
async with self._lock:
|
|
166
|
+
self._evict_expired()
|
|
167
|
+
targets = resolve_invalidation_targets(patterns, self._map)
|
|
168
|
+
self._seq += 1
|
|
169
|
+
until = _now() + self._stale_time
|
|
170
|
+
for t in targets:
|
|
171
|
+
self._upsert(t, until, self._seq, mutation_selector=None)
|
|
172
|
+
self._enforce_capacity()
|
|
173
|
+
|
|
174
|
+
async def mark_stale(
|
|
175
|
+
self,
|
|
176
|
+
invalidate: list[str],
|
|
177
|
+
params: dict[str, str] | None,
|
|
178
|
+
mutation_selector: str,
|
|
179
|
+
) -> None:
|
|
180
|
+
"""Bump seq, expand templated invalidation entries, store stale keys."""
|
|
181
|
+
if not self._enabled or not invalidate:
|
|
182
|
+
return
|
|
183
|
+
async with self._lock:
|
|
184
|
+
self._seq += 1
|
|
185
|
+
until = _now() + self._stale_time
|
|
186
|
+
resolved = resolve_invalidation_targets_for_mutation(invalidate, params)
|
|
187
|
+
for target in resolved:
|
|
188
|
+
self._upsert(
|
|
189
|
+
target, until, self._seq, mutation_selector=mutation_selector,
|
|
190
|
+
)
|
|
191
|
+
self._enforce_capacity()
|
|
192
|
+
|
|
193
|
+
async def mark(self, key: str) -> None:
|
|
194
|
+
"""Force *key* into the stale map. Used by SSE/WS open hooks and tests."""
|
|
195
|
+
if not self._enabled:
|
|
196
|
+
return
|
|
197
|
+
async with self._lock:
|
|
198
|
+
self._seq += 1
|
|
199
|
+
until = _now() + self._stale_time
|
|
200
|
+
self._upsert(key, until, self._seq, mutation_selector=None)
|
|
201
|
+
self._enforce_capacity()
|
|
202
|
+
|
|
203
|
+
async def is_stale(self, method_or_key: str, path: str | None = None) -> bool:
|
|
204
|
+
"""Return True if (method, path) is currently stale.
|
|
205
|
+
|
|
206
|
+
Accepts either ``is_stale("GET /users")`` or ``is_stale("GET", "/users")``.
|
|
207
|
+
Walks pattern entries too.
|
|
208
|
+
"""
|
|
209
|
+
if not self._enabled:
|
|
210
|
+
return False
|
|
211
|
+
if path is None:
|
|
212
|
+
method, concrete_path = self._split_key(method_or_key)
|
|
213
|
+
else:
|
|
214
|
+
method, concrete_path = method_or_key, path
|
|
215
|
+
async with self._lock:
|
|
216
|
+
now = _now()
|
|
217
|
+
_by, is_stale = self._lookup_locked(
|
|
218
|
+
concrete_selector=f"{method} {concrete_path}",
|
|
219
|
+
concrete_path=concrete_path,
|
|
220
|
+
method=method,
|
|
221
|
+
now=now,
|
|
222
|
+
)
|
|
223
|
+
return is_stale
|
|
224
|
+
|
|
225
|
+
async def lookup_stale(
|
|
226
|
+
self,
|
|
227
|
+
concrete_selector: str,
|
|
228
|
+
concrete_path: str,
|
|
229
|
+
method: str,
|
|
230
|
+
) -> tuple[list[str], bool]:
|
|
231
|
+
"""Return (invalidated_by_list, is_stale): exact + pattern lookup with sweep."""
|
|
232
|
+
if not self._enabled:
|
|
233
|
+
return [], False
|
|
234
|
+
async with self._lock:
|
|
235
|
+
return self._lookup_locked(concrete_selector, concrete_path, method, _now())
|
|
236
|
+
|
|
237
|
+
async def clear_stale(
|
|
238
|
+
self,
|
|
239
|
+
concrete_selector: str,
|
|
240
|
+
concrete_path: str,
|
|
241
|
+
method: str,
|
|
242
|
+
seq_snapshot: int,
|
|
243
|
+
) -> None:
|
|
244
|
+
"""Drop seq-older entries matching *concrete_path* (exact + pattern keys)."""
|
|
245
|
+
if not self._enabled:
|
|
246
|
+
return
|
|
247
|
+
async with self._lock:
|
|
248
|
+
exact = self._map.get(concrete_selector)
|
|
249
|
+
if exact and exact.seq <= seq_snapshot:
|
|
250
|
+
del self._map[concrete_selector]
|
|
251
|
+
to_delete: list[str] = []
|
|
252
|
+
for key, entry in self._map.items():
|
|
253
|
+
if entry.seq > seq_snapshot:
|
|
254
|
+
continue
|
|
255
|
+
key_method, key_pattern = self._split_key(key)
|
|
256
|
+
if "{" not in key_pattern and ":" not in key_pattern:
|
|
257
|
+
continue
|
|
258
|
+
if key_method != method:
|
|
259
|
+
continue
|
|
260
|
+
if path_matches_pattern(concrete_path, key_pattern):
|
|
261
|
+
to_delete.append(key)
|
|
262
|
+
for key in to_delete:
|
|
263
|
+
del self._map[key]
|
|
264
|
+
|
|
265
|
+
async def build_request_meta(
|
|
266
|
+
self,
|
|
267
|
+
concrete_selector: str,
|
|
268
|
+
concrete_path: str,
|
|
269
|
+
method: str,
|
|
270
|
+
) -> _RequestMeta | None:
|
|
271
|
+
if not self._enabled:
|
|
272
|
+
return None
|
|
273
|
+
async with self._lock:
|
|
274
|
+
by, is_stale = self._lookup_locked(
|
|
275
|
+
concrete_selector, concrete_path, method, _now(),
|
|
276
|
+
)
|
|
277
|
+
return _RequestMeta(
|
|
278
|
+
selector=concrete_selector,
|
|
279
|
+
is_stale=is_stale,
|
|
280
|
+
invalidated_by=by,
|
|
281
|
+
seq_snapshot=self._seq,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
def _upsert(
|
|
285
|
+
self,
|
|
286
|
+
key: str,
|
|
287
|
+
until: float,
|
|
288
|
+
seq: int,
|
|
289
|
+
mutation_selector: str | None,
|
|
290
|
+
) -> None:
|
|
291
|
+
existing = self._map.get(key)
|
|
292
|
+
if existing is not None:
|
|
293
|
+
existing.until = until
|
|
294
|
+
existing.seq = seq
|
|
295
|
+
if mutation_selector and mutation_selector not in existing.by:
|
|
296
|
+
if len(existing.by) < self._max_sources:
|
|
297
|
+
existing.by.append(mutation_selector)
|
|
298
|
+
self._map.move_to_end(key)
|
|
299
|
+
return
|
|
300
|
+
by = [mutation_selector] if mutation_selector else []
|
|
301
|
+
self._map[key] = _StaleEntry(by=by, seq=seq, until=until)
|
|
302
|
+
self._map.move_to_end(key)
|
|
303
|
+
|
|
304
|
+
def _lookup_locked(
|
|
305
|
+
self,
|
|
306
|
+
concrete_selector: str,
|
|
307
|
+
concrete_path: str,
|
|
308
|
+
method: str,
|
|
309
|
+
now: float,
|
|
310
|
+
) -> tuple[list[str], bool]:
|
|
311
|
+
all_by: list[str] = []
|
|
312
|
+
exact = self._map.get(concrete_selector)
|
|
313
|
+
if exact is not None:
|
|
314
|
+
if exact.until > now:
|
|
315
|
+
all_by.extend(exact.by)
|
|
316
|
+
else:
|
|
317
|
+
del self._map[concrete_selector]
|
|
318
|
+
expired: list[str] = []
|
|
319
|
+
for key, entry in self._map.items():
|
|
320
|
+
if entry.until <= now:
|
|
321
|
+
expired.append(key)
|
|
322
|
+
continue
|
|
323
|
+
if "{" not in key and ":" not in key:
|
|
324
|
+
continue
|
|
325
|
+
if key == concrete_selector:
|
|
326
|
+
continue
|
|
327
|
+
key_method, key_pattern = self._split_key(key)
|
|
328
|
+
if key_method != method:
|
|
329
|
+
continue
|
|
330
|
+
if path_matches_pattern(concrete_path, key_pattern):
|
|
331
|
+
all_by.extend(entry.by)
|
|
332
|
+
for key in expired:
|
|
333
|
+
del self._map[key]
|
|
334
|
+
deduped: list[str] = []
|
|
335
|
+
seen: set[str] = set()
|
|
336
|
+
for src in all_by:
|
|
337
|
+
if src in seen:
|
|
338
|
+
continue
|
|
339
|
+
seen.add(src)
|
|
340
|
+
deduped.append(src)
|
|
341
|
+
return deduped, len(deduped) > 0
|
|
342
|
+
|
|
343
|
+
def _evict_expired(self) -> None:
|
|
344
|
+
now = _now()
|
|
345
|
+
expired = [k for k, v in self._map.items() if now >= v.until]
|
|
346
|
+
for k in expired:
|
|
347
|
+
del self._map[k]
|
|
348
|
+
|
|
349
|
+
def _enforce_capacity(self) -> None:
|
|
350
|
+
if len(self._map) <= self._max_entries:
|
|
351
|
+
return
|
|
352
|
+
self._evict_expired()
|
|
353
|
+
target = max(self._max_entries // 2, 1)
|
|
354
|
+
while len(self._map) > target:
|
|
355
|
+
self._map.popitem(last=False)
|
|
356
|
+
|
|
357
|
+
@staticmethod
|
|
358
|
+
def _split_key(key: str) -> tuple[str, str]:
|
|
359
|
+
space_idx = key.find(" ")
|
|
360
|
+
if space_idx == -1:
|
|
361
|
+
return key, ""
|
|
362
|
+
return key[:space_idx], key[space_idx + 1 :]
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
class _StaleTrackerSync:
|
|
366
|
+
"""Sync variant backed by ``threading.Lock``; mirrors :class:`_StaleTracker`."""
|
|
367
|
+
|
|
368
|
+
def __init__(
|
|
369
|
+
self,
|
|
370
|
+
stale_time: float,
|
|
371
|
+
stale_max_entries: int = 1000,
|
|
372
|
+
max_sources_per_target: int = 16,
|
|
373
|
+
) -> None:
|
|
374
|
+
""" stale_time == 0 disables the tracker (no stale window applied). """
|
|
375
|
+
self._enabled = stale_time > 0
|
|
376
|
+
self._stale_time = max(stale_time, 0.0)
|
|
377
|
+
self._max_entries = max(stale_max_entries, 1)
|
|
378
|
+
self._max_sources = max(max_sources_per_target, 1)
|
|
379
|
+
self._map: OrderedDict[str, _StaleEntry] = OrderedDict()
|
|
380
|
+
self._lock = threading.Lock()
|
|
381
|
+
self._seq = 0
|
|
382
|
+
|
|
383
|
+
@property
|
|
384
|
+
def enabled(self) -> bool:
|
|
385
|
+
return self._enabled
|
|
386
|
+
|
|
387
|
+
def invalidate(self, patterns: list[str]) -> None:
|
|
388
|
+
if not self._enabled:
|
|
389
|
+
return
|
|
390
|
+
with self._lock:
|
|
391
|
+
self._evict_expired()
|
|
392
|
+
targets = resolve_invalidation_targets(patterns, self._map)
|
|
393
|
+
self._seq += 1
|
|
394
|
+
until = _now() + self._stale_time
|
|
395
|
+
for t in targets:
|
|
396
|
+
self._upsert(t, until, self._seq, None)
|
|
397
|
+
self._enforce_capacity()
|
|
398
|
+
|
|
399
|
+
def mark_stale(
|
|
400
|
+
self,
|
|
401
|
+
invalidate: list[str],
|
|
402
|
+
params: dict[str, str] | None,
|
|
403
|
+
mutation_selector: str,
|
|
404
|
+
) -> None:
|
|
405
|
+
if not self._enabled or not invalidate:
|
|
406
|
+
return
|
|
407
|
+
with self._lock:
|
|
408
|
+
self._seq += 1
|
|
409
|
+
until = _now() + self._stale_time
|
|
410
|
+
resolved = resolve_invalidation_targets_for_mutation(invalidate, params)
|
|
411
|
+
for target in resolved:
|
|
412
|
+
self._upsert(target, until, self._seq, mutation_selector)
|
|
413
|
+
self._enforce_capacity()
|
|
414
|
+
|
|
415
|
+
def mark(self, key: str) -> None:
|
|
416
|
+
if not self._enabled:
|
|
417
|
+
return
|
|
418
|
+
with self._lock:
|
|
419
|
+
self._seq += 1
|
|
420
|
+
until = _now() + self._stale_time
|
|
421
|
+
self._upsert(key, until, self._seq, None)
|
|
422
|
+
self._enforce_capacity()
|
|
423
|
+
|
|
424
|
+
def is_stale(self, method_or_key: str, path: str | None = None) -> bool:
|
|
425
|
+
if not self._enabled:
|
|
426
|
+
return False
|
|
427
|
+
if path is None:
|
|
428
|
+
method, concrete_path = _StaleTracker._split_key(method_or_key)
|
|
429
|
+
else:
|
|
430
|
+
method, concrete_path = method_or_key, path
|
|
431
|
+
with self._lock:
|
|
432
|
+
_by, is_stale = self._lookup_locked(
|
|
433
|
+
f"{method} {concrete_path}", concrete_path, method, _now(),
|
|
434
|
+
)
|
|
435
|
+
return is_stale
|
|
436
|
+
|
|
437
|
+
def lookup_stale(
|
|
438
|
+
self,
|
|
439
|
+
concrete_selector: str,
|
|
440
|
+
concrete_path: str,
|
|
441
|
+
method: str,
|
|
442
|
+
) -> tuple[list[str], bool]:
|
|
443
|
+
if not self._enabled:
|
|
444
|
+
return [], False
|
|
445
|
+
with self._lock:
|
|
446
|
+
return self._lookup_locked(concrete_selector, concrete_path, method, _now())
|
|
447
|
+
|
|
448
|
+
def clear_stale(
|
|
449
|
+
self,
|
|
450
|
+
concrete_selector: str,
|
|
451
|
+
concrete_path: str,
|
|
452
|
+
method: str,
|
|
453
|
+
seq_snapshot: int,
|
|
454
|
+
) -> None:
|
|
455
|
+
if not self._enabled:
|
|
456
|
+
return
|
|
457
|
+
with self._lock:
|
|
458
|
+
exact = self._map.get(concrete_selector)
|
|
459
|
+
if exact and exact.seq <= seq_snapshot:
|
|
460
|
+
del self._map[concrete_selector]
|
|
461
|
+
to_delete: list[str] = []
|
|
462
|
+
for key, entry in self._map.items():
|
|
463
|
+
if entry.seq > seq_snapshot:
|
|
464
|
+
continue
|
|
465
|
+
key_method, key_pattern = _StaleTracker._split_key(key)
|
|
466
|
+
if "{" not in key_pattern and ":" not in key_pattern:
|
|
467
|
+
continue
|
|
468
|
+
if key_method != method:
|
|
469
|
+
continue
|
|
470
|
+
if path_matches_pattern(concrete_path, key_pattern):
|
|
471
|
+
to_delete.append(key)
|
|
472
|
+
for key in to_delete:
|
|
473
|
+
del self._map[key]
|
|
474
|
+
|
|
475
|
+
def build_request_meta(
|
|
476
|
+
self,
|
|
477
|
+
concrete_selector: str,
|
|
478
|
+
concrete_path: str,
|
|
479
|
+
method: str,
|
|
480
|
+
) -> _RequestMeta | None:
|
|
481
|
+
if not self._enabled:
|
|
482
|
+
return None
|
|
483
|
+
with self._lock:
|
|
484
|
+
by, is_stale = self._lookup_locked(
|
|
485
|
+
concrete_selector, concrete_path, method, _now(),
|
|
486
|
+
)
|
|
487
|
+
return _RequestMeta(
|
|
488
|
+
selector=concrete_selector,
|
|
489
|
+
is_stale=is_stale,
|
|
490
|
+
invalidated_by=by,
|
|
491
|
+
seq_snapshot=self._seq,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
def _upsert(
|
|
495
|
+
self,
|
|
496
|
+
key: str,
|
|
497
|
+
until: float,
|
|
498
|
+
seq: int,
|
|
499
|
+
mutation_selector: str | None,
|
|
500
|
+
) -> None:
|
|
501
|
+
existing = self._map.get(key)
|
|
502
|
+
if existing is not None:
|
|
503
|
+
existing.until = until
|
|
504
|
+
existing.seq = seq
|
|
505
|
+
if mutation_selector and mutation_selector not in existing.by:
|
|
506
|
+
if len(existing.by) < self._max_sources:
|
|
507
|
+
existing.by.append(mutation_selector)
|
|
508
|
+
self._map.move_to_end(key)
|
|
509
|
+
return
|
|
510
|
+
by = [mutation_selector] if mutation_selector else []
|
|
511
|
+
self._map[key] = _StaleEntry(by=by, seq=seq, until=until)
|
|
512
|
+
self._map.move_to_end(key)
|
|
513
|
+
|
|
514
|
+
def _lookup_locked(
|
|
515
|
+
self,
|
|
516
|
+
concrete_selector: str,
|
|
517
|
+
concrete_path: str,
|
|
518
|
+
method: str,
|
|
519
|
+
now: float,
|
|
520
|
+
) -> tuple[list[str], bool]:
|
|
521
|
+
all_by: list[str] = []
|
|
522
|
+
exact = self._map.get(concrete_selector)
|
|
523
|
+
if exact is not None:
|
|
524
|
+
if exact.until > now:
|
|
525
|
+
all_by.extend(exact.by)
|
|
526
|
+
else:
|
|
527
|
+
del self._map[concrete_selector]
|
|
528
|
+
expired: list[str] = []
|
|
529
|
+
for key, entry in self._map.items():
|
|
530
|
+
if entry.until <= now:
|
|
531
|
+
expired.append(key)
|
|
532
|
+
continue
|
|
533
|
+
if "{" not in key and ":" not in key:
|
|
534
|
+
continue
|
|
535
|
+
if key == concrete_selector:
|
|
536
|
+
continue
|
|
537
|
+
key_method, key_pattern = _StaleTracker._split_key(key)
|
|
538
|
+
if key_method != method:
|
|
539
|
+
continue
|
|
540
|
+
if path_matches_pattern(concrete_path, key_pattern):
|
|
541
|
+
all_by.extend(entry.by)
|
|
542
|
+
for key in expired:
|
|
543
|
+
del self._map[key]
|
|
544
|
+
deduped: list[str] = []
|
|
545
|
+
seen: set[str] = set()
|
|
546
|
+
for src in all_by:
|
|
547
|
+
if src in seen:
|
|
548
|
+
continue
|
|
549
|
+
seen.add(src)
|
|
550
|
+
deduped.append(src)
|
|
551
|
+
return deduped, len(deduped) > 0
|
|
552
|
+
|
|
553
|
+
def _evict_expired(self) -> None:
|
|
554
|
+
now = _now()
|
|
555
|
+
expired = [k for k, v in self._map.items() if now >= v.until]
|
|
556
|
+
for k in expired:
|
|
557
|
+
del self._map[k]
|
|
558
|
+
|
|
559
|
+
def _enforce_capacity(self) -> None:
|
|
560
|
+
if len(self._map) <= self._max_entries:
|
|
561
|
+
return
|
|
562
|
+
self._evict_expired()
|
|
563
|
+
target = max(self._max_entries // 2, 1)
|
|
564
|
+
while len(self._map) > target:
|
|
565
|
+
self._map.popitem(last=False)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
__all__ = [
|
|
569
|
+
"_RequestMeta",
|
|
570
|
+
"_StaleEntry",
|
|
571
|
+
"_StaleTracker",
|
|
572
|
+
"_StaleTrackerSync",
|
|
573
|
+
"interpolate_path",
|
|
574
|
+
"lookup_stale",
|
|
575
|
+
"path_matches_pattern",
|
|
576
|
+
"resolve_invalidation_targets",
|
|
577
|
+
"resolve_invalidation_targets_for_mutation",
|
|
578
|
+
]
|