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