lorebase-keeper 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. lorebase_keeper/__init__.py +1 -0
  2. lorebase_keeper/__main__.py +456 -0
  3. lorebase_keeper/agents/__init__.py +1 -0
  4. lorebase_keeper/agents/budget.py +247 -0
  5. lorebase_keeper/agents/context.py +529 -0
  6. lorebase_keeper/agents/explain.py +582 -0
  7. lorebase_keeper/agents/feed.py +259 -0
  8. lorebase_keeper/agents/markdown.py +219 -0
  9. lorebase_keeper/agents/pack.py +403 -0
  10. lorebase_keeper/agents/settings.py +111 -0
  11. lorebase_keeper/app.py +373 -0
  12. lorebase_keeper/auth/__init__.py +4 -0
  13. lorebase_keeper/auth/api_keys.py +127 -0
  14. lorebase_keeper/auth/audit.py +356 -0
  15. lorebase_keeper/auth/cache.py +53 -0
  16. lorebase_keeper/auth/config.py +618 -0
  17. lorebase_keeper/auth/deps.py +183 -0
  18. lorebase_keeper/auth/keyring.py +14 -0
  19. lorebase_keeper/auth/oidc.py +242 -0
  20. lorebase_keeper/auth/permissions.py +142 -0
  21. lorebase_keeper/auth/ports.py +212 -0
  22. lorebase_keeper/auth/principals.py +377 -0
  23. lorebase_keeper/auth/provider.py +250 -0
  24. lorebase_keeper/auth/rbac.py +247 -0
  25. lorebase_keeper/auth/read_audit.py +357 -0
  26. lorebase_keeper/auth/runtime.py +113 -0
  27. lorebase_keeper/auth/sessions.py +452 -0
  28. lorebase_keeper/bootstrap.py +151 -0
  29. lorebase_keeper/client_address.py +129 -0
  30. lorebase_keeper/context.py +226 -0
  31. lorebase_keeper/db.py +93 -0
  32. lorebase_keeper/deps.py +47 -0
  33. lorebase_keeper/errors.py +257 -0
  34. lorebase_keeper/health.py +61 -0
  35. lorebase_keeper/kindhealth/__init__.py +6 -0
  36. lorebase_keeper/kindhealth/config.py +51 -0
  37. lorebase_keeper/kindhealth/markdown.py +163 -0
  38. lorebase_keeper/kindhealth/models.py +179 -0
  39. lorebase_keeper/kindhealth/report.py +154 -0
  40. lorebase_keeper/kindhealth/scan.py +512 -0
  41. lorebase_keeper/middleware.py +285 -0
  42. lorebase_keeper/pagination.py +87 -0
  43. lorebase_keeper/ratelimit/__init__.py +10 -0
  44. lorebase_keeper/ratelimit/bucket.py +118 -0
  45. lorebase_keeper/ratelimit/config.py +283 -0
  46. lorebase_keeper/ratelimit/enforce.py +393 -0
  47. lorebase_keeper/ratelimit/limiter.py +149 -0
  48. lorebase_keeper/ratelimit/quotas.py +204 -0
  49. lorebase_keeper/ratelimit/shared.py +242 -0
  50. lorebase_keeper/readiness.py +257 -0
  51. lorebase_keeper/reading/__init__.py +1 -0
  52. lorebase_keeper/reading/caching.py +251 -0
  53. lorebase_keeper/reading/compiler.py +678 -0
  54. lorebase_keeper/reading/cursors.py +56 -0
  55. lorebase_keeper/reading/diff.py +408 -0
  56. lorebase_keeper/reading/graph.py +268 -0
  57. lorebase_keeper/reading/history.py +152 -0
  58. lorebase_keeper/reading/points.py +410 -0
  59. lorebase_keeper/reading/problems.py +74 -0
  60. lorebase_keeper/reading/record_history.py +722 -0
  61. lorebase_keeper/reading/records.py +780 -0
  62. lorebase_keeper/reading/requests.py +107 -0
  63. lorebase_keeper/reading/runtime.py +145 -0
  64. lorebase_keeper/reading/search.py +708 -0
  65. lorebase_keeper/reading/sensitivity.py +882 -0
  66. lorebase_keeper/reading/settings.py +339 -0
  67. lorebase_keeper/reading/snapshots.py +148 -0
  68. lorebase_keeper/reading/tree.py +98 -0
  69. lorebase_keeper/reading/views.py +264 -0
  70. lorebase_keeper/reading/visibility.py +93 -0
  71. lorebase_keeper/routes/__init__.py +1 -0
  72. lorebase_keeper/routes/admin.py +597 -0
  73. lorebase_keeper/routes/agents.py +160 -0
  74. lorebase_keeper/routes/branches.py +438 -0
  75. lorebase_keeper/routes/derivations.py +215 -0
  76. lorebase_keeper/routes/documents.py +497 -0
  77. lorebase_keeper/routes/domain_api.py +59 -0
  78. lorebase_keeper/routes/domains.py +220 -0
  79. lorebase_keeper/routes/events.py +187 -0
  80. lorebase_keeper/routes/kind_health.py +166 -0
  81. lorebase_keeper/routes/operations.py +392 -0
  82. lorebase_keeper/routes/proposal_items.py +563 -0
  83. lorebase_keeper/routes/proposals.py +1107 -0
  84. lorebase_keeper/routes/read.py +493 -0
  85. lorebase_keeper/routes/revisions.py +523 -0
  86. lorebase_keeper/routes/schema.py +682 -0
  87. lorebase_keeper/routes/schema_inference.py +437 -0
  88. lorebase_keeper/routes/schema_suggestions.py +328 -0
  89. lorebase_keeper/routes/sessions.py +286 -0
  90. lorebase_keeper/routes/source_preview.py +232 -0
  91. lorebase_keeper/routes/sources.py +200 -0
  92. lorebase_keeper/routes/trace.py +506 -0
  93. lorebase_keeper/routes/vault.py +332 -0
  94. lorebase_keeper/routes/webhooks.py +298 -0
  95. lorebase_keeper/routes/whoami.py +88 -0
  96. lorebase_keeper/services/__init__.py +1 -0
  97. lorebase_keeper/services/agent_writes.py +1035 -0
  98. lorebase_keeper/services/branches.py +337 -0
  99. lorebase_keeper/services/config.py +431 -0
  100. lorebase_keeper/services/derivation_preview.py +279 -0
  101. lorebase_keeper/services/document_urls.py +114 -0
  102. lorebase_keeper/services/domains.py +118 -0
  103. lorebase_keeper/services/errors.py +196 -0
  104. lorebase_keeper/services/operation_catalog.py +806 -0
  105. lorebase_keeper/services/operation_events.py +199 -0
  106. lorebase_keeper/services/operations.py +1229 -0
  107. lorebase_keeper/services/pii_import.py +222 -0
  108. lorebase_keeper/services/proposal_reads.py +158 -0
  109. lorebase_keeper/services/proposals.py +850 -0
  110. lorebase_keeper/services/reads.py +286 -0
  111. lorebase_keeper/services/schema.py +601 -0
  112. lorebase_keeper/services/schema_inference.py +390 -0
  113. lorebase_keeper/services/schema_suggestions.py +418 -0
  114. lorebase_keeper/services/source_preview.py +127 -0
  115. lorebase_keeper/settings.py +313 -0
  116. lorebase_keeper/tracing/__init__.py +1 -0
  117. lorebase_keeper/tracing/capture.py +350 -0
  118. lorebase_keeper/tracing/config.py +157 -0
  119. lorebase_keeper/tracing/explain.py +97 -0
  120. lorebase_keeper/tracing/middleware.py +359 -0
  121. lorebase_keeper/tracing/notes.py +80 -0
  122. lorebase_keeper/tracing/recorder.py +180 -0
  123. lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
  124. lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
  125. lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
  126. lorebase_keeper-0.2.3.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,257 @@
