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/interfaces.py
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""The adapter interface.
|
|
2
|
+
|
|
3
|
+
This module is the contract between SourceLock's core and the route adapters.
|
|
4
|
+
It is deliberately small: four types and one base class.
|
|
5
|
+
|
|
6
|
+
from hc_source.adapters import (
|
|
7
|
+
Canary, CanaryObservation, SourceAdapter, ToolResult, ToolSpec,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
A route adapter is a single module in ``hc_source/adapters/`` that ends with::
|
|
11
|
+
|
|
12
|
+
ADAPTER = MyAdapter()
|
|
13
|
+
|
|
14
|
+
Nothing else in the repository needs to change for it to be discovered, listed,
|
|
15
|
+
called, canaried, locked, and served over MCP.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Any, Callable, Mapping
|
|
22
|
+
|
|
23
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
24
|
+
|
|
25
|
+
from pydantic import ValidationError
|
|
26
|
+
|
|
27
|
+
from .guard import PHI_NON_CLAIM, ParamsRejected, assert_public_params, safe_validation_reasons
|
|
28
|
+
from .schemas import CanarySeverity, CanaryStatus, Receipt, SourceContract
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"AdapterError",
|
|
32
|
+
"Canary",
|
|
33
|
+
"CanaryObservation",
|
|
34
|
+
"CanarySeverity",
|
|
35
|
+
"SourceAdapter",
|
|
36
|
+
"ToolResult",
|
|
37
|
+
"ToolSpec",
|
|
38
|
+
"validate_adapter",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AdapterError(Exception):
|
|
43
|
+
"""An adapter module is malformed. Raised at discovery time, never later."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class CanaryObservation(BaseModel):
|
|
47
|
+
"""What a canary saw upstream, reduced to something comparable.
|
|
48
|
+
|
|
49
|
+
``value`` is the token the lockfile pins -- a release id, a row count, a
|
|
50
|
+
version string. Keep it short, deterministic, and free of payload data.
|
|
51
|
+
``schema_hash`` lets doctor distinguish "the data moved" (drift) from "the
|
|
52
|
+
shape moved" (schema_changed), which need different fixes.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
model_config = ConfigDict(extra="forbid")
|
|
56
|
+
|
|
57
|
+
value: str = Field(min_length=1, description="Comparable observation token.")
|
|
58
|
+
schema_hash: str | None = Field(
|
|
59
|
+
default=None, description="Stable hash of the upstream shape, if the source exposes one."
|
|
60
|
+
)
|
|
61
|
+
upstream_status: int | None = None
|
|
62
|
+
note: str | None = Field(
|
|
63
|
+
default=None,
|
|
64
|
+
description=(
|
|
65
|
+
"One short line for humans. No payloads. Doctor copies this onto the "
|
|
66
|
+
"result's warnings, so it reaches the CLI, the JSON report, and the "
|
|
67
|
+
"GitHub annotations -- it used to be dropped on the floor."
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
stale: bool = Field(
|
|
71
|
+
default=False,
|
|
72
|
+
description=(
|
|
73
|
+
"True when the canary read something real but out of date: a weekly "
|
|
74
|
+
"snapshot that has not moved in a month, a cross-check it had to skip. "
|
|
75
|
+
"Doctor downgrades an otherwise-matching canary to STALE, because an "
|
|
76
|
+
"`ok` computed from data the canary itself calls stale is not "
|
|
77
|
+
"assurance. Say why in `note`."
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
fallback_used: bool = Field(
|
|
81
|
+
default=False,
|
|
82
|
+
description=(
|
|
83
|
+
"True when this observation came from a mirror because the pinned authority "
|
|
84
|
+
"was unreachable. Pass `FetchResult.fallback_used` straight through: doctor "
|
|
85
|
+
"cannot tell 'the authority agrees with the pin' from 'the authority is dark "
|
|
86
|
+
"and a mirror agrees with the pin' unless the canary says so."
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
fallback_name: str | None = Field(
|
|
90
|
+
default=None, description="Which mirror answered, when fallback_used is True."
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class Canary:
|
|
96
|
+
"""One cheap, deterministic, unauthenticated check against a source.
|
|
97
|
+
|
|
98
|
+
``observe`` must either return a :class:`CanaryObservation` or raise
|
|
99
|
+
:class:`hc_source.http.SourceUnreachable`. Any other exception is caught by
|
|
100
|
+
doctor and reported as unreachable with a warning -- it is never allowed to
|
|
101
|
+
take the run down.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
canary_id: str
|
|
105
|
+
source_id: str
|
|
106
|
+
description: str
|
|
107
|
+
observe: Callable[[], CanaryObservation]
|
|
108
|
+
#: Exact human-readable fix, shown on any non-ok status. Required.
|
|
109
|
+
remediation: str
|
|
110
|
+
#: Optional override producing status-specific remediation text.
|
|
111
|
+
remediation_for: Callable[[CanaryStatus, str | None, str | None], str] | None = None
|
|
112
|
+
#: Whether a finding here may fail somebody's build.
|
|
113
|
+
#:
|
|
114
|
+
#: ``BLOCKING`` (the default) is the honest default: a canary exists because
|
|
115
|
+
#: a change upstream would make an answer wrong. ``ADVISORY`` is for the
|
|
116
|
+
#: checks that cannot support that weight -- an HTML scrape of a page CMS
|
|
117
|
+
#: restyles at will, a cross-check whose disagreement means "look at this",
|
|
118
|
+
#: not "your data is wrong". An advisory finding is reported with its real
|
|
119
|
+
#: status, annotated in CI, and left out of the exit code. Do not reach for
|
|
120
|
+
#: it to quiet a canary that is telling the truth: a gate that cries wolf
|
|
121
|
+
#: gets uninstalled, and a gate that never fires gets uninstalled too.
|
|
122
|
+
severity: CanarySeverity = CanarySeverity.BLOCKING
|
|
123
|
+
#: Retries for a TRANSIENT upstream failure, or ``None`` for the adapter's
|
|
124
|
+
#: default. Never retries a bug in our own code, a body over the ceiling, or
|
|
125
|
+
#: a 4xx that is not 408/425/429 -- retrying those just spends the build's
|
|
126
|
+
#: time to reach the same answer.
|
|
127
|
+
retries: int | None = None
|
|
128
|
+
#: Per-request timeout in seconds for fetches made during this observation,
|
|
129
|
+
#: or ``None`` for the adapter's default. A canary is supposed to be cheap;
|
|
130
|
+
#: this is where you say what cheap means for a source that is slow.
|
|
131
|
+
timeout: float | None = None
|
|
132
|
+
|
|
133
|
+
def resolve_remediation(
|
|
134
|
+
self, status: CanaryStatus, observed: str | None, expected: str | None
|
|
135
|
+
) -> str:
|
|
136
|
+
if self.remediation_for is not None:
|
|
137
|
+
text = self.remediation_for(status, observed, expected)
|
|
138
|
+
if text and text.strip():
|
|
139
|
+
return text
|
|
140
|
+
return self.remediation
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class ToolResult(BaseModel):
|
|
144
|
+
"""A tool's answer and the evidence behind it."""
|
|
145
|
+
|
|
146
|
+
model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
|
|
147
|
+
|
|
148
|
+
data: Any = Field(description="The answer. JSON-serialisable.")
|
|
149
|
+
receipt: Receipt
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass(frozen=True)
|
|
153
|
+
class ToolSpec:
|
|
154
|
+
"""One callable route.
|
|
155
|
+
|
|
156
|
+
``params_model`` must be a pydantic model with ``extra='forbid'``: SourceLock
|
|
157
|
+
accepts public typed parameters only, and silently ignoring an unexpected
|
|
158
|
+
parameter is how a caller ends up believing it filtered something.
|
|
159
|
+
|
|
160
|
+
``handler`` receives the VALIDATED model instance and returns a
|
|
161
|
+
:class:`ToolResult`. Call the tool through :meth:`invoke`, never the handler
|
|
162
|
+
directly -- ``invoke`` is where the zero-PHI guard runs.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
name: str
|
|
166
|
+
description: str
|
|
167
|
+
params_model: type[BaseModel]
|
|
168
|
+
handler: Callable[[Any], ToolResult]
|
|
169
|
+
tags: tuple[str, ...] = field(default=())
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def source_id(self) -> str:
|
|
173
|
+
return self.name.split(".", 1)[0]
|
|
174
|
+
|
|
175
|
+
def params_schema(self) -> dict[str, Any]:
|
|
176
|
+
schema = self.params_model.model_json_schema()
|
|
177
|
+
schema.setdefault("type", "object")
|
|
178
|
+
return schema
|
|
179
|
+
|
|
180
|
+
def invoke(self, raw_params: Mapping[str, Any] | None = None) -> ToolResult:
|
|
181
|
+
"""Guard, validate, call, and stamp the receipt.
|
|
182
|
+
|
|
183
|
+
Raises :class:`hc_source.guard.PHIRejected` for patient-shaped input and
|
|
184
|
+
:class:`hc_source.guard.ParamsRejected` (a ``ValueError``) for anything
|
|
185
|
+
that does not fit the declared parameter model.
|
|
186
|
+
|
|
187
|
+
The ``ValidationError`` is deliberately not allowed to escape. Its
|
|
188
|
+
``str()`` contains ``input_value=<the raw value>``, and a caller that
|
|
189
|
+
logs the exception would then have written the value it asked us to
|
|
190
|
+
refuse. Converting it here means every consumer of ``invoke`` -- CLI,
|
|
191
|
+
MCP server, or someone else's agent framework -- is value-safe by
|
|
192
|
+
construction rather than by remembering to be careful.
|
|
193
|
+
"""
|
|
194
|
+
params = dict(raw_params or {})
|
|
195
|
+
assert_public_params(params, tool=self.name)
|
|
196
|
+
try:
|
|
197
|
+
model = self.params_model.model_validate(params)
|
|
198
|
+
except ValidationError as exc:
|
|
199
|
+
# `from None`: chaining would put the raw ValidationError, and with
|
|
200
|
+
# it the input value, into any printed traceback.
|
|
201
|
+
raise ParamsRejected(self.name, safe_validation_reasons(exc)) from None
|
|
202
|
+
|
|
203
|
+
# The result cache sits AFTER the guard and the model, never before. A
|
|
204
|
+
# cache consulted on raw input would answer a question nobody validated,
|
|
205
|
+
# and a patient-shaped parameter would become a cache key.
|
|
206
|
+
cached = _cache_lookup(self.name, model)
|
|
207
|
+
if cached is not None:
|
|
208
|
+
return cached
|
|
209
|
+
|
|
210
|
+
result = self.handler(model)
|
|
211
|
+
|
|
212
|
+
if not isinstance(result, ToolResult):
|
|
213
|
+
raise AdapterError(f"{self.name}: handler returned {type(result).__name__}, expected ToolResult")
|
|
214
|
+
receipt = result.receipt
|
|
215
|
+
_assert_receipt_is_bound(self.name, result.receipt)
|
|
216
|
+
if PHI_NON_CLAIM not in receipt.non_claims:
|
|
217
|
+
receipt.non_claims.insert(0, PHI_NON_CLAIM)
|
|
218
|
+
_cache_store(self.name, model, result)
|
|
219
|
+
return result
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _assert_receipt_is_bound(tool_name: str, receipt: Receipt) -> None:
|
|
223
|
+
"""A receipt may only speak for the route and the source that produced it.
|
|
224
|
+
|
|
225
|
+
``route`` was checked from the start. ``source_id`` was not, and it is the
|
|
226
|
+
field a reader trusts to know WHOSE data an answer came from: the lockfile
|
|
227
|
+
pins by source id, doctor grades by source id, and every "this came from
|
|
228
|
+
CMS" claim in the product is that string. An installed plugin's
|
|
229
|
+
``acme.lookup`` could return a receipt saying ``source_id='codes'`` and the
|
|
230
|
+
answer would be filed under a source it never touched -- silently, since
|
|
231
|
+
the two fields are read in different places. Discovery already refuses to
|
|
232
|
+
let a plugin CLAIM a built-in source id; this refuses to let one borrow the
|
|
233
|
+
name on the way out.
|
|
234
|
+
"""
|
|
235
|
+
if receipt.route != tool_name:
|
|
236
|
+
raise AdapterError(
|
|
237
|
+
f"{tool_name}: receipt.route is {receipt.route!r}; it must equal the tool name"
|
|
238
|
+
)
|
|
239
|
+
expected = tool_name.split(".", 1)[0]
|
|
240
|
+
if receipt.source_id != expected:
|
|
241
|
+
raise AdapterError(
|
|
242
|
+
f"{tool_name}: receipt.source_id is {receipt.source_id!r}, but this tool "
|
|
243
|
+
f"belongs to {expected!r}. A receipt names the source an answer came from; "
|
|
244
|
+
"one adapter may not issue evidence under another's name."
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
#: Warning added to a receipt served out of the result cache. It is a warning
|
|
249
|
+
#: rather than a silent flag because the result cache, unlike the HTTP cache,
|
|
250
|
+
#: did not ask upstream anything -- the operator opted into that tradeoff by
|
|
251
|
+
#: setting a TTL, and every answer that took it says so.
|
|
252
|
+
CACHE_HIT_WARNING = (
|
|
253
|
+
"CACHED_RESULT: this answer was replayed from the local result cache "
|
|
254
|
+
"(HC_SOURCE_TOOL_CACHE_TTL) and no source was contacted. retrieved_at, "
|
|
255
|
+
"source_version and raw_sha256 describe the ORIGINAL read; clear the cache "
|
|
256
|
+
"with `hc-source cache clear` to force a fresh one."
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _cache_lookup(tool: str, model: BaseModel) -> "ToolResult | None":
|
|
261
|
+
"""A stored result for this exact call, or ``None``. Off unless a TTL is set."""
|
|
262
|
+
from . import cache
|
|
263
|
+
from .lockfile import lockfile_fingerprint
|
|
264
|
+
|
|
265
|
+
ttl = cache.tool_ttl()
|
|
266
|
+
if ttl <= 0:
|
|
267
|
+
return None
|
|
268
|
+
key = cache.tool_key(
|
|
269
|
+
tool, model.model_dump(mode="json"), lockfile_hash=lockfile_fingerprint()
|
|
270
|
+
)
|
|
271
|
+
stored = cache.read_tool(key, ttl)
|
|
272
|
+
if stored is None:
|
|
273
|
+
return None
|
|
274
|
+
data, receipt_payload = stored
|
|
275
|
+
try:
|
|
276
|
+
receipt = Receipt.model_validate(receipt_payload)
|
|
277
|
+
except ValidationError:
|
|
278
|
+
# An entry we cannot parse is a miss. Serving a receipt we could not
|
|
279
|
+
# validate would be handing back evidence nobody checked.
|
|
280
|
+
return None
|
|
281
|
+
try:
|
|
282
|
+
# The same binding the live path enforces. A stored entry passed it once
|
|
283
|
+
# when it was written, so reaching this is a cache directory that is not
|
|
284
|
+
# what this build thinks it is -- and the safe reading of that is "miss",
|
|
285
|
+
# not "raise": the caller asked a question that has a perfectly good
|
|
286
|
+
# fresh answer.
|
|
287
|
+
_assert_receipt_is_bound(tool, receipt)
|
|
288
|
+
except AdapterError:
|
|
289
|
+
return None
|
|
290
|
+
receipt.cache_hit = True
|
|
291
|
+
if CACHE_HIT_WARNING not in receipt.warnings:
|
|
292
|
+
receipt.warnings.append(CACHE_HIT_WARNING)
|
|
293
|
+
return ToolResult(data=data, receipt=receipt)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _cache_store(tool: str, model: BaseModel, result: "ToolResult") -> None:
|
|
297
|
+
from . import cache
|
|
298
|
+
from .lockfile import lockfile_fingerprint
|
|
299
|
+
|
|
300
|
+
if cache.tool_ttl() <= 0:
|
|
301
|
+
return
|
|
302
|
+
key = cache.tool_key(
|
|
303
|
+
tool, model.model_dump(mode="json"), lockfile_hash=lockfile_fingerprint()
|
|
304
|
+
)
|
|
305
|
+
cache.write_tool(key, result.data, result.receipt.model_dump(mode="json"))
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class SourceAdapter:
|
|
309
|
+
"""Base class for a route adapter.
|
|
310
|
+
|
|
311
|
+
Subclasses set ``source_id`` and ``contract`` and override ``canaries`` and
|
|
312
|
+
``tools``. Both are methods rather than attributes so an adapter can build
|
|
313
|
+
them lazily from configuration without import-time work.
|
|
314
|
+
"""
|
|
315
|
+
|
|
316
|
+
source_id: str = ""
|
|
317
|
+
contract: SourceContract
|
|
318
|
+
|
|
319
|
+
#: Retries doctor allows each of this adapter's canaries on a TRANSIENT
|
|
320
|
+
#: upstream failure. Zero is right for a source read from vendored data --
|
|
321
|
+
#: there is no network to be flaky. Set 2 when the canaries talk to a live
|
|
322
|
+
#: service: CMS returns the occasional 502, and a red build that goes green
|
|
323
|
+
#: on re-run teaches people to press re-run.
|
|
324
|
+
canary_retries: int = 0
|
|
325
|
+
|
|
326
|
+
#: Per-request timeout in seconds for this adapter's canaries. ``None``
|
|
327
|
+
#: means the transport default.
|
|
328
|
+
canary_timeout: float | None = None
|
|
329
|
+
|
|
330
|
+
def canaries(self) -> list[Canary]:
|
|
331
|
+
return []
|
|
332
|
+
|
|
333
|
+
def tools(self) -> list[ToolSpec]:
|
|
334
|
+
return []
|
|
335
|
+
|
|
336
|
+
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
337
|
+
return f"<{type(self).__name__} source_id={self.source_id!r}>"
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def validate_adapter(adapter: Any, *, origin: str = "<unknown>") -> SourceAdapter:
|
|
341
|
+
"""Check an ADAPTER object against the interface. Raises :class:`AdapterError`.
|
|
342
|
+
|
|
343
|
+
Discovery runs this on every adapter, so a malformed adapter fails at load
|
|
344
|
+
with a message naming its module -- not halfway through a doctor run.
|
|
345
|
+
"""
|
|
346
|
+
where = f"{origin}: ADAPTER"
|
|
347
|
+
|
|
348
|
+
if not isinstance(adapter, SourceAdapter):
|
|
349
|
+
raise AdapterError(f"{where} must subclass hc_source.adapters.SourceAdapter")
|
|
350
|
+
|
|
351
|
+
source_id = getattr(adapter, "source_id", "")
|
|
352
|
+
if not isinstance(source_id, str) or not source_id:
|
|
353
|
+
raise AdapterError(f"{where} must set a non-empty source_id")
|
|
354
|
+
|
|
355
|
+
contract = getattr(adapter, "contract", None)
|
|
356
|
+
if not isinstance(contract, SourceContract):
|
|
357
|
+
raise AdapterError(f"{where}.contract must be a SourceContract")
|
|
358
|
+
if contract.source_id != source_id:
|
|
359
|
+
raise AdapterError(
|
|
360
|
+
f"{where}.contract.source_id is {contract.source_id!r} but source_id is {source_id!r}"
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
canaries = adapter.canaries()
|
|
364
|
+
if not isinstance(canaries, list) or not all(isinstance(c, Canary) for c in canaries):
|
|
365
|
+
raise AdapterError(f"{where}.canaries() must return a list[Canary]")
|
|
366
|
+
seen_canaries: set[str] = set()
|
|
367
|
+
for c in canaries:
|
|
368
|
+
if c.source_id != source_id:
|
|
369
|
+
raise AdapterError(f"{where}: canary {c.canary_id!r} has source_id {c.source_id!r}")
|
|
370
|
+
if not c.canary_id.startswith(f"{source_id}."):
|
|
371
|
+
raise AdapterError(f"{where}: canary id {c.canary_id!r} must start with '{source_id}.'")
|
|
372
|
+
if c.canary_id in seen_canaries:
|
|
373
|
+
raise AdapterError(f"{where}: duplicate canary id {c.canary_id!r}")
|
|
374
|
+
if not (c.remediation.strip() or c.remediation_for):
|
|
375
|
+
raise AdapterError(f"{where}: canary {c.canary_id!r} needs remediation text")
|
|
376
|
+
seen_canaries.add(c.canary_id)
|
|
377
|
+
|
|
378
|
+
tools = adapter.tools()
|
|
379
|
+
if not isinstance(tools, list) or not all(isinstance(t, ToolSpec) for t in tools):
|
|
380
|
+
raise AdapterError(f"{where}.tools() must return a list[ToolSpec]")
|
|
381
|
+
seen_tools: set[str] = set()
|
|
382
|
+
for t in tools:
|
|
383
|
+
if not t.name.startswith(f"{source_id}."):
|
|
384
|
+
raise AdapterError(f"{where}: tool name {t.name!r} must start with '{source_id}.'")
|
|
385
|
+
if t.name in seen_tools:
|
|
386
|
+
raise AdapterError(f"{where}: duplicate tool name {t.name!r}")
|
|
387
|
+
if not (isinstance(t.params_model, type) and issubclass(t.params_model, BaseModel)):
|
|
388
|
+
raise AdapterError(f"{where}: tool {t.name!r} params_model must be a pydantic model")
|
|
389
|
+
if t.params_model.model_config.get("extra") != "forbid":
|
|
390
|
+
raise AdapterError(
|
|
391
|
+
f"{where}: tool {t.name!r} params_model must set ConfigDict(extra='forbid')"
|
|
392
|
+
)
|
|
393
|
+
seen_tools.add(t.name)
|
|
394
|
+
|
|
395
|
+
return adapter
|
hc_source/lockfile.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Reading, writing, and building ``source-lock.json``.
|
|
2
|
+
|
|
3
|
+
The lockfile is what turns "the data moved" from a silent production surprise
|
|
4
|
+
into a failing build. It is meant to be committed and reviewed like any other
|
|
5
|
+
lockfile.
|
|
6
|
+
|
|
7
|
+
Two rules govern everything in this module, both learned the hard way:
|
|
8
|
+
|
|
9
|
+
* **A failed observation never erases a pin.** ``build_lockfile`` used to
|
|
10
|
+
``continue`` past any canary that raised, then emit whatever it managed to
|
|
11
|
+
see. An offline ``hc-source lock init --source codes`` therefore pinned the
|
|
12
|
+
three vendored canaries, silently dropped the two live release-train pins, and
|
|
13
|
+
exited 0 -- destroying the only artifact that can detect drift, in the one
|
|
14
|
+
situation (no network) where the operator is least likely to notice.
|
|
15
|
+
* **Nothing is written until the whole build validates.** The write is a
|
|
16
|
+
temp-file-plus-rename, so a crash or a full disk leaves the previous lockfile
|
|
17
|
+
intact rather than a truncated one.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import hashlib
|
|
23
|
+
import os
|
|
24
|
+
import tempfile
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from .interfaces import SourceAdapter
|
|
29
|
+
from .schemas import ExpectedCanary, Lockfile, SourceLockEntry, utcnow
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"DEFAULT_LOCK_FILENAME",
|
|
33
|
+
"CanaryFailure",
|
|
34
|
+
"LockfileBuild",
|
|
35
|
+
"build_lockfile",
|
|
36
|
+
"default_lock_path",
|
|
37
|
+
"load_lockfile",
|
|
38
|
+
"lockfile_fingerprint",
|
|
39
|
+
"save_lockfile",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
DEFAULT_LOCK_FILENAME = "source-lock.json"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def default_lock_path() -> Path:
|
|
46
|
+
return Path.cwd() / DEFAULT_LOCK_FILENAME
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def lockfile_fingerprint(path: Path | None = None) -> str:
|
|
50
|
+
"""SHA-256 of the lockfile on disk, or ``""`` when there is none.
|
|
51
|
+
|
|
52
|
+
Used as part of the tool-result cache key. Re-pinning a source must not
|
|
53
|
+
leave yesterday's answers reachable under today's pins: a cached answer
|
|
54
|
+
citing a release it was never derived from is precisely the failure this
|
|
55
|
+
product exists to prevent, and a cache is a fast way to build one.
|
|
56
|
+
"""
|
|
57
|
+
path = Path(path) if path is not None else default_lock_path()
|
|
58
|
+
try:
|
|
59
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
60
|
+
except OSError:
|
|
61
|
+
return ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load_lockfile(path: Path) -> Lockfile:
|
|
65
|
+
"""Load and validate a lockfile. Raises FileNotFoundError with the fix."""
|
|
66
|
+
path = Path(path)
|
|
67
|
+
if not path.exists():
|
|
68
|
+
raise FileNotFoundError(
|
|
69
|
+
f"no lockfile at {path}. Create one with `hc-source lock init`."
|
|
70
|
+
)
|
|
71
|
+
return Lockfile.model_validate_json(path.read_text(encoding="utf-8"))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def save_lockfile(lockfile: Lockfile, path: Path) -> Path:
|
|
75
|
+
"""Write a lockfile as stable, diff-friendly JSON, atomically.
|
|
76
|
+
|
|
77
|
+
Serialise first, then write a temp file in the destination directory, then
|
|
78
|
+
``os.replace``. A half-written ``source-lock.json`` is worse than a missing
|
|
79
|
+
one: it fails validation on the next run, which reads as "regenerate it",
|
|
80
|
+
which is how a partial write becomes a total loss.
|
|
81
|
+
"""
|
|
82
|
+
path = Path(path)
|
|
83
|
+
payload = lockfile.model_dump_json(indent=2) + "\n"
|
|
84
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
|
|
86
|
+
handle, tmp_name = tempfile.mkstemp(
|
|
87
|
+
dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp"
|
|
88
|
+
)
|
|
89
|
+
try:
|
|
90
|
+
with os.fdopen(handle, "w", encoding="utf-8") as fh:
|
|
91
|
+
fh.write(payload)
|
|
92
|
+
fh.flush()
|
|
93
|
+
os.fsync(fh.fileno())
|
|
94
|
+
os.replace(tmp_name, path)
|
|
95
|
+
except BaseException:
|
|
96
|
+
try:
|
|
97
|
+
os.unlink(tmp_name)
|
|
98
|
+
except OSError: # pragma: no cover - best effort cleanup
|
|
99
|
+
pass
|
|
100
|
+
raise
|
|
101
|
+
return path
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class CanaryFailure:
|
|
106
|
+
"""One canary that could not be observed during a ``lock init``."""
|
|
107
|
+
|
|
108
|
+
canary_id: str
|
|
109
|
+
source_id: str
|
|
110
|
+
reason: str
|
|
111
|
+
#: True when a previous pin for this canary was carried forward untouched.
|
|
112
|
+
pin_preserved: bool = False
|
|
113
|
+
|
|
114
|
+
def __str__(self) -> str:
|
|
115
|
+
kept = "previous pin kept" if self.pin_preserved else "NOT PINNED"
|
|
116
|
+
return f"{self.canary_id}: {self.reason} ({kept})"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class LockfileBuild:
|
|
121
|
+
"""The result of observing every selected canary once.
|
|
122
|
+
|
|
123
|
+
``lockfile`` is only safe to write when ``complete`` is true, or when the
|
|
124
|
+
operator has explicitly accepted a partial pin.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
lockfile: Lockfile
|
|
128
|
+
failures: list[CanaryFailure] = field(default_factory=list)
|
|
129
|
+
#: Canary ids observed fresh from upstream in this run.
|
|
130
|
+
observed: list[str] = field(default_factory=list)
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def complete(self) -> bool:
|
|
134
|
+
return not self.failures
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def preserved(self) -> list[str]:
|
|
138
|
+
return [f.canary_id for f in self.failures if f.pin_preserved]
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def unpinned(self) -> list[str]:
|
|
142
|
+
return [f.canary_id for f in self.failures if not f.pin_preserved]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def build_lockfile(
|
|
146
|
+
adapters: list[SourceAdapter], previous: Lockfile | None = None
|
|
147
|
+
) -> LockfileBuild:
|
|
148
|
+
"""Observe every canary once and pin what it saw.
|
|
149
|
+
|
|
150
|
+
A canary that cannot be observed is recorded as a :class:`CanaryFailure` and
|
|
151
|
+
its PREVIOUS pin, if there is one, is carried through unchanged. The caller
|
|
152
|
+
decides what to do about it; this function never silently narrows a lockfile.
|
|
153
|
+
|
|
154
|
+
When ``previous`` is supplied and a source's expectations are unchanged, its
|
|
155
|
+
timestamps are carried over so that re-running ``lock init`` on an unchanged
|
|
156
|
+
world produces a byte-identical file. A lockfile that churns on every run is
|
|
157
|
+
a lockfile nobody reads in review.
|
|
158
|
+
"""
|
|
159
|
+
now = utcnow()
|
|
160
|
+
sources: dict[str, SourceLockEntry] = {}
|
|
161
|
+
failures: list[CanaryFailure] = []
|
|
162
|
+
observed_ids: list[str] = []
|
|
163
|
+
|
|
164
|
+
for adapter in adapters:
|
|
165
|
+
prior = previous.sources.get(adapter.source_id) if previous else None
|
|
166
|
+
canaries = adapter.canaries()
|
|
167
|
+
expected: dict[str, ExpectedCanary] = {}
|
|
168
|
+
#: Canary ids in declaration order, so release_id/schema_hash stay stable.
|
|
169
|
+
order: list[str] = []
|
|
170
|
+
|
|
171
|
+
for canary in canaries:
|
|
172
|
+
order.append(canary.canary_id)
|
|
173
|
+
try:
|
|
174
|
+
observation = canary.observe()
|
|
175
|
+
except Exception as exc: # noqa: BLE001 - unreachable or broken; do not pin a guess
|
|
176
|
+
kept = prior.expected_canaries.get(canary.canary_id) if prior else None
|
|
177
|
+
if kept is not None:
|
|
178
|
+
expected[canary.canary_id] = kept
|
|
179
|
+
failures.append(
|
|
180
|
+
CanaryFailure(
|
|
181
|
+
canary_id=canary.canary_id,
|
|
182
|
+
source_id=adapter.source_id,
|
|
183
|
+
reason=_reason(exc),
|
|
184
|
+
pin_preserved=kept is not None,
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
continue
|
|
188
|
+
expected[canary.canary_id] = ExpectedCanary(
|
|
189
|
+
value=observation.value, schema_hash=observation.schema_hash
|
|
190
|
+
)
|
|
191
|
+
observed_ids.append(canary.canary_id)
|
|
192
|
+
|
|
193
|
+
if not expected:
|
|
194
|
+
# Nothing observed and nothing to preserve. Keep the previous entry
|
|
195
|
+
# rather than deleting a source because today's run could not reach it.
|
|
196
|
+
if prior is not None:
|
|
197
|
+
sources[adapter.source_id] = prior
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
release_value: str | None = None
|
|
201
|
+
schema_hash: str | None = None
|
|
202
|
+
for canary_id in order:
|
|
203
|
+
pin = expected.get(canary_id)
|
|
204
|
+
if pin is None:
|
|
205
|
+
continue
|
|
206
|
+
if release_value is None:
|
|
207
|
+
release_value = pin.value
|
|
208
|
+
if schema_hash is None and pin.schema_hash:
|
|
209
|
+
schema_hash = pin.schema_hash
|
|
210
|
+
|
|
211
|
+
expected = dict(sorted(expected.items()))
|
|
212
|
+
unchanged = prior is not None and prior.expected_canaries == expected
|
|
213
|
+
|
|
214
|
+
sources[adapter.source_id] = SourceLockEntry(
|
|
215
|
+
source_id=adapter.source_id,
|
|
216
|
+
pinned_version=release_value or "unknown",
|
|
217
|
+
release_id=release_value,
|
|
218
|
+
schema_hash=schema_hash,
|
|
219
|
+
checked_at=prior.checked_at if unchanged else now,
|
|
220
|
+
expected_canaries=expected,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
sources = dict(sorted(sources.items()))
|
|
224
|
+
if previous is not None and previous.sources == sources:
|
|
225
|
+
lockfile = Lockfile(generated_at=previous.generated_at, sources=sources)
|
|
226
|
+
else:
|
|
227
|
+
lockfile = Lockfile(generated_at=now, sources=sources)
|
|
228
|
+
return LockfileBuild(lockfile=lockfile, failures=failures, observed=observed_ids)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _reason(exc: BaseException) -> str:
|
|
232
|
+
"""A short, payload-free reason. Never the exception's own str() blindly."""
|
|
233
|
+
reason = getattr(exc, "reason", None)
|
|
234
|
+
if isinstance(reason, str) and reason.strip():
|
|
235
|
+
return reason.strip()
|
|
236
|
+
return type(exc).__name__
|