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/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