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/doctor.py
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
"""``hc-source doctor``: run every canary and compare it to the lockfile.
|
|
2
|
+
|
|
3
|
+
Exit codes, which are the contract with CI:
|
|
4
|
+
|
|
5
|
+
* ``0`` -- everything matches the lockfile.
|
|
6
|
+
* ``1`` -- drift or a schema change: upstream moved and the lockfile is stale.
|
|
7
|
+
* ``2`` -- a source was unreachable, or an adapter module failed to load. This
|
|
8
|
+
outranks drift, because an unreachable source means the run does not know
|
|
9
|
+
whether drift happened.
|
|
10
|
+
* ``3`` -- a canary was observed but nothing pinned it, so nothing was compared.
|
|
11
|
+
This outranks drift for the same reason ``2`` does. It used to be ``0``, which
|
|
12
|
+
meant a CI job that lost its ``source-lock.json`` reported "22 ok, 0 drift"
|
|
13
|
+
forever while real upstream regressions sailed past (round-1 finding H1).
|
|
14
|
+
``cli._scoped_discovery`` already refuses an unknown ``--source`` with exactly
|
|
15
|
+
this reasoning: silently checking nothing would report a green run that
|
|
16
|
+
verified nothing.
|
|
17
|
+
* ``4`` -- every canary matched its pin, but at least one said the thing it read
|
|
18
|
+
is out of date. The comparison succeeded and told you nothing, which is the
|
|
19
|
+
same failure as ``3`` wearing a friendlier face. It ranks below drift because
|
|
20
|
+
drift is a concrete finding and staleness is an absence of one.
|
|
21
|
+
|
|
22
|
+
Precedence, highest first: adapter load failure or unreachable source (2),
|
|
23
|
+
unpinned (3), drift or schema change (1), stale (4), ok (0). Everything above
|
|
24
|
+
``1`` means "this run does not know", which is why they outrank a finding.
|
|
25
|
+
|
|
26
|
+
**Severity is a separate axis from status.** A canary declared ``advisory``
|
|
27
|
+
reports its real status, prints its remediation, and is annotated in CI -- and
|
|
28
|
+
is left out of the exit code. That exists for the checks that cannot carry a
|
|
29
|
+
build: an HTML scrape of a page CMS restyles at will, a cross-check whose
|
|
30
|
+
disagreement means "somebody look" rather than "your data is wrong". A gate that
|
|
31
|
+
cries wolf gets uninstalled, and then it protects nothing at all.
|
|
32
|
+
|
|
33
|
+
**Transient failures are retried, deliberately narrowly.** A canary whose
|
|
34
|
+
observation raises :class:`~hc_source.http.SourceUnreachable` for a transient
|
|
35
|
+
reason -- a dropped connection, a 502, a 429 -- is observed again after an
|
|
36
|
+
exponential backoff, up to the adapter's or the canary's declared retry count.
|
|
37
|
+
Nothing else is retried: an error inside our own code returns the same error
|
|
38
|
+
next time, and a 404 is an answer. The attempt count is reported, because "ok on
|
|
39
|
+
the third try" is a different fact about a source than "ok".
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import os
|
|
45
|
+
import time
|
|
46
|
+
from dataclasses import dataclass, field
|
|
47
|
+
from typing import Any
|
|
48
|
+
|
|
49
|
+
from .adapters import AdapterLoadError, discover
|
|
50
|
+
from .http import SourceUnreachable, is_transient, request_timeout
|
|
51
|
+
from .interfaces import Canary, CanaryObservation, SourceAdapter
|
|
52
|
+
from .schemas import CanaryResult, CanarySeverity, CanaryStatus, Lockfile, utcnow
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"DoctorReport",
|
|
56
|
+
"EXIT_DRIFT",
|
|
57
|
+
"EXIT_OK",
|
|
58
|
+
"EXIT_STALE",
|
|
59
|
+
"EXIT_UNPINNED",
|
|
60
|
+
"EXIT_UNREACHABLE",
|
|
61
|
+
"MAX_RETRY_BACKOFF",
|
|
62
|
+
"RETRY_BACKOFF",
|
|
63
|
+
"run_doctor",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
EXIT_OK = 0
|
|
67
|
+
EXIT_DRIFT = 1
|
|
68
|
+
EXIT_UNREACHABLE = 2
|
|
69
|
+
EXIT_UNPINNED = 3
|
|
70
|
+
EXIT_STALE = 4
|
|
71
|
+
|
|
72
|
+
#: Base of the exponential backoff between observation attempts, in seconds.
|
|
73
|
+
RETRY_BACKOFF = 0.5
|
|
74
|
+
|
|
75
|
+
#: Ceiling on one backoff interval. A canary is supposed to be cheap; a build
|
|
76
|
+
#: waiting minutes on a source that is plainly down is not cheap.
|
|
77
|
+
MAX_RETRY_BACKOFF = 8.0
|
|
78
|
+
|
|
79
|
+
#: Raise the retry floor for every canary without editing an adapter -- for the
|
|
80
|
+
#: customer whose runners sit behind a proxy that drops one connection in fifty.
|
|
81
|
+
RETRIES_ENV = "HC_SOURCE_CANARY_RETRIES"
|
|
82
|
+
|
|
83
|
+
#: Per-request timeout for canary fetches, overriding what adapters declare.
|
|
84
|
+
TIMEOUT_ENV = "HC_SOURCE_CANARY_TIMEOUT"
|
|
85
|
+
|
|
86
|
+
#: Indirection so tests can assert the backoff schedule without waiting for it.
|
|
87
|
+
_sleep = time.sleep
|
|
88
|
+
|
|
89
|
+
_UNPINNED_WARNING = (
|
|
90
|
+
"not pinned in source-lock.json; run `hc-source lock init` to pin it so drift can be detected"
|
|
91
|
+
)
|
|
92
|
+
_UNPINNED_REMEDIATION = (
|
|
93
|
+
"This canary was observed but never compared: source-lock.json does not pin it, so this "
|
|
94
|
+
"run cannot tell you whether upstream moved. Pin it with `hc-source lock init` (and commit "
|
|
95
|
+
"the lockfile) before treating this run as a verification."
|
|
96
|
+
)
|
|
97
|
+
_FALLBACK_WARNING = (
|
|
98
|
+
"answered by the fallback mirror {name!r} because the pinned authority could not be read; "
|
|
99
|
+
"this compares the MIRROR against the pin, not the authority"
|
|
100
|
+
)
|
|
101
|
+
_STALE_REMEDIATION = (
|
|
102
|
+
"This canary matched its pin, but it reported that what it read is out of date -- see "
|
|
103
|
+
"the note below. A match against stale data is not evidence that upstream is unchanged; "
|
|
104
|
+
"it is evidence that the thing being read stopped moving. Check the source's publication "
|
|
105
|
+
"schedule before treating this run as a verification."
|
|
106
|
+
)
|
|
107
|
+
_ERROR_REMEDIATION = (
|
|
108
|
+
"This canary raised an error that is not a transport failure, so upstream may be fine and "
|
|
109
|
+
"SourceLock's own adapter is what broke. Re-run with the adapter's fixture override to "
|
|
110
|
+
"reproduce it locally, and fix the adapter -- re-pinning will not help."
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class DoctorReport:
|
|
116
|
+
results: list[CanaryResult] = field(default_factory=list)
|
|
117
|
+
load_errors: list[AdapterLoadError] = field(default_factory=list)
|
|
118
|
+
lockfile_present: bool = False
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def summary(self) -> dict[str, int]:
|
|
122
|
+
counts = {s.value: 0 for s in CanaryStatus}
|
|
123
|
+
for r in self.results:
|
|
124
|
+
counts[r.status.value] += 1
|
|
125
|
+
# Not a status: a canary can be `ok` AND have been answered by a mirror.
|
|
126
|
+
# Surfaced in the summary so CI can gate on it without walking results.
|
|
127
|
+
counts["fallback"] = sum(1 for r in self.results if r.fallback_used)
|
|
128
|
+
# Nor is this: a finding that was reported and did not fail the build.
|
|
129
|
+
# It belongs in the summary line so an advisory canary that has been
|
|
130
|
+
# drifting for six months is visible rather than merely not-failing.
|
|
131
|
+
counts["advisory"] = sum(1 for r in self.advisory_findings)
|
|
132
|
+
counts["retried"] = sum(1 for r in self.results if r.attempts > 1)
|
|
133
|
+
return counts
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def advisory_findings(self) -> list[CanaryResult]:
|
|
137
|
+
"""Non-ok results that are excluded from the verdict by declaration."""
|
|
138
|
+
return [
|
|
139
|
+
r
|
|
140
|
+
for r in self.results
|
|
141
|
+
if r.severity is CanarySeverity.ADVISORY and r.status is not CanaryStatus.OK
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def exit_code(self) -> int:
|
|
146
|
+
if self.load_errors:
|
|
147
|
+
return EXIT_UNREACHABLE
|
|
148
|
+
# Advisory canaries report their status and stay out of the verdict.
|
|
149
|
+
# An adapter that fails to LOAD is never advisory: nothing declared
|
|
150
|
+
# anything, so there is nothing to have declared it harmless.
|
|
151
|
+
statuses = {
|
|
152
|
+
r.status for r in self.results if r.severity is CanarySeverity.BLOCKING
|
|
153
|
+
}
|
|
154
|
+
# ERROR is never advisory, whatever the canary declared.
|
|
155
|
+
#
|
|
156
|
+
# Severity is a statement about UPSTREAM -- "this source restyles its
|
|
157
|
+
# HTML at will, do not fail a build over it". ERROR is the one status
|
|
158
|
+
# that is a statement about US: the observation raised something that is
|
|
159
|
+
# not a transport failure, so SourceLock's own adapter is what broke.
|
|
160
|
+
# Filtering advisory results out BEFORE looking at status meant an
|
|
161
|
+
# advisory canary could crash our code and leave doctor and the action
|
|
162
|
+
# green -- a bug in this product silenced by a declaration about
|
|
163
|
+
# somebody else's website.
|
|
164
|
+
errored = any(r.status is CanaryStatus.ERROR for r in self.results)
|
|
165
|
+
# `error` rides with `unreachable`: both mean this run produced no
|
|
166
|
+
# verdict. They are separate STATUSES because the fix differs, and one
|
|
167
|
+
# exit code because CI's decision -- stop -- is the same.
|
|
168
|
+
if errored or CanaryStatus.UNREACHABLE in statuses:
|
|
169
|
+
return EXIT_UNREACHABLE
|
|
170
|
+
if CanaryStatus.UNPINNED in statuses or not self.lockfile_present:
|
|
171
|
+
return EXIT_UNPINNED
|
|
172
|
+
if statuses & {CanaryStatus.DRIFT, CanaryStatus.SCHEMA_CHANGED}:
|
|
173
|
+
return EXIT_DRIFT
|
|
174
|
+
if CanaryStatus.STALE in statuses:
|
|
175
|
+
return EXIT_STALE
|
|
176
|
+
return EXIT_OK
|
|
177
|
+
|
|
178
|
+
def to_dict(self) -> dict[str, Any]:
|
|
179
|
+
return {
|
|
180
|
+
"exit_code": self.exit_code,
|
|
181
|
+
"lockfile_present": self.lockfile_present,
|
|
182
|
+
"summary": self.summary,
|
|
183
|
+
# as_dict, not two hand-picked fields: a load error now carries which
|
|
184
|
+
# discovery route it came from, and "the adapter I pip-installed is
|
|
185
|
+
# broken" must not read the same as "the one you ship is broken".
|
|
186
|
+
"load_errors": [e.as_dict() for e in self.load_errors],
|
|
187
|
+
"results": [r.model_dump(mode="json") for r in self.results],
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def run_doctor(
|
|
192
|
+
lockfile: Lockfile | None,
|
|
193
|
+
adapters: list[SourceAdapter] | None = None,
|
|
194
|
+
*,
|
|
195
|
+
load_errors: list[AdapterLoadError] | None = None,
|
|
196
|
+
) -> DoctorReport:
|
|
197
|
+
"""Observe every canary of every adapter and grade it against the lockfile.
|
|
198
|
+
|
|
199
|
+
``adapters`` scopes the run (e.g. ``adapters=[get_adapter("demo")]``); when
|
|
200
|
+
omitted, every discovered adapter is checked. ``load_errors`` lets a caller
|
|
201
|
+
that already ran discovery carry its load failures into the report.
|
|
202
|
+
"""
|
|
203
|
+
load_errors = list(load_errors) if load_errors is not None else []
|
|
204
|
+
if adapters is None:
|
|
205
|
+
found = discover()
|
|
206
|
+
adapters = found.adapters
|
|
207
|
+
load_errors.extend(found.errors)
|
|
208
|
+
|
|
209
|
+
report = DoctorReport(load_errors=load_errors, lockfile_present=lockfile is not None)
|
|
210
|
+
seen_ids: set[str] = set()
|
|
211
|
+
|
|
212
|
+
for adapter in adapters:
|
|
213
|
+
retries = _effective_retries(adapter)
|
|
214
|
+
timeout = _effective_timeout(adapter)
|
|
215
|
+
for canary in adapter.canaries():
|
|
216
|
+
seen_ids.add(canary.canary_id)
|
|
217
|
+
report.results.append(
|
|
218
|
+
_check(
|
|
219
|
+
canary,
|
|
220
|
+
lockfile,
|
|
221
|
+
retries=canary.retries if canary.retries is not None else retries,
|
|
222
|
+
timeout=canary.timeout if canary.timeout is not None else timeout,
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
if lockfile is not None:
|
|
227
|
+
for source_id, entry in lockfile.sources.items():
|
|
228
|
+
live_source = any(a.source_id == source_id for a in adapters)
|
|
229
|
+
for canary_id in entry.expected_canaries:
|
|
230
|
+
if canary_id in seen_ids:
|
|
231
|
+
continue
|
|
232
|
+
if not live_source:
|
|
233
|
+
# The whole adapter is absent from this install; that is a
|
|
234
|
+
# packaging question, not upstream drift.
|
|
235
|
+
continue
|
|
236
|
+
report.results.append(
|
|
237
|
+
CanaryResult(
|
|
238
|
+
canary_id=canary_id,
|
|
239
|
+
source_id=source_id,
|
|
240
|
+
status=CanaryStatus.DRIFT,
|
|
241
|
+
observed=None,
|
|
242
|
+
expected=entry.expected_canaries[canary_id].value,
|
|
243
|
+
checked_at=utcnow(),
|
|
244
|
+
remediation=(
|
|
245
|
+
f"source-lock.json pins canary {canary_id!r} but no adapter provides "
|
|
246
|
+
"it. Either restore the canary in the adapter, or drop the entry with "
|
|
247
|
+
"`hc-source lock init`."
|
|
248
|
+
),
|
|
249
|
+
)
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
report.results.sort(key=lambda r: r.canary_id)
|
|
253
|
+
return report
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _effective_retries(adapter: SourceAdapter) -> int:
|
|
257
|
+
"""The adapter's declared retries, raised (never lowered) by the environment."""
|
|
258
|
+
declared = max(0, int(getattr(adapter, "canary_retries", 0) or 0))
|
|
259
|
+
return max(declared, _env_int(RETRIES_ENV, 0))
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _effective_timeout(adapter: SourceAdapter) -> float | None:
|
|
263
|
+
env = os.environ.get(TIMEOUT_ENV)
|
|
264
|
+
if env is not None:
|
|
265
|
+
try:
|
|
266
|
+
value = float(env)
|
|
267
|
+
except ValueError:
|
|
268
|
+
value = 0.0
|
|
269
|
+
if value > 0:
|
|
270
|
+
return value
|
|
271
|
+
return getattr(adapter, "canary_timeout", None)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _env_int(name: str, default: int) -> int:
|
|
275
|
+
raw = os.environ.get(name)
|
|
276
|
+
if raw is None:
|
|
277
|
+
return default
|
|
278
|
+
try:
|
|
279
|
+
return max(0, int(raw))
|
|
280
|
+
except ValueError:
|
|
281
|
+
return default
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _observe(canary: Canary, retries: int, timeout: float | None):
|
|
285
|
+
"""Observe once, or a few times if the failure looks transient.
|
|
286
|
+
|
|
287
|
+
Returns ``(observation, attempts)`` or raises the last failure. The retry is
|
|
288
|
+
deliberately narrow: only :class:`SourceUnreachable`, only when
|
|
289
|
+
:func:`hc_source.http.is_transient` says trying again could plausibly get a
|
|
290
|
+
different answer. A bug in our own parsing raises something else and is not
|
|
291
|
+
retried -- three identical TypeErrors are not more informative than one, and
|
|
292
|
+
a build that eventually goes green on re-run teaches people to press re-run.
|
|
293
|
+
"""
|
|
294
|
+
attempts = max(0, retries) + 1
|
|
295
|
+
last: Exception | None = None
|
|
296
|
+
for attempt in range(attempts):
|
|
297
|
+
try:
|
|
298
|
+
with request_timeout(timeout):
|
|
299
|
+
return canary.observe(), attempt + 1
|
|
300
|
+
except SourceUnreachable as exc:
|
|
301
|
+
last = exc
|
|
302
|
+
if attempt == attempts - 1 or not is_transient(exc):
|
|
303
|
+
raise
|
|
304
|
+
_sleep(min(RETRY_BACKOFF * (2**attempt), MAX_RETRY_BACKOFF))
|
|
305
|
+
raise last # pragma: no cover - the loop either returns or raises
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _check(
|
|
309
|
+
canary: Canary,
|
|
310
|
+
lockfile: Lockfile | None,
|
|
311
|
+
*,
|
|
312
|
+
retries: int = 0,
|
|
313
|
+
timeout: float | None = None,
|
|
314
|
+
) -> CanaryResult:
|
|
315
|
+
checked_at = utcnow()
|
|
316
|
+
warnings: list[str] = []
|
|
317
|
+
attempts = 1
|
|
318
|
+
|
|
319
|
+
try:
|
|
320
|
+
observation, attempts = _observe(canary, retries, timeout)
|
|
321
|
+
except SourceUnreachable as exc:
|
|
322
|
+
return _result(
|
|
323
|
+
canary,
|
|
324
|
+
CanaryStatus.UNREACHABLE,
|
|
325
|
+
observed=None,
|
|
326
|
+
expected=_expected_value(canary, lockfile),
|
|
327
|
+
checked_at=checked_at,
|
|
328
|
+
warnings=[
|
|
329
|
+
exc.reason
|
|
330
|
+
+ (f" (after {retries + 1} attempts)" if retries else "")
|
|
331
|
+
],
|
|
332
|
+
attempts=retries + 1 if is_transient(exc) else 1,
|
|
333
|
+
)
|
|
334
|
+
except Exception as exc: # noqa: BLE001 - a bad canary must not end the run
|
|
335
|
+
# Not `unreachable`: nothing says the source is down. A TypeError in our
|
|
336
|
+
# own parsing was reported for a year as "CMS could not be read", which
|
|
337
|
+
# sent people to check a network that was never the problem.
|
|
338
|
+
return _result(
|
|
339
|
+
canary,
|
|
340
|
+
CanaryStatus.ERROR,
|
|
341
|
+
observed=None,
|
|
342
|
+
expected=_expected_value(canary, lockfile),
|
|
343
|
+
checked_at=checked_at,
|
|
344
|
+
warnings=[f"canary raised {type(exc).__name__}"],
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
if attempts > 1:
|
|
348
|
+
warnings.append(
|
|
349
|
+
f"observed on attempt {attempts} of {retries + 1}: the first "
|
|
350
|
+
f"{attempts - 1} failed transiently. This source is answering, but not "
|
|
351
|
+
"reliably on the first try"
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
if observation.fallback_used:
|
|
355
|
+
warnings.append(
|
|
356
|
+
_FALLBACK_WARNING.format(name=observation.fallback_name or "unnamed mirror")
|
|
357
|
+
)
|
|
358
|
+
# The canary's own note is the only channel an adapter has for "I saw
|
|
359
|
+
# something you should know about". It used to be recorded on the
|
|
360
|
+
# observation and then dropped: coverage's stale-snapshot signal and leie's
|
|
361
|
+
# skipped waiver cross-check never reached the CLI, the JSON, or CI.
|
|
362
|
+
if observation.note:
|
|
363
|
+
warnings.append(observation.note)
|
|
364
|
+
|
|
365
|
+
expectation = lockfile.expectation(canary.source_id, canary.canary_id) if lockfile else None
|
|
366
|
+
if expectation is None:
|
|
367
|
+
warnings.append(_UNPINNED_WARNING)
|
|
368
|
+
return _result(
|
|
369
|
+
canary,
|
|
370
|
+
CanaryStatus.UNPINNED,
|
|
371
|
+
observed=observation.value,
|
|
372
|
+
expected=None,
|
|
373
|
+
checked_at=checked_at,
|
|
374
|
+
warnings=warnings,
|
|
375
|
+
observation=observation,
|
|
376
|
+
attempts=attempts,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
status = _classify(observation, expectation, warnings)
|
|
380
|
+
|
|
381
|
+
return _result(
|
|
382
|
+
canary,
|
|
383
|
+
status,
|
|
384
|
+
observed=observation.value,
|
|
385
|
+
expected=expectation.value,
|
|
386
|
+
checked_at=checked_at,
|
|
387
|
+
warnings=warnings,
|
|
388
|
+
observation=observation,
|
|
389
|
+
attempts=attempts,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _classify(observation, expectation, warnings: list[str]) -> CanaryStatus:
|
|
394
|
+
"""Grade one observation against its pin. Shape first, then value.
|
|
395
|
+
|
|
396
|
+
The old order asked "did the value move?" first and only looked at the
|
|
397
|
+
schema hash when BOTH hashes were non-null. So a source that stopped
|
|
398
|
+
publishing its shape entirely -- the hash going from a real digest to
|
|
399
|
+
``None`` -- came back ``ok``, which is the loudest possible upstream event
|
|
400
|
+
reported as the quietest possible verdict. Shape loss is graded first, and
|
|
401
|
+
when the value moved too, that fact is recorded rather than discarded: two
|
|
402
|
+
facets, one status, no silent facts.
|
|
403
|
+
"""
|
|
404
|
+
schema_lost = expectation.schema_hash is not None and observation.schema_hash is None
|
|
405
|
+
schema_moved = (
|
|
406
|
+
expectation.schema_hash is not None
|
|
407
|
+
and observation.schema_hash is not None
|
|
408
|
+
and observation.schema_hash != expectation.schema_hash
|
|
409
|
+
)
|
|
410
|
+
value_moved = observation.value != expectation.value
|
|
411
|
+
|
|
412
|
+
if schema_lost or schema_moved:
|
|
413
|
+
if schema_lost:
|
|
414
|
+
warnings.append(
|
|
415
|
+
"the pin records a schema hash but this observation carries none: the source "
|
|
416
|
+
"stopped exposing its shape, so nothing about the shape was verified"
|
|
417
|
+
)
|
|
418
|
+
if value_moved:
|
|
419
|
+
warnings.append("the observed value moved as well as the shape")
|
|
420
|
+
return CanaryStatus.SCHEMA_CHANGED
|
|
421
|
+
|
|
422
|
+
if value_moved:
|
|
423
|
+
return CanaryStatus.DRIFT
|
|
424
|
+
|
|
425
|
+
# Matched. Whether that is assurance depends on what the canary read.
|
|
426
|
+
if observation.stale:
|
|
427
|
+
return CanaryStatus.STALE
|
|
428
|
+
return CanaryStatus.OK
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _expected_value(canary: Canary, lockfile: Lockfile | None) -> str | None:
|
|
432
|
+
expectation = lockfile.expectation(canary.source_id, canary.canary_id) if lockfile else None
|
|
433
|
+
return expectation.value if expectation else None
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _result(
|
|
437
|
+
canary: Canary,
|
|
438
|
+
status: CanaryStatus,
|
|
439
|
+
*,
|
|
440
|
+
observed: str | None,
|
|
441
|
+
expected: str | None,
|
|
442
|
+
checked_at,
|
|
443
|
+
warnings: list[str],
|
|
444
|
+
observation: CanaryObservation | None = None,
|
|
445
|
+
attempts: int = 1,
|
|
446
|
+
) -> CanaryResult:
|
|
447
|
+
if status is CanaryStatus.OK:
|
|
448
|
+
remediation = ""
|
|
449
|
+
elif status is CanaryStatus.UNPINNED:
|
|
450
|
+
# The canary's own remediation text is about DRIFT ("upstream moved,
|
|
451
|
+
# re-pin"). Unpinned is a different problem with a different fix.
|
|
452
|
+
remediation = _UNPINNED_REMEDIATION
|
|
453
|
+
elif status is CanaryStatus.STALE:
|
|
454
|
+
remediation = _STALE_REMEDIATION
|
|
455
|
+
elif status is CanaryStatus.ERROR:
|
|
456
|
+
remediation = _ERROR_REMEDIATION
|
|
457
|
+
else:
|
|
458
|
+
remediation = canary.resolve_remediation(status, observed, expected)
|
|
459
|
+
return CanaryResult(
|
|
460
|
+
canary_id=canary.canary_id,
|
|
461
|
+
source_id=canary.source_id,
|
|
462
|
+
status=status,
|
|
463
|
+
severity=canary.severity,
|
|
464
|
+
attempts=max(1, attempts),
|
|
465
|
+
observed=observed,
|
|
466
|
+
expected=expected,
|
|
467
|
+
checked_at=checked_at,
|
|
468
|
+
remediation=remediation,
|
|
469
|
+
warnings=warnings,
|
|
470
|
+
fallback_used=bool(observation and observation.fallback_used),
|
|
471
|
+
fallback_name=(observation.fallback_name if observation else None),
|
|
472
|
+
)
|