1
+ """Readiness checks behind `/readyz`.
2
+
3
+ A check is an async callable with a `name`. The database check connects, runs `SELECT 1` and
4
+ compares the Alembic heads the code expects (`packaged_heads`, from the store) with the revision
5
+ stored in the `alembic_version` table, so a service whose migrations are missing or behind is not
6
+ ready. Check failures are logged in full and reported to callers as a short
7
+ reason that never contains connection strings or credentials.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import functools
14
+ import importlib
15
+ import logging
16
+ import re
17
+ from collections.abc import Awaitable, Callable, Collection, Mapping, Sequence
18
+ from dataclasses import dataclass
19
+ from enum import StrEnum
20
+ from typing import Any, Protocol
21
+
22
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
23
+
24
+ from lorebase_platform.secrets import SecretResolutionError, resolve_secret
25
+ from lorebase_platform.settings import ConfigError, Settings
26
+ from lorebase_store import bm25
27
+ from lorebase_store.migrations import head_revisions
28
+ from lorebase_store.replicas import ReplicaState
29
+
30
+ logger = logging.getLogger("lorebase.keeper.readiness")
31
+
32
+ MigrationHeads = Callable[[], Collection[str]]
33
+ """Returns the Alembic head revisions the running code expects; empty skips the comparison."""
34
+
35
+ _DRIVER_SUFFIX = re.compile(r"^(postgres(?:ql)?)\+\w+://")
36
+
37
+
38
+ @functools.cache
39
+ def packaged_heads() -> tuple[str, ...]:
40
+ """Alembic head revisions of the installed store code (read once: they cannot change)."""
41
+ return head_revisions()
42
+
43
+
44
+ class CheckStatus(StrEnum):
45
+ """Outcome of one readiness check."""
46
+
47
+ OK = "ok"
48
+ DEGRADED = "degraded"
49
+ NOT_CONFIGURED = "not_configured"
50
+ FAILED = "failed"
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class CheckResult:
55
+ """Status of a check and an optional short reason."""
56
+
57
+ status: CheckStatus
58
+ detail: str | None = None
59
+
60
+ @property
61
+ def passed(self) -> bool:
62
+ """Failed checks make the service not ready; degraded and unconfigured ones do not."""
63
+ return self.status is not CheckStatus.FAILED
64
+
65
+
66
+ class ReadinessCheck(Protocol):
67
+ """One probe of a dependency."""
68
+
69
+ name: str
70
+
71
+ async def __call__(self) -> CheckResult:
72
+ """Run the probe."""
73
+ ...
74
+
75
+
76
+ class NotConfiguredCheck:
77
+ """Placeholder reporting that a dependency is not part of this deployment."""
78
+
79
+ def __init__(self, name: str) -> None:
80
+ self.name = name
81
+
82
+ async def __call__(self) -> CheckResult:
83
+ return CheckResult(CheckStatus.NOT_CONFIGURED)
84
+
85
+
86
+ def asyncpg_dsn(dsn: str) -> str:
87
+ """Connection string without a SQLAlchemy driver suffix (`postgresql+asyncpg://` etc.)."""
88
+ return _DRIVER_SUFFIX.sub(r"\1://", dsn)
89
+
90
+
91
+ def _asyncpg_connect(dsn: str, timeout: float) -> Awaitable[Any]:
92
+ asyncpg = importlib.import_module("asyncpg")
93
+ return asyncpg.connect(dsn, timeout=timeout) # type: ignore[no-any-return]
94
+
95
+
96
+ class DatabaseCheck:
97
+ """Postgres is reachable and its migrations match the code."""
98
+
99
+ name = "database"
100
+
101
+ def __init__(
102
+ self,
103
+ dsn: str,
104
+ *,
105
+ timeout_s: float,
106
+ expected_heads: MigrationHeads | None = None,
107
+ connect: Callable[[str, float], Awaitable[Any]] = _asyncpg_connect,
108
+ ) -> None:
109
+ self._dsn = asyncpg_dsn(dsn)
110
+ self._timeout_s = timeout_s
111
+ self._expected_heads = expected_heads
112
+ self._connect = connect
113
+
114
+ @property
115
+ def expected_heads(self) -> frozenset[str]:
116
+ """Revisions the database must be at; empty when migrations are not verified."""
117
+ return frozenset(self._expected_heads()) if self._expected_heads else frozenset()
118
+
119
+ async def __call__(self) -> CheckResult:
120
+ connection = await self._connect(self._dsn, self._timeout_s)
121
+ try:
122
+ await connection.fetchval("SELECT 1")
123
+ expected = self.expected_heads
124
+ if not expected:
125
+ return CheckResult(CheckStatus.OK, "migration revision not verified")
126
+ if await connection.fetchval("SELECT to_regclass('alembic_version')") is None:
127
+ return CheckResult(CheckStatus.FAILED, "migrations have not been applied")
128
+ rows = await connection.fetch("SELECT version_num FROM alembic_version")
129
+ finally:
130
+ await connection.close()
131
+ current = frozenset(row["version_num"] for row in rows)
132
+ if current != expected:
133
+ return CheckResult(
134
+ CheckStatus.FAILED,
135
+ f"migrations at {_join(current)}, expected {_join(expected)}",
136
+ )
137
+ return CheckResult(CheckStatus.OK)
138
+
139
+
140
+ class ReplicaProbe(Protocol):
141
+ """What the replica check needs from the store's replica set."""
142
+
143
+ async def probe(self, timeout_s: float) -> list[ReplicaState]:
144
+ """Probe every replica."""
145
+ ...
146
+
147
+
148
+ class ReplicaCheck:
149
+ """State of the read replicas.
150
+
151
+ Replicas that are unreachable or lag more than `max_lag_ms` degrade the service without
152
+ taking it out of rotation, since reads fall back to the primary, unless `required`: reads
153
+ are served by replicas only (`prefer_replica` with no fallback) and none is usable. The
154
+ detail names each replica by its configuration name and `host:port`, never a credential.
155
+ """
156
+
157
+ name = "database_replicas"
158
+
159
+ def __init__(
160
+ self,
161
+ replicas: ReplicaProbe,
162
+ *,
163
+ timeout_s: float,
164
+ max_lag_ms: int,
165
+ required: bool,
166
+ ) -> None:
167
+ self._replicas = replicas
168
+ self._timeout_s = timeout_s
169
+ self._max_lag_ms = max_lag_ms
170
+ self._required = required
171
+
172
+ async def __call__(self) -> CheckResult:
173
+ states = await self._replicas.probe(self._timeout_s)
174
+ usable = [s for s in states if s.usable(self._max_lag_ms)]
175
+ detail = "; ".join(self._describe(s) for s in states)
176
+ if len(usable) == len(states):
177
+ return CheckResult(CheckStatus.OK, detail)
178
+ if not usable and self._required:
179
+ return CheckResult(CheckStatus.FAILED, detail)
180
+ return CheckResult(CheckStatus.DEGRADED, detail)
181
+
182
+ def _describe(self, state: ReplicaState) -> str:
183
+ where = f"{state.name} ({state.endpoint})"
184
+ if not state.reachable:
185
+ return f"{where}: unreachable ({state.error})"
186
+ if state.lagging(self._max_lag_ms):
187
+ lag = "unknown" if state.lag_ms is None else f"{state.lag_ms:.0f} ms"
188
+ return f"{where}: lagging ({lag})"
189
+ if state.in_recovery is False:
190
+ return f"{where}: ok, not a standby"
191
+ return f"{where}: ok, lag {state.lag_ms or 0:.0f} ms"
192
+
193
+
194
+ class SearchBackendCheck:
195
+ """Whether `search.backend: bm25` is served: degraded, without taking the service out of
196
+ rotation, while the database has no `pg_search` or no valid BM25 index, when lexical
197
+ search ranks the default way (`lorebase_store.bm25`)."""
198
+
199
+ name = "search_backend"
200
+
201
+ def __init__(self, sessions: async_sessionmaker[AsyncSession], *, definer: bool) -> None:
202
+ self._sessions = sessions
203
+ self._definer = definer
204
+
205
+ async def __call__(self) -> CheckResult:
206
+ if not self._definer:
207
+ reason = "store.reads.use_definer_functions is false"
208
+ else:
209
+ async with self._sessions() as session:
210
+ found = await bm25.status(session)
211
+ if found.ready:
212
+ return CheckResult(CheckStatus.OK, "bm25")
213
+ reason = str(found.reason)
214
+ return CheckResult(
215
+ CheckStatus.DEGRADED,
216
+ f"search.backend is bm25 but {reason}; lexical search ranks with postgres",
217
+ )
218
+
219
+
220
+ def _join(revisions: Collection[str]) -> str:
221
+ return ", ".join(sorted(revisions)) or "none"
222
+
223
+
224
+ async def run_checks(checks: Sequence[ReadinessCheck], timeout_s: float) -> dict[str, CheckResult]:
225
+ """Run every check concurrently, each within `timeout_s`."""
226
+
227
+ async def run(check: ReadinessCheck) -> CheckResult:
228
+ try:
229
+ return await asyncio.wait_for(check(), timeout_s)
230
+ except TimeoutError:
231
+ return CheckResult(CheckStatus.FAILED, f"no answer within {timeout_s:g}s")
232
+ except Exception as exc:
233
+ logger.warning("readiness check %s failed", check.name, exc_info=True)
234
+ return CheckResult(CheckStatus.FAILED, type(exc).__name__)
235
+
236
+ results = await asyncio.gather(*(run(check) for check in checks))
237
+ return {check.name: result for check, result in zip(checks, results, strict=True)}
238
+
239
+
240
+ def default_checks(
241
+ settings: Settings,
242
+ *,
243
+ section: str,
244
+ dsn_key: str,
245
+ timeout_s: float,
246
+ expected_heads: MigrationHeads | None,
247
+ ) -> list[ReadinessCheck]:
248
+ """The database check when the settings hold a connection string, else a placeholder."""
249
+ raw: Mapping[str, Any] = (settings.model_extra or {}).get(section) or {}
250
+ dsn = raw.get(dsn_key)
251
+ if not dsn:
252
+ return [NotConfiguredCheck("database")]
253
+ try:
254
+ resolved = resolve_secret(str(dsn))
255
+ except SecretResolutionError as exc:
256
+ raise ConfigError([f"{section}.{dsn_key}: {exc}"]) from None
257
+ return [DatabaseCheck(resolved, timeout_s=timeout_s, expected_heads=expected_heads)]
@@ -0,0 +1 @@
1
+ """The read path of Keeper (design 8): Records, Links, queries, search, history and diff."""
@@ -0,0 +1,251 @@
1
+ """Conditional GET and response caching of reads.
2
+
3
+ Every GET read answers with `ETag` = the Revision it was served from plus a digest of the path
4
+ and the canonical query parameters, `Cache-Control: private, must-revalidate` and the
5
+ `Lorebase-Revision` header; `If-None-Match` with that tag answers 304. Responses are cached
6
+ behind `ResponseCache`, keyed by `(Domain, Revision, path, canonical query)`. Revisions are
7
+ immutable, so a commit never invalidates an entry: it moves the branch to a new Revision and
8
+ therefore to new keys. The cache is consulted after the read is resolved and authorized, so it
9
+ never serves a caller something it could not read. Bodies are masked for the caller's
10
+ clearance, which is part of the key (`variant`), and hold vault references, not vault values:
11
+ `disclose` replaces them on every answer, cached or not (`reading.sensitivity`).
12
+
13
+ Only the in-process `InMemoryResponseCache` exists; a shared adapter (for example over Valkey)
14
+ implements the same protocol.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ import time
21
+ from collections import OrderedDict
22
+ from collections.abc import Awaitable, Callable, Mapping
23
+ from dataclasses import dataclass, field
24
+ from threading import Lock
25
+ from typing import Protocol
26
+
27
+ from fastapi import Request, Response
28
+ from opentelemetry import metrics
29
+ from pydantic import BaseModel
30
+
31
+ from lorebase_keeper.auth.read_audit import note_all, noted_sensitive
32
+ from lorebase_keeper.settings import ReadCacheSettings
33
+ from lorebase_keeper.tracing.notes import note_cache
34
+
35
+ JSON = "application/json"
36
+
37
+ Disclose = Callable[[bytes, str], Awaitable[bytes]]
38
+ """Rewrites a body of a media type as it is sent (vault references, `reading.sensitivity`)."""
39
+ CACHE_CONTROL = "private, must-revalidate"
40
+
41
+ _LOOKUPS = metrics.get_meter("lorebase.keeper").create_counter(
42
+ "lorebase.keeper.cache.requests",
43
+ unit="{request}",
44
+ description="Read response cache lookups, by result (hit, miss, bypass when disabled).",
45
+ )
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class CacheKey:
50
+ domain_id: str
51
+ revision: str
52
+ path: str
53
+ query: tuple[tuple[str, str], ...]
54
+ variant: str = ""
55
+ """What else the body depends on, such as the Kinds the caller may read."""
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class CachedResponse:
60
+ body: bytes
61
+ headers: Mapping[str, str] = field(default_factory=dict)
62
+ time_travel: bool = False
63
+ media_type: str = JSON
64
+ sensitive: frozenset[str] = frozenset()
65
+ """Sensitive values the first rendering showed or masked (`read_audit.note_sensitive`),
66
+ reported again by every answer from the cache."""
67
+
68
+
69
+ class ResponseCache(Protocol):
70
+ """Storage of read responses (in process now; a shared store later)."""
71
+
72
+ async def get(self, key: CacheKey) -> CachedResponse | None: ...
73
+
74
+ async def put(self, key: CacheKey, value: CachedResponse) -> None: ...
75
+
76
+
77
+ class InMemoryResponseCache:
78
+ """LRU bounded by entries and bytes, with a time to live and a cap on time travel entries."""
79
+
80
+ def __init__(
81
+ self,
82
+ settings: ReadCacheSettings,
83
+ *,
84
+ clock: Callable[[], float] = time.monotonic,
85
+ ) -> None:
86
+ self._settings = settings
87
+ self._clock = clock
88
+ self._entries: OrderedDict[CacheKey, tuple[float, CachedResponse]] = OrderedDict()
89
+ self._bytes = 0
90
+ self._time_travel = 0
91
+ self._lock = Lock()
92
+
93
+ def __len__(self) -> int:
94
+ return len(self._entries)
95
+
96
+ @property
97
+ def size_bytes(self) -> int:
98
+ return self._bytes
99
+
100
+ async def get(self, key: CacheKey) -> CachedResponse | None:
101
+ with self._lock:
102
+ found = self._entries.get(key)
103
+ if found is None:
104
+ return None
105
+ expires, value = found
106
+ if expires <= self._clock():
107
+ self._remove(key)
108
+ return None
109
+ self._entries.move_to_end(key)
110
+ return value
111
+
112
+ async def put(self, key: CacheKey, value: CachedResponse) -> None:
113
+ settings = self._settings
114
+ size = len(value.body)
115
+ if size > settings.max_body_bytes or size > settings.max_bytes:
116
+ return
117
+ with self._lock:
118
+ if key in self._entries:
119
+ self._remove(key)
120
+ if value.time_travel and self._time_travel >= settings.time_travel_max_entries:
121
+ return
122
+ self._entries[key] = (self._clock() + settings.ttl_s, value)
123
+ self._bytes += size
124
+ self._time_travel += value.time_travel
125
+ while len(self._entries) > settings.max_entries or self._bytes > settings.max_bytes:
126
+ self._remove(next(iter(self._entries)))
127
+
128
+ def _remove(self, key: CacheKey) -> None:
129
+ _, value = self._entries.pop(key)
130
+ self._bytes -= len(value.body)
131
+ self._time_travel -= value.time_travel
132
+
133
+
134
+ def build_cache(settings: ReadCacheSettings) -> ResponseCache | None:
135
+ return InMemoryResponseCache(settings) if settings.enabled else None
136
+
137
+
138
+ def cache_key(request: Request, domain_id: str, revision: str, variant: str = "") -> CacheKey:
139
+ query = tuple(sorted(request.query_params.multi_items()))
140
+ return CacheKey(domain_id, revision, request.url.path, query, variant)
141
+
142
+
143
+ def entity_tag(key: CacheKey) -> str:
144
+ """Strong ETag: the Revision plus a digest of the path and canonical query parameters."""
145
+ digest = hashlib.sha256(key.path.encode())
146
+ for name, value in key.query:
147
+ digest.update(b"\x00" + name.encode() + b"\x1f" + value.encode())
148
+ if key.variant:
149
+ digest.update(b"\x1e" + key.variant.encode())
150
+ return f'"{key.revision}.{digest.hexdigest()[:16]}"'
151
+
152
+
153
+ def _matches(request: Request, tag: str) -> bool:
154
+ header = request.headers.get("if-none-match")
155
+ if header is None:
156
+ return False
157
+ candidates = {t.strip() for t in header.split(",")}
158
+ return "*" in candidates or tag in candidates or f"W/{tag}" in candidates
159
+
160
+
161
+ def not_modified(
162
+ request: Request,
163
+ *,
164
+ domain_id: str,
165
+ revision: str,
166
+ headers: Mapping[str, str] | None = None,
167
+ variant: str = "",
168
+ ) -> Response | None:
169
+ """The 304 answer of a GET read whose `If-None-Match` names its current tag, else `None`."""
170
+ tag = entity_tag(cache_key(request, domain_id, revision, variant))
171
+ if not _matches(request, tag):
172
+ return None
173
+ common = {"ETag": tag, "Cache-Control": CACHE_CONTROL, **(headers or {})}
174
+ note_cache("not_modified")
175
+ return Response(status_code=304, headers=common)
176
+
177
+
178
+ async def respond(
179
+ request: Request,
180
+ cache: ResponseCache | None,
181
+ *,
182
+ domain_id: str,
183
+ revision: str,
184
+ produce: Callable[[], Awaitable[BaseModel]],
185
+ time_travel: bool = False,
186
+ headers: Mapping[str, str] | None = None,
187
+ variant: str = "",
188
+ disclose: Disclose | None = None,
189
+ ) -> Response:
190
+ """Answer a GET read: 304, a cached body, or `produce()` serialized and cached.
191
+
192
+ `revision` names everything the response depends on besides the request (one Revision id,
193
+ or several joined for a diff); `variant` anything else it depends on, such as the Kinds
194
+ the caller may read and its clearance; `headers` are added to every answer; `disclose`
195
+ post-processes every body sent (not the cached one).
196
+ """
197
+
198
+ async def body() -> tuple[bytes, str]:
199
+ return (await produce()).model_dump_json().encode(), JSON
200
+
201
+ return await respond_with(
202
+ request,
203
+ cache,
204
+ cache_key(request, domain_id, revision, variant),
205
+ body,
206
+ time_travel=time_travel,
207
+ headers=headers,
208
+ disclose=disclose,
209
+ )
210
+
211
+
212
+ async def respond_with(
213
+ request: Request,
214
+ cache: ResponseCache | None,
215
+ key: CacheKey,
216
+ produce: Callable[[], Awaitable[tuple[bytes, str]]],
217
+ *,
218
+ time_travel: bool = False,
219
+ headers: Mapping[str, str] | None = None,
220
+ disclose: Disclose | None = None,
221
+ ) -> Response:
222
+ """Answer a read under `key`: 304, a cached body, or `produce()` (body and media type),
223
+ cached. The ETag is `entity_tag(key)`. `disclose`, when given, rewrites bodies as they are
224
+ sent."""
225
+ tag = entity_tag(key)
226
+ common = {"ETag": tag, "Cache-Control": CACHE_CONTROL, **(headers or {})}
227
+ if _matches(request, tag):
228
+ note_cache("not_modified")
229
+ return Response(status_code=304, headers=common)
230
+ cached = None if cache is None else await cache.get(key)
231
+ result = "bypass" if cache is None else "miss" if cached is None else "hit"
232
+ _LOOKUPS.add(1, {"result": result})
233
+ note_cache(result)
234
+ if cached is not None:
235
+ note_all(cached.sensitive)
236
+ return Response(
237
+ await _sent(cached.body, cached.media_type, disclose),
238
+ media_type=cached.media_type,
239
+ headers={**cached.headers, **common},
240
+ )
241
+ body, media_type = await produce()
242
+ if cache is not None:
243
+ await cache.put(
244
+ key,
245
+ CachedResponse(body, dict(headers or {}), time_travel, media_type, noted_sensitive()),
246
+ )
247
+ return Response(await _sent(body, media_type, disclose), media_type=media_type, headers=common)
248
+
249
+
250
+ async def _sent(body: bytes, media_type: str, disclose: Disclose | None) -> bytes:
251
+ return body if disclose is None else await disclose(body, media_type)