sourcelock 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.
- hc_source/__init__.py +5 -0
- hc_source/adapters/__init__.py +500 -0
- hc_source/adapters/_demo.py +258 -0
- hc_source/adapters/_demo_fixture.json +25 -0
- hc_source/adapters/_leie_sample.csv +15 -0
- hc_source/adapters/codes.py +1232 -0
- hc_source/adapters/coverage.py +1569 -0
- hc_source/adapters/hcc.py +1450 -0
- hc_source/adapters/leie.py +1310 -0
- hc_source/adapters/provider.py +1159 -0
- hc_source/cache.py +664 -0
- hc_source/cli.py +959 -0
- hc_source/cli_manifest.py +207 -0
- hc_source/data/codes/hcpcs_2026q3.csv.gz +0 -0
- hc_source/data/codes/icd10cm_fy2026.csv.gz +0 -0
- hc_source/data/codes/icd10cm_fy2027.csv.gz +0 -0
- hc_source/data/codes/manifest.json +75 -0
- hc_source/data/codes/regenerate.py +291 -0
- hc_source/data/hcc/hcc_data.json.zlib +0 -0
- hc_source/doctor.py +472 -0
- hc_source/guard.py +877 -0
- hc_source/http.py +541 -0
- hc_source/interfaces.py +395 -0
- hc_source/lockfile.py +236 -0
- hc_source/manifest.py +422 -0
- hc_source/mcp_server.py +203 -0
- hc_source/npi.py +50 -0
- hc_source/receipts.py +74 -0
- hc_source/schemas.py +339 -0
- sourcelock-0.1.0.dist-info/METADATA +272 -0
- sourcelock-0.1.0.dist-info/RECORD +34 -0
- sourcelock-0.1.0.dist-info/WHEEL +4 -0
- sourcelock-0.1.0.dist-info/entry_points.txt +2 -0
- sourcelock-0.1.0.dist-info/licenses/LICENSE +21 -0
hc_source/cache.py
ADDED
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
"""On-disk caches, built so that a receipt cannot become a lie.
|
|
2
|
+
|
|
3
|
+
Two caches, with deliberately different defaults, because they carry different
|
|
4
|
+
risks.
|
|
5
|
+
|
|
6
|
+
**The HTTP cache is on by default.** It never serves a body without asking
|
|
7
|
+
upstream first: an entry is stored only when the response carried an ``ETag`` or
|
|
8
|
+
a ``Last-Modified``, and a hit is a conditional request that came back ``304 Not
|
|
9
|
+
Modified``. The bytes are therefore confirmed current by the source itself on
|
|
10
|
+
every use. What it saves is the transfer, not the round trip -- which is the
|
|
11
|
+
expensive part for the 15 MB LEIE file and the 32 MB MCD zip, and for an agent
|
|
12
|
+
looping hundreds of MCP calls over the same release.
|
|
13
|
+
|
|
14
|
+
**The tool-result cache is off by default.** It answers without asking anyone,
|
|
15
|
+
so it trades freshness for latency, and that is an operator's decision rather
|
|
16
|
+
than ours. Set ``HC_SOURCE_TOOL_CACHE_TTL`` to a number of seconds to enable it.
|
|
17
|
+
|
|
18
|
+
The rule both obey, and the reason this module exists rather than a dict in each
|
|
19
|
+
adapter:
|
|
20
|
+
|
|
21
|
+
**A cache hit reports the retrieval time of the bytes, never the time of
|
|
22
|
+
the hit.**
|
|
23
|
+
|
|
24
|
+
Round-1 finding: ``build_receipt`` stamped ``utcnow()`` when the receipt was
|
|
25
|
+
assembled, so anything served out of an adapter's process cache produced a
|
|
26
|
+
receipt claiming it had just been to CMS. Wave 2 moved the stamp to the fetch
|
|
27
|
+
boundary. A disk cache is the same bug waiting at a longer timescale, so
|
|
28
|
+
``retrieved_at`` is stored with the entry and restored with it, ``cache_hit``
|
|
29
|
+
says plainly that this answer came from a cache, and ``revalidated_at`` records
|
|
30
|
+
when upstream last confirmed the bytes are still current.
|
|
31
|
+
|
|
32
|
+
Configuration:
|
|
33
|
+
|
|
34
|
+
============================== ===========================================
|
|
35
|
+
``HC_SOURCE_CACHE_DIR`` Where to keep it. ``off``/``0`` disables both
|
|
36
|
+
caches entirely. Default: ``$XDG_CACHE_HOME``
|
|
37
|
+
(or ``~/.cache``) ``/sourcelock``.
|
|
38
|
+
``HC_SOURCE_TOOL_CACHE_TTL`` Seconds a tool result may be reused. ``0``
|
|
39
|
+
(the default) means the result cache is off.
|
|
40
|
+
============================== ===========================================
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import hashlib
|
|
46
|
+
import json
|
|
47
|
+
import os
|
|
48
|
+
import shutil
|
|
49
|
+
import tempfile
|
|
50
|
+
from dataclasses import dataclass
|
|
51
|
+
from datetime import datetime, timezone
|
|
52
|
+
from email.utils import parsedate_to_datetime
|
|
53
|
+
from pathlib import Path
|
|
54
|
+
from typing import Any
|
|
55
|
+
|
|
56
|
+
import httpx
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"CACHE_DIR_ENV",
|
|
60
|
+
"CachedResponse",
|
|
61
|
+
"TOOL_TTL_ENV",
|
|
62
|
+
"cache_dir",
|
|
63
|
+
"cache_stats",
|
|
64
|
+
"clear_cache",
|
|
65
|
+
"http_key",
|
|
66
|
+
"read_http",
|
|
67
|
+
"request_signature",
|
|
68
|
+
"read_tool",
|
|
69
|
+
"tool_key",
|
|
70
|
+
"tool_ttl",
|
|
71
|
+
"write_http",
|
|
72
|
+
"write_tool",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
CACHE_DIR_ENV = "HC_SOURCE_CACHE_DIR"
|
|
76
|
+
TOOL_TTL_ENV = "HC_SOURCE_TOOL_CACHE_TTL"
|
|
77
|
+
|
|
78
|
+
_OFF = {"0", "off", "no", "false", "none", ""}
|
|
79
|
+
|
|
80
|
+
#: Bump when the on-disk layout changes so that old entries are simply missed
|
|
81
|
+
#: rather than misread. A cache that mis-parses an old entry is worse than a
|
|
82
|
+
#: cold cache in exactly the way this product cannot afford.
|
|
83
|
+
#:
|
|
84
|
+
#: 2: the key and the stored entry cover the request's headers and its byte
|
|
85
|
+
#: ceiling. A version-1 entry was keyed by URL alone, so a ranged probe's 206
|
|
86
|
+
#: could be replayed to a caller that asked for the whole file -- see
|
|
87
|
+
#: :func:`http_key`.
|
|
88
|
+
#:
|
|
89
|
+
#: 3: header VALUES enter the key verbatim. A version-2 key collapsed internal
|
|
90
|
+
#: whitespace in them, so two requests that put different bytes on the wire
|
|
91
|
+
#: could share an entry -- the ranged-vs-full replay again, one spelling down.
|
|
92
|
+
#: See :func:`_key_headers`.
|
|
93
|
+
#:
|
|
94
|
+
#: 4: headers enter the key after HTTPX has encoded the defaults and caller
|
|
95
|
+
#: values into the request's actual wire model. A version-3 key described the
|
|
96
|
+
#: caller's pre-merge object instead, so it could be coarser than the request.
|
|
97
|
+
LAYOUT_VERSION = "4"
|
|
98
|
+
|
|
99
|
+
#: Request headers whose value must never be written to disk. The NAME still
|
|
100
|
+
#: enters the key (asking as somebody is not the same request as asking as
|
|
101
|
+
#: nobody) and so does a digest of the value, so two different tokens cannot
|
|
102
|
+
#: share an entry -- but the token itself never lands in the cache directory.
|
|
103
|
+
_SECRET_HEADERS = frozenset(
|
|
104
|
+
{
|
|
105
|
+
"authorization",
|
|
106
|
+
"proxy-authorization",
|
|
107
|
+
"cookie",
|
|
108
|
+
"authentication",
|
|
109
|
+
"x-api-key",
|
|
110
|
+
"api-key",
|
|
111
|
+
"x-auth-token",
|
|
112
|
+
"x-amz-security-token",
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
#: The transport's own ``If-None-Match``/``If-Modified-Since`` never reach the
|
|
117
|
+
#: key, and not because they are filtered out: :func:`hc_source.http._read`
|
|
118
|
+
#: keys on the fully merged base request and adds the stored validator
|
|
119
|
+
#: afterwards. So revalidation still hits, and a conditional header already in
|
|
120
|
+
#: that base request came from the caller, goes on the wire, and can change what
|
|
121
|
+
#: comes back -- which is why there is no drop-list here. A key that ignores a
|
|
122
|
+
#: header the request actually sends is a key coarser than its request.
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def cache_dir() -> Path | None:
|
|
126
|
+
"""The cache root, or ``None`` when caching is switched off."""
|
|
127
|
+
raw = os.environ.get(CACHE_DIR_ENV)
|
|
128
|
+
if raw is not None:
|
|
129
|
+
if raw.strip().lower() in _OFF:
|
|
130
|
+
return None
|
|
131
|
+
return Path(raw).expanduser()
|
|
132
|
+
base = os.environ.get("XDG_CACHE_HOME")
|
|
133
|
+
root = Path(base).expanduser() if base else Path.home() / ".cache"
|
|
134
|
+
return root / "sourcelock"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def tool_ttl() -> float:
|
|
138
|
+
"""Seconds a tool result may be reused. ``0`` means the result cache is off."""
|
|
139
|
+
raw = os.environ.get(TOOL_TTL_ENV)
|
|
140
|
+
if raw is None:
|
|
141
|
+
return 0.0
|
|
142
|
+
try:
|
|
143
|
+
value = float(raw)
|
|
144
|
+
except ValueError:
|
|
145
|
+
return 0.0
|
|
146
|
+
return max(0.0, value)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _slot(kind: str, key: str) -> Path | None:
|
|
150
|
+
root = cache_dir()
|
|
151
|
+
if root is None:
|
|
152
|
+
return None
|
|
153
|
+
return root / LAYOUT_VERSION / kind / key[:2] / key
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _write_atomic(path: Path, payload: bytes) -> None:
|
|
157
|
+
"""Temp file plus rename, so an interrupted write leaves no half entry."""
|
|
158
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".tmp-")
|
|
160
|
+
try:
|
|
161
|
+
with os.fdopen(fd, "wb") as fh:
|
|
162
|
+
fh.write(payload)
|
|
163
|
+
os.replace(tmp, path)
|
|
164
|
+
except BaseException:
|
|
165
|
+
Path(tmp).unlink(missing_ok=True)
|
|
166
|
+
raise
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _utcnow() -> datetime:
|
|
170
|
+
return datetime.now(timezone.utc)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
# HTTP responses
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass(frozen=True)
|
|
179
|
+
class CachedResponse:
|
|
180
|
+
"""A stored response body plus everything a receipt needs about it."""
|
|
181
|
+
|
|
182
|
+
url: str
|
|
183
|
+
status: int | None
|
|
184
|
+
body: bytes
|
|
185
|
+
sha256: str
|
|
186
|
+
headers: dict[str, str]
|
|
187
|
+
#: When the bytes were read from upstream. Restored, never re-stamped.
|
|
188
|
+
retrieved_at: datetime
|
|
189
|
+
#: When upstream last confirmed these bytes are current (a 304, or the
|
|
190
|
+
#: original 200). Distinct from ``retrieved_at`` on purpose: "you still have
|
|
191
|
+
#: the right bytes" and "these bytes are new" are different statements.
|
|
192
|
+
revalidated_at: datetime
|
|
193
|
+
#: The canonical description of the request that produced these bytes -- see
|
|
194
|
+
#: :func:`request_signature`. Stored beside the body and checked on read, so
|
|
195
|
+
#: a hash collision or a hand-edited cache directory cannot hand a caller a
|
|
196
|
+
#: representation it did not ask for.
|
|
197
|
+
request_signature: str = ""
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def etag(self) -> str | None:
|
|
201
|
+
return self.headers.get("etag")
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def last_modified(self) -> str | None:
|
|
205
|
+
return self.headers.get("last-modified")
|
|
206
|
+
|
|
207
|
+
def conditional_headers(self) -> dict[str, str]:
|
|
208
|
+
headers = {}
|
|
209
|
+
if self.etag:
|
|
210
|
+
headers["If-None-Match"] = self.etag
|
|
211
|
+
if self.last_modified:
|
|
212
|
+
headers["If-Modified-Since"] = self.last_modified
|
|
213
|
+
return headers
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _header_items(headers: Any) -> list[tuple[bytes, bytes]]:
|
|
217
|
+
"""Every HTTPX-encoded occurrence, in wire order and wire bytes.
|
|
218
|
+
|
|
219
|
+
HTTPX is the transport's header encoder, so its ``raw`` pairs are the
|
|
220
|
+
request model the key has to describe. Constructing ``Headers`` here also
|
|
221
|
+
means ``b"foo"`` and ``"b'foo'"`` stay as different wire values instead of
|
|
222
|
+
colliding through Python's ``str(bytes)`` representation. Repeated
|
|
223
|
+
occurrences stay repeated; folding them through ``items()`` would instead
|
|
224
|
+
describe one comma-joined line that the transport did not send.
|
|
225
|
+
"""
|
|
226
|
+
if not headers:
|
|
227
|
+
return []
|
|
228
|
+
return httpx.Headers(headers).raw
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _key_headers(headers: Any) -> list[list[str]]:
|
|
232
|
+
"""Request headers as key material -- verbatim, with no secret written down.
|
|
233
|
+
|
|
234
|
+
Every header in the merged base request is included, defaults and caller
|
|
235
|
+
values alike, not a hand-picked list of the ones we currently think select
|
|
236
|
+
a representation. ``Range`` is the one that broke this product, but
|
|
237
|
+
``Accept``, ``Accept-Language``, an API version pin and a tenant id all
|
|
238
|
+
change what comes back too, and a header we did not think of is exactly the
|
|
239
|
+
one an allow-list drops.
|
|
240
|
+
|
|
241
|
+
Values are encoded by HTTPX and copied from its raw byte pairs. Header NAMES
|
|
242
|
+
are case-insensitive per RFC 9110 and are lowered; values are not ours to
|
|
243
|
+
canonicalize, and an earlier version of this function canonicalized them
|
|
244
|
+
anyway -- it collapsed internal whitespace, so ``Range: bytes=0-2047`` and
|
|
245
|
+
``Range: bytes=0- 2047``
|
|
246
|
+
produced one key while two different byte strings went on the wire. That is
|
|
247
|
+
the ranged-probe replay in a different spelling: a key coarser than the
|
|
248
|
+
request it stands for, and therefore a 304 for one representation that can
|
|
249
|
+
hand back another representation's body with an upstream-confirmed receipt
|
|
250
|
+
on it. Whitespace is the example; the rule is that no lossy step belongs
|
|
251
|
+
anywhere on this path.
|
|
252
|
+
|
|
253
|
+
Occurrences are a LIST of pairs in the order given, not a dict. A dict keyed
|
|
254
|
+
by the folded name remembers only the last of ``{"Accept": "text/csv",
|
|
255
|
+
"accept": "application/json"}`` while both go on the wire, and for a
|
|
256
|
+
repeated name the order is the meaning.
|
|
257
|
+
|
|
258
|
+
Secret-bearing headers are the one exception, and a lossless one: the value
|
|
259
|
+
is replaced by a digest of the RAW bytes, so two tokens stay distinguishable
|
|
260
|
+
and no token reaches the cache directory. The name-match is done on a
|
|
261
|
+
stripped name so that an odd spelling redacts rather than leaks; the
|
|
262
|
+
stripping decides redaction only and never touches key material.
|
|
263
|
+
"""
|
|
264
|
+
pairs: list[list[str]] = []
|
|
265
|
+
for raw_name, raw_value in _header_items(headers):
|
|
266
|
+
name = raw_name.lower().decode("latin-1")
|
|
267
|
+
# latin-1 is deliberately a one-to-one rendering of arbitrary bytes,
|
|
268
|
+
# not a text normalization. JSON then gives the signature stable text.
|
|
269
|
+
value = raw_value.decode("latin-1")
|
|
270
|
+
if name.strip() in _SECRET_HEADERS:
|
|
271
|
+
value = "sha256:" + hashlib.sha256(raw_value).hexdigest()
|
|
272
|
+
pairs.append([name, value])
|
|
273
|
+
return pairs
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def request_signature(
|
|
277
|
+
url: str,
|
|
278
|
+
*,
|
|
279
|
+
params: Any = None,
|
|
280
|
+
scope: str = "",
|
|
281
|
+
headers: Any = None,
|
|
282
|
+
max_bytes: int | None = None,
|
|
283
|
+
) -> str:
|
|
284
|
+
"""Canonical text describing WHICH representation a request asked for.
|
|
285
|
+
|
|
286
|
+
The key is a digest of this; the entry stores it verbatim and
|
|
287
|
+
:func:`read_http` refuses an entry whose signature is not the one the caller
|
|
288
|
+
is asking under. Two channels rather than one because the failure this
|
|
289
|
+
guards against is silent by construction: a body served under the wrong
|
|
290
|
+
request is still a well-formed body, and the receipt would attest to it.
|
|
291
|
+
|
|
292
|
+
**The invariant: this function is injective with respect to what actually
|
|
293
|
+
goes on the wire.** Two requests that would send different bytes must not
|
|
294
|
+
produce the same text here, because the entry a signature names is a body,
|
|
295
|
+
and a 304 for one request replaying another request's body is the exact
|
|
296
|
+
failure the ranged probe caused. Distinguishing MORE than the wire does is
|
|
297
|
+
merely a cache miss; distinguishing less is a wrong answer with a receipt on
|
|
298
|
+
it, so every step on this path either preserves information or is a digest
|
|
299
|
+
(reversible enough: distinct inputs stay distinct). Nothing here trims,
|
|
300
|
+
case-folds a value, sorts away an occurrence, or drops an empty one.
|
|
301
|
+
|
|
302
|
+
``default=str`` is not an exception: a parameter reaches the query string
|
|
303
|
+
through ``str`` too, so rendering it that way here matches the wire rather
|
|
304
|
+
than blurring it.
|
|
305
|
+
"""
|
|
306
|
+
return json.dumps(
|
|
307
|
+
{
|
|
308
|
+
"url": url,
|
|
309
|
+
"params": _wire_stable(params),
|
|
310
|
+
"scope": scope,
|
|
311
|
+
"headers": _key_headers(headers),
|
|
312
|
+
"max_bytes": max_bytes,
|
|
313
|
+
},
|
|
314
|
+
sort_keys=True,
|
|
315
|
+
default=str,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def http_key(
|
|
320
|
+
url: str,
|
|
321
|
+
*,
|
|
322
|
+
params: Any = None,
|
|
323
|
+
scope: str = "",
|
|
324
|
+
headers: Any = None,
|
|
325
|
+
max_bytes: int | None = None,
|
|
326
|
+
) -> str:
|
|
327
|
+
"""Cache key for one request.
|
|
328
|
+
|
|
329
|
+
``scope`` is where a caller binds an entry to the version of the artifact it
|
|
330
|
+
is reading. The crosswalk bug this product had -- a cache keyed by a
|
|
331
|
+
constant URL but stamped with a freshly fetched schedule version, so week
|
|
332
|
+
N's rows were served claiming week N+1 -- is exactly what an empty scope
|
|
333
|
+
permits. When the identity of what you are fetching is decided by something
|
|
334
|
+
other than the URL, put it here.
|
|
335
|
+
|
|
336
|
+
``headers`` and ``max_bytes`` are in the key because a URL does not name a
|
|
337
|
+
representation on its own. LEIE revalidates the 15 MB exclusion file with a
|
|
338
|
+
``Range: bytes=0-2047`` probe; keyed by URL alone, that probe's 2 KB body
|
|
339
|
+
was stored under the full file's key, and the next full read got the first
|
|
340
|
+
two kilobytes back -- with ``cache_hit`` true, an upstream-confirmed
|
|
341
|
+
receipt, and an exclusion index missing 99.99% of its rows. A screen that
|
|
342
|
+
answers "not excluded" from that is the worst answer this product can give.
|
|
343
|
+
The byte ceiling is in the key for the same reason: bytes stored under a
|
|
344
|
+
30 MB allowance are not an answer to a call that declared a 16 MB one.
|
|
345
|
+
"""
|
|
346
|
+
return hashlib.sha256(
|
|
347
|
+
request_signature(
|
|
348
|
+
url, params=params, scope=scope, headers=headers, max_bytes=max_bytes
|
|
349
|
+
).encode("utf-8")
|
|
350
|
+
).hexdigest()
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _stable(value: Any) -> Any:
|
|
354
|
+
"""Order-independent rendering, for keys whose identity is a parameter SET.
|
|
355
|
+
|
|
356
|
+
Right for :func:`tool_key`: ``valid_on(code=X, on=Y)`` is the same call
|
|
357
|
+
however the dict was built, and the same call has the same answer. Wrong for
|
|
358
|
+
a URL, which is why :func:`request_signature` does not use it -- see
|
|
359
|
+
:func:`_wire_stable`.
|
|
360
|
+
"""
|
|
361
|
+
if isinstance(value, dict):
|
|
362
|
+
return {str(k): _stable(v) for k, v in sorted(value.items())}
|
|
363
|
+
if isinstance(value, (list, tuple)):
|
|
364
|
+
return [_stable(v) for v in value]
|
|
365
|
+
return value
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _wire_stable(value: Any) -> Any:
|
|
369
|
+
"""Query parameters as an ORDERED list of pairs rather than a mapping.
|
|
370
|
+
|
|
371
|
+
``json.dumps(sort_keys=True)`` sorts every nested mapping it is handed, and
|
|
372
|
+
a sorted mapping is a lossy rendering of a query string: ``{"b": 2, "a": 1}``
|
|
373
|
+
and ``{"a": 1, "b": 2}`` are one signature and two different URLs. Pairs in
|
|
374
|
+
encounter order are what httpx will actually serialize, so a dict and the
|
|
375
|
+
equivalent sequence of pairs land on the same text -- they land on the same
|
|
376
|
+
wire bytes too, so that is agreement, not a collision.
|
|
377
|
+
|
|
378
|
+
Encoding is delegated to HTTPX, the same library that will serialize the
|
|
379
|
+
request, for the same reason the header key is copied from its raw byte
|
|
380
|
+
pairs: a signature that renders the request itself, rather than a private
|
|
381
|
+
guess at how it renders, cannot disagree with the wire. An earlier version
|
|
382
|
+
walked dicts by hand and returned anything else unchanged, so a mapping
|
|
383
|
+
that was not a ``dict`` -- the annotation says ``Mapping``, and
|
|
384
|
+
``QueryParams`` and ``MappingProxyType`` are both mappings -- fell through
|
|
385
|
+
to ``json.dumps(default=str)``. Two mappings with one ``str()`` then shared
|
|
386
|
+
a signature while two different query strings went on the wire: the same
|
|
387
|
+
replay this file has now been rewritten for six times, wearing parameters
|
|
388
|
+
instead of headers.
|
|
389
|
+
|
|
390
|
+
Un-renderable parameters raise rather than degrade. A signature we cannot
|
|
391
|
+
compute honestly is not a cache miss, it is a request we do not understand,
|
|
392
|
+
and answering it from a directory keyed by a guess is how this fails.
|
|
393
|
+
"""
|
|
394
|
+
if value is None:
|
|
395
|
+
return None
|
|
396
|
+
try:
|
|
397
|
+
return [[name, item] for name, item in httpx.QueryParams(value).multi_items()]
|
|
398
|
+
except (TypeError, ValueError, AttributeError) as exc:
|
|
399
|
+
raise TypeError(
|
|
400
|
+
f"query parameters cannot be rendered for a cache key: {type(value).__name__}"
|
|
401
|
+
) from exc
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def read_http(key: str, *, signature: str | None = None) -> CachedResponse | None:
|
|
405
|
+
"""The stored response for ``key``, or ``None``.
|
|
406
|
+
|
|
407
|
+
Any unreadable or unrecognised entry is a miss. A cache that guesses at a
|
|
408
|
+
corrupt entry hands back bytes nobody can vouch for, and the receipt would
|
|
409
|
+
attest to them.
|
|
410
|
+
|
|
411
|
+
``signature`` is the caller's :func:`request_signature`. When it is given
|
|
412
|
+
and the entry does not carry the same one, the entry is a miss: the key
|
|
413
|
+
already covers the request, so a mismatch means the directory is not what
|
|
414
|
+
this build thinks it is, and the safe reading of that is "fetch it again".
|
|
415
|
+
"""
|
|
416
|
+
slot = _slot("http", key)
|
|
417
|
+
if slot is None:
|
|
418
|
+
return None
|
|
419
|
+
meta_path, body_path = slot.with_suffix(".meta.json"), slot.with_suffix(".body")
|
|
420
|
+
try:
|
|
421
|
+
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
422
|
+
body = body_path.read_bytes()
|
|
423
|
+
except (OSError, ValueError):
|
|
424
|
+
return None
|
|
425
|
+
|
|
426
|
+
digest = hashlib.sha256(body).hexdigest()
|
|
427
|
+
if digest != meta.get("sha256"):
|
|
428
|
+
# The body on disk is not the body the entry describes. Treat it as a
|
|
429
|
+
# miss and let the next write replace it.
|
|
430
|
+
return None
|
|
431
|
+
if signature is not None and meta.get("request_signature") != signature:
|
|
432
|
+
return None
|
|
433
|
+
try:
|
|
434
|
+
return CachedResponse(
|
|
435
|
+
url=meta["url"],
|
|
436
|
+
status=meta["status"],
|
|
437
|
+
body=body,
|
|
438
|
+
sha256=digest,
|
|
439
|
+
headers=meta["headers"],
|
|
440
|
+
retrieved_at=datetime.fromisoformat(meta["retrieved_at"]),
|
|
441
|
+
revalidated_at=datetime.fromisoformat(meta["revalidated_at"]),
|
|
442
|
+
request_signature=meta.get("request_signature", ""),
|
|
443
|
+
)
|
|
444
|
+
except (KeyError, TypeError, ValueError):
|
|
445
|
+
return None
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
_NO_VALIDATOR = "no ETag and no Last-Modified: nothing to revalidate against"
|
|
449
|
+
|
|
450
|
+
#: 206 bodies are never stored. The key covers ``Range`` now, so a partial
|
|
451
|
+
#: could only be replayed to a caller asking for the same range -- but a range
|
|
452
|
+
#: request is a probe, its answer is a fragment of a file, and one server that
|
|
453
|
+
#: ignores ``If-Range`` on the way back turns that fragment into a whole
|
|
454
|
+
#: document as far as the parser is concerned. There is nothing to gain: the
|
|
455
|
+
#: probes this product makes are 2 KB.
|
|
456
|
+
_PARTIAL_CONTENT = (
|
|
457
|
+
"206 Partial Content: SourceLock does not store fragments, because a "
|
|
458
|
+
"fragment replayed as a whole document is a truncated parse with a "
|
|
459
|
+
"confident receipt on it"
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def note_uncacheable(key: str, url: str, reason: str = _NO_VALIDATOR) -> None:
|
|
464
|
+
"""Record that an endpoint cannot be cached, and why, for ``cache info``.
|
|
465
|
+
|
|
466
|
+
Without this, an operator who enables the cache and then sees ``0 entries``
|
|
467
|
+
has no way to tell "the cache is broken" from "CMS does not send validators
|
|
468
|
+
on that endpoint". The second is true of most of the Coverage API, and it is
|
|
469
|
+
a fact about upstream that nobody should have to discover with curl.
|
|
470
|
+
"""
|
|
471
|
+
slot = _slot("uncacheable", key)
|
|
472
|
+
if slot is None:
|
|
473
|
+
return
|
|
474
|
+
try:
|
|
475
|
+
_write_atomic(
|
|
476
|
+
slot.with_suffix(".json"),
|
|
477
|
+
json.dumps(
|
|
478
|
+
{"url": url, "reason": reason, "seen_at": _utcnow().isoformat()},
|
|
479
|
+
sort_keys=True,
|
|
480
|
+
).encode("utf-8"),
|
|
481
|
+
)
|
|
482
|
+
except OSError:
|
|
483
|
+
return
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def write_http(key: str, entry: CachedResponse) -> bool:
|
|
487
|
+
"""Store a response. Returns False when nothing was stored.
|
|
488
|
+
|
|
489
|
+
A response with no ``ETag`` and no ``Last-Modified`` is NOT stored: without
|
|
490
|
+
a validator there is no way to ask upstream whether the bytes are still
|
|
491
|
+
current, and this cache never serves bytes it has not just re-confirmed.
|
|
492
|
+
Nor is anything but a plain ``200``: see :data:`_PARTIAL_CONTENT`.
|
|
493
|
+
"""
|
|
494
|
+
slot = _slot("http", key)
|
|
495
|
+
if slot is None:
|
|
496
|
+
return False
|
|
497
|
+
if entry.status == 206:
|
|
498
|
+
note_uncacheable(key, entry.url, _PARTIAL_CONTENT)
|
|
499
|
+
return False
|
|
500
|
+
if entry.status is not None and entry.status != 200:
|
|
501
|
+
return False
|
|
502
|
+
if not (entry.etag or entry.last_modified):
|
|
503
|
+
note_uncacheable(key, entry.url)
|
|
504
|
+
return False
|
|
505
|
+
meta = {
|
|
506
|
+
"layout": LAYOUT_VERSION,
|
|
507
|
+
"url": entry.url,
|
|
508
|
+
"status": entry.status,
|
|
509
|
+
"sha256": entry.sha256,
|
|
510
|
+
"headers": entry.headers,
|
|
511
|
+
"retrieved_at": entry.retrieved_at.astimezone(timezone.utc).isoformat(),
|
|
512
|
+
"revalidated_at": entry.revalidated_at.astimezone(timezone.utc).isoformat(),
|
|
513
|
+
"request_signature": entry.request_signature,
|
|
514
|
+
}
|
|
515
|
+
try:
|
|
516
|
+
_write_atomic(slot.with_suffix(".body"), entry.body)
|
|
517
|
+
_write_atomic(
|
|
518
|
+
slot.with_suffix(".meta.json"),
|
|
519
|
+
json.dumps(meta, sort_keys=True).encode("utf-8"),
|
|
520
|
+
)
|
|
521
|
+
except OSError:
|
|
522
|
+
# A cache that cannot be written is a slow product, not a broken one.
|
|
523
|
+
return False
|
|
524
|
+
return True
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def touch_http(key: str, entry: CachedResponse, revalidated_at: datetime) -> None:
|
|
528
|
+
"""Record that upstream just confirmed a stored entry is still current."""
|
|
529
|
+
slot = _slot("http", key)
|
|
530
|
+
if slot is None:
|
|
531
|
+
return
|
|
532
|
+
meta_path = slot.with_suffix(".meta.json")
|
|
533
|
+
try:
|
|
534
|
+
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
535
|
+
meta["revalidated_at"] = revalidated_at.astimezone(timezone.utc).isoformat()
|
|
536
|
+
_write_atomic(meta_path, json.dumps(meta, sort_keys=True).encode("utf-8"))
|
|
537
|
+
except (OSError, ValueError):
|
|
538
|
+
return
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def parse_http_date(value: str | None) -> datetime | None:
|
|
542
|
+
if not value:
|
|
543
|
+
return None
|
|
544
|
+
try:
|
|
545
|
+
parsed = parsedate_to_datetime(value)
|
|
546
|
+
except (TypeError, ValueError):
|
|
547
|
+
return None
|
|
548
|
+
if parsed is None:
|
|
549
|
+
return None
|
|
550
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
# ---------------------------------------------------------------------------
|
|
554
|
+
# tool results
|
|
555
|
+
# ---------------------------------------------------------------------------
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def tool_key(tool: str, params: Any, *, lockfile_hash: str = "") -> str:
|
|
559
|
+
"""Cache key for one tool call.
|
|
560
|
+
|
|
561
|
+
The lockfile hash is in the key because the lockfile is what pins the source
|
|
562
|
+
versions an answer was computed against. Re-pinning a source must not leave
|
|
563
|
+
yesterday's answers reachable under today's pins -- that would be a cached
|
|
564
|
+
answer citing a release it was never derived from.
|
|
565
|
+
"""
|
|
566
|
+
material = json.dumps(
|
|
567
|
+
{"tool": tool, "params": _stable(params), "lock": lockfile_hash},
|
|
568
|
+
sort_keys=True,
|
|
569
|
+
default=str,
|
|
570
|
+
)
|
|
571
|
+
return hashlib.sha256(material.encode("utf-8")).hexdigest()
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def read_tool(key: str, ttl: float) -> tuple[Any, dict] | None:
|
|
575
|
+
"""``(data, receipt_dict)`` for a live entry, or ``None``."""
|
|
576
|
+
if ttl <= 0:
|
|
577
|
+
return None
|
|
578
|
+
slot = _slot("tools", key)
|
|
579
|
+
if slot is None:
|
|
580
|
+
return None
|
|
581
|
+
path = slot.with_suffix(".json")
|
|
582
|
+
try:
|
|
583
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
584
|
+
stored_at = datetime.fromisoformat(payload["stored_at"])
|
|
585
|
+
except (OSError, ValueError, KeyError):
|
|
586
|
+
return None
|
|
587
|
+
if (_utcnow() - stored_at).total_seconds() > ttl:
|
|
588
|
+
return None
|
|
589
|
+
try:
|
|
590
|
+
return payload["data"], payload["receipt"]
|
|
591
|
+
except KeyError:
|
|
592
|
+
return None
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def write_tool(key: str, data: Any, receipt: dict) -> bool:
|
|
596
|
+
slot = _slot("tools", key)
|
|
597
|
+
if slot is None:
|
|
598
|
+
return False
|
|
599
|
+
payload = {
|
|
600
|
+
"layout": LAYOUT_VERSION,
|
|
601
|
+
"stored_at": _utcnow().isoformat(),
|
|
602
|
+
"data": data,
|
|
603
|
+
"receipt": receipt,
|
|
604
|
+
}
|
|
605
|
+
try:
|
|
606
|
+
_write_atomic(
|
|
607
|
+
slot.with_suffix(".json"),
|
|
608
|
+
json.dumps(payload, sort_keys=True, default=str).encode("utf-8"),
|
|
609
|
+
)
|
|
610
|
+
except (OSError, TypeError, ValueError):
|
|
611
|
+
return False
|
|
612
|
+
return True
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
# ---------------------------------------------------------------------------
|
|
616
|
+
# housekeeping
|
|
617
|
+
# ---------------------------------------------------------------------------
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def cache_stats() -> dict[str, Any]:
|
|
621
|
+
root = cache_dir()
|
|
622
|
+
stats: dict[str, Any] = {
|
|
623
|
+
"enabled": root is not None,
|
|
624
|
+
"path": str(root) if root else None,
|
|
625
|
+
"tool_cache_ttl_seconds": tool_ttl(),
|
|
626
|
+
"http_entries": 0,
|
|
627
|
+
"tool_entries": 0,
|
|
628
|
+
"bytes": 0,
|
|
629
|
+
# Endpoints seen that upstream serves without a validator. Not a
|
|
630
|
+
# failure: an honest fact about the source, and the answer to "why does
|
|
631
|
+
# this say 0 entries".
|
|
632
|
+
"uncacheable_urls": [],
|
|
633
|
+
}
|
|
634
|
+
if root is None or not root.exists():
|
|
635
|
+
return stats
|
|
636
|
+
uncacheable: set[str] = set()
|
|
637
|
+
for path in root.rglob("*"):
|
|
638
|
+
if not path.is_file():
|
|
639
|
+
continue
|
|
640
|
+
try:
|
|
641
|
+
stats["bytes"] += path.stat().st_size
|
|
642
|
+
except OSError:
|
|
643
|
+
continue
|
|
644
|
+
if path.name.endswith(".meta.json"):
|
|
645
|
+
stats["http_entries"] += 1
|
|
646
|
+
elif path.suffix == ".json" and path.parent.parent.name == "tools":
|
|
647
|
+
stats["tool_entries"] += 1
|
|
648
|
+
elif path.suffix == ".json" and path.parent.parent.name == "uncacheable":
|
|
649
|
+
try:
|
|
650
|
+
uncacheable.add(json.loads(path.read_text(encoding="utf-8"))["url"])
|
|
651
|
+
except (OSError, ValueError, KeyError):
|
|
652
|
+
continue
|
|
653
|
+
stats["uncacheable_urls"] = sorted(uncacheable)
|
|
654
|
+
return stats
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def clear_cache() -> dict[str, Any]:
|
|
658
|
+
"""Delete everything. Returns what was there before, for the operator."""
|
|
659
|
+
before = cache_stats()
|
|
660
|
+
root = cache_dir()
|
|
661
|
+
if root is not None and root.exists():
|
|
662
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
663
|
+
before["cleared"] = root is not None
|
|
664
|
+
return before
|