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/cli.py
ADDED
|
@@ -0,0 +1,959 @@
|
|
|
1
|
+
"""``hc-source`` command line.
|
|
2
|
+
|
|
3
|
+
Exit codes are part of the contract:
|
|
4
|
+
|
|
5
|
+
* ``doctor``: 0 in sync with the lockfile, 1 drift or schema change,
|
|
6
|
+
2 unreachable source, adapter load failure, or an unpinned canary.
|
|
7
|
+
* ``call``: 0 on success, 1 on a usage or validation error, 2 when the upstream
|
|
8
|
+
source could not be read. 1 and 2 are kept apart deliberately -- a caller has
|
|
9
|
+
to be able to tell "your input was wrong" from "CMS is down", and retrying is
|
|
10
|
+
the right response to only one of them.
|
|
11
|
+
* every other command: 0 on success, 1 on a usage, I/O, or validation error.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Optional
|
|
19
|
+
|
|
20
|
+
import typer
|
|
21
|
+
from pydantic import ValidationError
|
|
22
|
+
from rich.console import Console
|
|
23
|
+
from rich.table import Table
|
|
24
|
+
|
|
25
|
+
from . import __version__, cache
|
|
26
|
+
from .adapters import AdapterError, discover, discovery_errors, find_tool, iter_tools
|
|
27
|
+
from .doctor import EXIT_DRIFT, EXIT_OK, EXIT_UNREACHABLE, run_doctor
|
|
28
|
+
from .guard import (
|
|
29
|
+
ParamsRejected,
|
|
30
|
+
PHIRejected,
|
|
31
|
+
assert_public_params,
|
|
32
|
+
safe_label,
|
|
33
|
+
safe_tool_name,
|
|
34
|
+
safe_validation_message,
|
|
35
|
+
)
|
|
36
|
+
from .http import SourceUnreachable
|
|
37
|
+
from .lockfile import (
|
|
38
|
+
DEFAULT_LOCK_FILENAME,
|
|
39
|
+
build_lockfile,
|
|
40
|
+
default_lock_path,
|
|
41
|
+
load_lockfile,
|
|
42
|
+
save_lockfile,
|
|
43
|
+
)
|
|
44
|
+
from .schemas import CanarySeverity, CanaryStatus, Lockfile, Receipt
|
|
45
|
+
|
|
46
|
+
app = typer.Typer(
|
|
47
|
+
name="hc-source",
|
|
48
|
+
help="Source assurance for public healthcare data. Receipts on every answer.",
|
|
49
|
+
no_args_is_help=True,
|
|
50
|
+
add_completion=False,
|
|
51
|
+
)
|
|
52
|
+
lock_app = typer.Typer(help="Manage source-lock.json.", no_args_is_help=True)
|
|
53
|
+
app.add_typer(lock_app, name="lock")
|
|
54
|
+
|
|
55
|
+
# Additive registration: the manifest commands live in cli_manifest.py.
|
|
56
|
+
from .cli_manifest import manifest_app # noqa: E402
|
|
57
|
+
|
|
58
|
+
app.add_typer(manifest_app, name="manifest")
|
|
59
|
+
|
|
60
|
+
console = Console(stderr=False)
|
|
61
|
+
err = Console(stderr=True)
|
|
62
|
+
|
|
63
|
+
_STATUS_STYLE = {
|
|
64
|
+
CanaryStatus.OK: "green",
|
|
65
|
+
CanaryStatus.DRIFT: "yellow",
|
|
66
|
+
CanaryStatus.SCHEMA_CHANGED: "yellow",
|
|
67
|
+
CanaryStatus.UNREACHABLE: "red",
|
|
68
|
+
CanaryStatus.UNPINNED: "yellow",
|
|
69
|
+
CanaryStatus.STALE: "yellow",
|
|
70
|
+
CanaryStatus.ERROR: "red",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _version_callback(value: bool) -> None:
|
|
75
|
+
if value:
|
|
76
|
+
typer.echo(f"hc-source {__version__}")
|
|
77
|
+
raise typer.Exit(EXIT_OK)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.callback()
|
|
81
|
+
def main_callback(
|
|
82
|
+
version: bool = typer.Option(
|
|
83
|
+
False, "--version", callback=_version_callback, is_eager=True, help="Show version and exit."
|
|
84
|
+
),
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Source assurance for public healthcare data."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _fail(message: str, code: int = 1) -> None:
|
|
90
|
+
typer.echo(message)
|
|
91
|
+
raise typer.Exit(code)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _os_error_reason(exc: OSError) -> str:
|
|
95
|
+
"""A short human reason for an OSError, with no traceback and no payload."""
|
|
96
|
+
return (exc.strerror or type(exc).__name__).lower()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _load_lock_or_none(path: Path) -> tuple[Any, str | None]:
|
|
100
|
+
try:
|
|
101
|
+
return load_lockfile(path), None
|
|
102
|
+
except FileNotFoundError as exc:
|
|
103
|
+
return None, str(exc)
|
|
104
|
+
except OSError as exc:
|
|
105
|
+
# Unreadable (permissions, a directory, a broken symlink). Not "no
|
|
106
|
+
# lockfile": pretending nothing is pinned would report a green run that
|
|
107
|
+
# verified nothing, the same failure `_scoped_discovery` refuses.
|
|
108
|
+
_fail(f"cannot read {path}: {_os_error_reason(exc)}")
|
|
109
|
+
raise AssertionError # pragma: no cover - _fail always exits
|
|
110
|
+
except ValidationError as exc:
|
|
111
|
+
_fail(f"source-lock.json at {path} is not valid: {exc.error_count()} problem(s). "
|
|
112
|
+
"Regenerate it with `hc-source lock init`.")
|
|
113
|
+
raise AssertionError # pragma: no cover - _fail always exits
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _scoped_discovery(source: list[str]):
|
|
117
|
+
"""Discover adapters, optionally scoped to specific source ids.
|
|
118
|
+
|
|
119
|
+
Returns ``(adapters, load_errors)``. An unknown ``--source`` is a usage
|
|
120
|
+
error: silently checking nothing would report a green run that verified
|
|
121
|
+
nothing. Load errors are matched to a scope by module basename, since a
|
|
122
|
+
module that failed to load has no source_id to ask.
|
|
123
|
+
"""
|
|
124
|
+
found = discover()
|
|
125
|
+
adapters, load_errors = found.adapters, found.errors
|
|
126
|
+
if not source:
|
|
127
|
+
return adapters, load_errors
|
|
128
|
+
|
|
129
|
+
wanted = set(source)
|
|
130
|
+
known = {a.source_id for a in adapters} | {
|
|
131
|
+
e.module.rsplit(".", 1)[-1] for e in load_errors
|
|
132
|
+
}
|
|
133
|
+
unknown = sorted(wanted - known)
|
|
134
|
+
if unknown:
|
|
135
|
+
available = ", ".join(sorted(a.source_id for a in adapters)) or "none"
|
|
136
|
+
_fail(
|
|
137
|
+
f"unknown source(s): {', '.join(unknown)}. Discovered sources: {available}."
|
|
138
|
+
)
|
|
139
|
+
return (
|
|
140
|
+
[a for a in adapters if a.source_id in wanted],
|
|
141
|
+
[e for e in load_errors if e.module.rsplit(".", 1)[-1] in wanted],
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
# doctor
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@app.command()
|
|
151
|
+
def doctor(
|
|
152
|
+
json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
|
|
153
|
+
lock: Optional[Path] = typer.Option(
|
|
154
|
+
None, "--lock", help="Path to source-lock.json (default: ./source-lock.json)."
|
|
155
|
+
),
|
|
156
|
+
source: list[str] = typer.Option(
|
|
157
|
+
[],
|
|
158
|
+
"--source",
|
|
159
|
+
"-s",
|
|
160
|
+
help="Check only these source ids (repeatable). Default: every discovered source.",
|
|
161
|
+
),
|
|
162
|
+
) -> None:
|
|
163
|
+
"""Run every adapter's canaries and compare them to the lockfile."""
|
|
164
|
+
lock_path = lock or default_lock_path()
|
|
165
|
+
lockfile, missing = _load_lock_or_none(lock_path)
|
|
166
|
+
adapters, load_errors = _scoped_discovery(source)
|
|
167
|
+
report = run_doctor(lockfile, adapters, load_errors=load_errors)
|
|
168
|
+
|
|
169
|
+
if json_output:
|
|
170
|
+
payload = report.to_dict()
|
|
171
|
+
payload["lock_path"] = str(lock_path)
|
|
172
|
+
if missing:
|
|
173
|
+
payload["lockfile_error"] = missing
|
|
174
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
175
|
+
raise typer.Exit(report.exit_code)
|
|
176
|
+
|
|
177
|
+
if missing:
|
|
178
|
+
typer.echo(f"! {missing}")
|
|
179
|
+
|
|
180
|
+
for error in report.load_errors:
|
|
181
|
+
typer.echo(f"! adapter failed to load -- {error}")
|
|
182
|
+
|
|
183
|
+
# canary and status must always be readable; observed/expected absorb the
|
|
184
|
+
# truncation when the terminal is narrow (all-no_wrap columns make rich
|
|
185
|
+
# collapse the narrowest column to zero width instead). Full values are
|
|
186
|
+
# always available via --json.
|
|
187
|
+
table = Table(title=f"hc-source doctor ({lock_path.name})", title_justify="left")
|
|
188
|
+
table.add_column("canary", no_wrap=True)
|
|
189
|
+
table.add_column("status", no_wrap=True)
|
|
190
|
+
# Which source actually answered. An `ok` from a mirror while the authority
|
|
191
|
+
# is dark is a different claim from an `ok` from the authority (finding H2).
|
|
192
|
+
table.add_column("via", overflow="ellipsis", max_width=22)
|
|
193
|
+
table.add_column("observed", overflow="ellipsis", max_width=38)
|
|
194
|
+
table.add_column("expected", overflow="ellipsis", max_width=38)
|
|
195
|
+
|
|
196
|
+
for result in report.results:
|
|
197
|
+
advisory = result.severity is CanarySeverity.ADVISORY
|
|
198
|
+
# An advisory finding is still a finding: it keeps its status word and
|
|
199
|
+
# is marked as not counting, rather than being quietly recoloured green.
|
|
200
|
+
status_text = result.status.value + (" (advisory)" if advisory else "")
|
|
201
|
+
style = "dim" if advisory and result.status is not CanaryStatus.OK else (
|
|
202
|
+
_STATUS_STYLE[result.status]
|
|
203
|
+
)
|
|
204
|
+
table.add_row(
|
|
205
|
+
result.canary_id,
|
|
206
|
+
f"[{style}]{status_text}[/]",
|
|
207
|
+
f"[yellow]{result.fallback_name or 'mirror'}[/]" if result.fallback_used
|
|
208
|
+
else "authority",
|
|
209
|
+
result.observed or "-",
|
|
210
|
+
result.expected or "-",
|
|
211
|
+
)
|
|
212
|
+
console.print(table)
|
|
213
|
+
|
|
214
|
+
summary = report.summary
|
|
215
|
+
extras = []
|
|
216
|
+
if summary["fallback"]:
|
|
217
|
+
extras.append(f"{summary['fallback']} answered by a mirror")
|
|
218
|
+
if summary["advisory"]:
|
|
219
|
+
extras.append(f"{summary['advisory']} advisory, not counted")
|
|
220
|
+
if summary["retried"]:
|
|
221
|
+
extras.append(f"{summary['retried']} needed a retry")
|
|
222
|
+
typer.echo(
|
|
223
|
+
", ".join(f"{summary[s.value]} {s.value}" for s in CanaryStatus)
|
|
224
|
+
+ (f" ({'; '.join(extras)})" if extras else "")
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# Printed unwrapped: remediation text is meant to be copied and run.
|
|
228
|
+
for result in report.results:
|
|
229
|
+
if result.status is not CanaryStatus.OK:
|
|
230
|
+
mark = (
|
|
231
|
+
" advisory -- reported, not counted toward the exit code"
|
|
232
|
+
if result.severity is CanarySeverity.ADVISORY
|
|
233
|
+
else ""
|
|
234
|
+
)
|
|
235
|
+
typer.echo(
|
|
236
|
+
f"\n{result.canary_id} [{result.status.value}{mark}]: {result.remediation}"
|
|
237
|
+
)
|
|
238
|
+
for warning in result.warnings:
|
|
239
|
+
typer.echo(f" note {result.canary_id}: {warning}")
|
|
240
|
+
|
|
241
|
+
raise typer.Exit(report.exit_code)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
# lock
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@lock_app.command("init")
|
|
250
|
+
def lock_init(
|
|
251
|
+
lock: Optional[Path] = typer.Option(
|
|
252
|
+
None, "--lock", help="Where to write the lockfile (default: ./source-lock.json)."
|
|
253
|
+
),
|
|
254
|
+
source: list[str] = typer.Option(
|
|
255
|
+
[],
|
|
256
|
+
"--source",
|
|
257
|
+
"-s",
|
|
258
|
+
help=(
|
|
259
|
+
"Pin only these source ids (repeatable); other sources' existing pins are "
|
|
260
|
+
"kept untouched. Default: every discovered source."
|
|
261
|
+
),
|
|
262
|
+
),
|
|
263
|
+
allow_partial: bool = typer.Option(
|
|
264
|
+
False,
|
|
265
|
+
"--allow-partial",
|
|
266
|
+
help=(
|
|
267
|
+
"Write the lockfile even though some canaries could not be observed. "
|
|
268
|
+
"Previous pins for those canaries are kept; canaries that have never "
|
|
269
|
+
"been pinned stay unpinned, and doctor will report them as such."
|
|
270
|
+
),
|
|
271
|
+
),
|
|
272
|
+
) -> None:
|
|
273
|
+
"""Observe every source now and pin what it says to source-lock.json.
|
|
274
|
+
|
|
275
|
+
Refuses to write unless every selected canary was observed. A `lock init`
|
|
276
|
+
that runs offline used to pin what it could reach and drop the rest, which
|
|
277
|
+
quietly deleted exactly the pins that detect drift. Pass `--allow-partial`
|
|
278
|
+
to accept a partial run; previous pins are preserved either way.
|
|
279
|
+
"""
|
|
280
|
+
lock_path = lock or default_lock_path()
|
|
281
|
+
adapters, load_errors = _scoped_discovery(source)
|
|
282
|
+
|
|
283
|
+
for error in load_errors:
|
|
284
|
+
typer.echo(f"! adapter failed to load -- {error}")
|
|
285
|
+
|
|
286
|
+
existing, _ = _load_lock_or_none(lock_path)
|
|
287
|
+
build = build_lockfile(adapters, previous=existing)
|
|
288
|
+
lockfile = build.lockfile
|
|
289
|
+
|
|
290
|
+
if existing is not None:
|
|
291
|
+
# Never drop a source this run did not actually re-observe. A source
|
|
292
|
+
# whose adapter module failed to LOAD has no canaries to run, so it is
|
|
293
|
+
# absent from `build.lockfile` -- and dropping it here is how a scoped
|
|
294
|
+
# `lock init --source leie` with a broken leie.py wiped leie's pins.
|
|
295
|
+
rebuilt = set(lockfile.sources)
|
|
296
|
+
merged = {
|
|
297
|
+
sid: entry for sid, entry in existing.sources.items() if sid not in rebuilt
|
|
298
|
+
}
|
|
299
|
+
merged.update(lockfile.sources)
|
|
300
|
+
merged = dict(sorted(merged.items()))
|
|
301
|
+
generated_at = (
|
|
302
|
+
existing.generated_at if merged == existing.sources else lockfile.generated_at
|
|
303
|
+
)
|
|
304
|
+
lockfile = Lockfile(generated_at=generated_at, sources=merged)
|
|
305
|
+
|
|
306
|
+
blocked = bool(build.failures or load_errors) and not allow_partial
|
|
307
|
+
if blocked:
|
|
308
|
+
for failure in build.failures:
|
|
309
|
+
typer.echo(f"! {failure}")
|
|
310
|
+
typer.echo(
|
|
311
|
+
f"refusing to write {lock_path}: "
|
|
312
|
+
+ _incomplete_summary(build.failures, load_errors)
|
|
313
|
+
+ ". Nothing was changed -- the existing pins are intact. Fix the source (or "
|
|
314
|
+
"the adapter) and run again, or accept a partial pin with "
|
|
315
|
+
"`hc-source lock init --allow-partial`."
|
|
316
|
+
)
|
|
317
|
+
raise typer.Exit(EXIT_UNREACHABLE)
|
|
318
|
+
|
|
319
|
+
try:
|
|
320
|
+
save_lockfile(lockfile, lock_path)
|
|
321
|
+
except OSError as exc:
|
|
322
|
+
# Read-only checkout, a directory in the way, a full disk. All of these
|
|
323
|
+
# used to surface as a raw PermissionError traceback.
|
|
324
|
+
_fail(f"cannot write {lock_path}: {_os_error_reason(exc)}")
|
|
325
|
+
raise AssertionError # pragma: no cover
|
|
326
|
+
|
|
327
|
+
pinned = sum(len(e.expected_canaries) for e in lockfile.sources.values())
|
|
328
|
+
typer.echo(
|
|
329
|
+
f"wrote {lock_path} -- {len(lockfile.sources)} source(s), {pinned} canary expectation(s)"
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
if build.failures or load_errors:
|
|
333
|
+
typer.echo(
|
|
334
|
+
f"! PARTIAL PIN: {_incomplete_summary(build.failures, load_errors)}. "
|
|
335
|
+
"This lockfile is not a complete observation of upstream."
|
|
336
|
+
)
|
|
337
|
+
for failure in build.failures:
|
|
338
|
+
typer.echo(f"! {failure}")
|
|
339
|
+
if build.preserved:
|
|
340
|
+
typer.echo(
|
|
341
|
+
f"! {len(build.preserved)} previous pin(s) were kept unchanged and "
|
|
342
|
+
"describe an EARLIER observation, not this run."
|
|
343
|
+
)
|
|
344
|
+
if build.unpinned:
|
|
345
|
+
typer.echo(
|
|
346
|
+
f"! {len(build.unpinned)} canary/canaries remain unpinned; "
|
|
347
|
+
"`hc-source doctor` will report them as unpinned (exit 3)."
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
raise typer.Exit(
|
|
351
|
+
EXIT_UNREACHABLE if (build.failures or load_errors) else EXIT_OK
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _incomplete_summary(failures, load_errors) -> str:
|
|
356
|
+
parts = []
|
|
357
|
+
if failures:
|
|
358
|
+
parts.append(f"{len(failures)} canary/canaries could not be observed")
|
|
359
|
+
if load_errors:
|
|
360
|
+
parts.append(f"{len(load_errors)} adapter(s) failed to load")
|
|
361
|
+
return " and ".join(parts)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# ---------------------------------------------------------------------------
|
|
365
|
+
# tools
|
|
366
|
+
# ---------------------------------------------------------------------------
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@app.command()
|
|
370
|
+
def tools(
|
|
371
|
+
json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
|
|
372
|
+
) -> None:
|
|
373
|
+
"""List every tool from every discovered adapter.
|
|
374
|
+
|
|
375
|
+
Exits 2 when any adapter module failed to load. An adapter that is broken
|
|
376
|
+
looks exactly like an adapter that is absent from a list of what works, and
|
|
377
|
+
a source someone is paying for must not disappear quietly.
|
|
378
|
+
"""
|
|
379
|
+
found = discover()
|
|
380
|
+
specs = iter_tools(found.adapters)
|
|
381
|
+
load_errors = found.errors
|
|
382
|
+
|
|
383
|
+
if json_output:
|
|
384
|
+
typer.echo(
|
|
385
|
+
json.dumps(
|
|
386
|
+
{
|
|
387
|
+
"load_errors": [e.as_dict() for e in load_errors],
|
|
388
|
+
# Where each source came from: built in, an installed
|
|
389
|
+
# distribution, or a local file. A proprietary adapter is
|
|
390
|
+
# not a second-class citizen, but it is a DIFFERENT claim,
|
|
391
|
+
# and the person reading a receipt gets to see which.
|
|
392
|
+
"adapters": [
|
|
393
|
+
{"source_id": a.source_id, "origin": found.origin(a.source_id)}
|
|
394
|
+
for a in found.adapters
|
|
395
|
+
],
|
|
396
|
+
"tools": [
|
|
397
|
+
{
|
|
398
|
+
"name": s.name,
|
|
399
|
+
"source_id": s.source_id,
|
|
400
|
+
"description": s.description,
|
|
401
|
+
"tags": list(s.tags),
|
|
402
|
+
"params_schema": s.params_schema(),
|
|
403
|
+
}
|
|
404
|
+
for s in specs
|
|
405
|
+
]
|
|
406
|
+
},
|
|
407
|
+
indent=2,
|
|
408
|
+
)
|
|
409
|
+
)
|
|
410
|
+
raise typer.Exit(EXIT_UNREACHABLE if load_errors else EXIT_OK)
|
|
411
|
+
|
|
412
|
+
# tool names stay whole; parameters wrap; description gets the room
|
|
413
|
+
# (a no_wrap parameters column can starve description to nothing).
|
|
414
|
+
table = Table(title="hc-source tools", title_justify="left")
|
|
415
|
+
table.add_column("tool", no_wrap=True)
|
|
416
|
+
table.add_column("parameters", max_width=24)
|
|
417
|
+
table.add_column("description", ratio=1)
|
|
418
|
+
for spec in specs:
|
|
419
|
+
params = ", ".join(spec.params_model.model_fields) or "-"
|
|
420
|
+
table.add_row(spec.name, params, spec.description)
|
|
421
|
+
console.print(table)
|
|
422
|
+
|
|
423
|
+
third_party = found.third_party()
|
|
424
|
+
if third_party:
|
|
425
|
+
typer.echo(
|
|
426
|
+
f"{len(third_party)} source(s) came from outside this package: "
|
|
427
|
+
+ ", ".join(f"{a.source_id} ({found.origin(a.source_id)})" for a in third_party)
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
for error in load_errors:
|
|
431
|
+
typer.echo(f"! adapter failed to load -- {error}")
|
|
432
|
+
if load_errors:
|
|
433
|
+
typer.echo(
|
|
434
|
+
f"! {len(load_errors)} adapter(s) are missing from this list because they "
|
|
435
|
+
"failed to import, not because they do not exist. Every route they provide "
|
|
436
|
+
"is unavailable."
|
|
437
|
+
)
|
|
438
|
+
raise typer.Exit(EXIT_UNREACHABLE)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# ---------------------------------------------------------------------------
|
|
442
|
+
# call
|
|
443
|
+
# ---------------------------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _parse_params(pairs: list[str], tool: str) -> dict[str, str]:
|
|
447
|
+
"""Turn ``k=v`` strings into a mapping, guarding the RAW text first.
|
|
448
|
+
|
|
449
|
+
Three round-1 holes lived in the four lines this replaced:
|
|
450
|
+
|
|
451
|
+
* a syntax error printed the offending pair verbatim, so ``--param
|
|
452
|
+
123-45-6789`` was refused by echoing it;
|
|
453
|
+
* ``params[key] = value`` was last-wins, so ``-p code=<something dirty>
|
|
454
|
+
-p code=E11.9`` dropped the first value BEFORE the guard ever saw it --
|
|
455
|
+
a clean second value hid a dirty first;
|
|
456
|
+
* the key itself was never scanned as text.
|
|
457
|
+
|
|
458
|
+
So the raw pairs are scanned before anything is parsed, duplicates are a
|
|
459
|
+
refusal rather than an overwrite, and nothing caller-supplied is echoed
|
|
460
|
+
unless it is identifier-shaped.
|
|
461
|
+
"""
|
|
462
|
+
# Scan the raw text of every occurrence. The keys here are ours, so the
|
|
463
|
+
# guard's own paths stay readable; the values are the whole `k=v` strings.
|
|
464
|
+
try:
|
|
465
|
+
assert_public_params(
|
|
466
|
+
{f"param_{i + 1}": pair for i, pair in enumerate(pairs)}, tool=tool
|
|
467
|
+
)
|
|
468
|
+
except PHIRejected as exc:
|
|
469
|
+
# Carries detector codes and positions only, never the text.
|
|
470
|
+
_fail(str(exc))
|
|
471
|
+
raise AssertionError # pragma: no cover
|
|
472
|
+
|
|
473
|
+
params: dict[str, str] = {}
|
|
474
|
+
for i, pair in enumerate(pairs, start=1):
|
|
475
|
+
key, sep, value = pair.partition("=")
|
|
476
|
+
if not sep or not key:
|
|
477
|
+
_fail(
|
|
478
|
+
f"bad --param #{i}: expected k=v. The pair is not repeated here on "
|
|
479
|
+
"purpose -- SourceLock does not echo parameter text back at you."
|
|
480
|
+
)
|
|
481
|
+
if key in params:
|
|
482
|
+
_fail(
|
|
483
|
+
f"--param {safe_label(key)} was given more than once. SourceLock refuses "
|
|
484
|
+
"rather than taking the last one: a repeated key means one of the values "
|
|
485
|
+
"was going to be discarded, and discarding a value before it is inspected "
|
|
486
|
+
"is how a clean second value hides a dirty first."
|
|
487
|
+
)
|
|
488
|
+
params[key] = value
|
|
489
|
+
return params
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
@app.command()
|
|
493
|
+
def call(
|
|
494
|
+
tool: str = typer.Argument(..., help="Tool name, e.g. demo.lookup_code."),
|
|
495
|
+
param: list[str] = typer.Option(
|
|
496
|
+
[], "--param", "-p", help="Typed public parameter as k=v. Repeatable."
|
|
497
|
+
),
|
|
498
|
+
json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
|
|
499
|
+
) -> None:
|
|
500
|
+
"""Call one tool and print its result with the evidence receipt."""
|
|
501
|
+
# Resolved before the parameters are parsed so that a misspelled tool name
|
|
502
|
+
# is reported as a misspelled tool name, not as a complaint about the flags
|
|
503
|
+
# that were going to be passed to it.
|
|
504
|
+
spec = _resolve_tool(tool)
|
|
505
|
+
_run_tool(tool, _parse_params(param, tool), json_output, spec=spec)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _resolve_tool(tool: str) -> Any:
|
|
509
|
+
"""Find one tool, or exit with the right code and a fix.
|
|
510
|
+
|
|
511
|
+
``call`` had this and the shortcuts did not, so a broken adapter answered
|
|
512
|
+
``valid-on``, ``lookup-npi``, ``check-npi`` and ``hcc score`` with a Python
|
|
513
|
+
traceback and exit 1. A traceback is not remediation, and exit 1 says "your
|
|
514
|
+
command was wrong" about something that was not.
|
|
515
|
+
"""
|
|
516
|
+
try:
|
|
517
|
+
return find_tool(tool)
|
|
518
|
+
except AdapterError as exc:
|
|
519
|
+
# The route exists and is broken. Exit 2, not 1: nothing about the
|
|
520
|
+
# caller's command was wrong.
|
|
521
|
+
_fail(str(exc), EXIT_UNREACHABLE)
|
|
522
|
+
raise AssertionError # pragma: no cover
|
|
523
|
+
except KeyError:
|
|
524
|
+
# safe_tool_name, not `tool!r`: the name is caller-supplied text, and
|
|
525
|
+
# "no tool named X" reflecting X back is a free write into anyone's log.
|
|
526
|
+
_fail(
|
|
527
|
+
f"no tool named {safe_tool_name(tool)}. Run `hc-source tools` to see what "
|
|
528
|
+
"is available."
|
|
529
|
+
)
|
|
530
|
+
raise AssertionError # pragma: no cover
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _run_tool(
|
|
534
|
+
tool: str, params: dict[str, Any], json_output: bool, *, spec: Any = None
|
|
535
|
+
) -> None:
|
|
536
|
+
"""Resolve, invoke and print one tool call. The single error-mapping path.
|
|
537
|
+
|
|
538
|
+
Every command that reaches an adapter goes through here -- ``call`` and all
|
|
539
|
+
four shortcuts -- so the exit codes, the remediation text and the
|
|
540
|
+
no-echo rules are the same wherever you arrive from. Two copies of this
|
|
541
|
+
drifted once already: the shortcuts' copy was missing the adapter-load
|
|
542
|
+
cases entirely.
|
|
543
|
+
"""
|
|
544
|
+
spec = spec if spec is not None else _resolve_tool(tool)
|
|
545
|
+
try:
|
|
546
|
+
result = spec.invoke(params)
|
|
547
|
+
except (PHIRejected, ParamsRejected) as exc:
|
|
548
|
+
# Both carry field names and rule text only, never a parameter value.
|
|
549
|
+
_fail(str(exc))
|
|
550
|
+
raise AssertionError # pragma: no cover
|
|
551
|
+
except ValidationError as exc:
|
|
552
|
+
# Defence in depth: invoke() converts these, so reaching here means an
|
|
553
|
+
# adapter validated something itself. Never print the raw exception.
|
|
554
|
+
_fail(f"{tool}: invalid parameters -- {safe_validation_message(exc)}")
|
|
555
|
+
raise AssertionError # pragma: no cover
|
|
556
|
+
except SourceUnreachable as exc:
|
|
557
|
+
# exc carries a URL and a transport reason, never a response body and
|
|
558
|
+
# never a parameter value. Exit 2, not 1: this is not the caller's fault.
|
|
559
|
+
_fail(
|
|
560
|
+
f"{tool}: source unreachable -- {exc.url} ({exc.reason}"
|
|
561
|
+
+ (f", HTTP {exc.status}" if exc.status else "")
|
|
562
|
+
+ ").\n"
|
|
563
|
+
"The answer would have had no evidence behind it, so no answer was given. "
|
|
564
|
+
"Check connectivity and any proxy settings, then run `hc-source doctor "
|
|
565
|
+
f"--source {tool.split('.', 1)[0]}` to see whether the source is reachable "
|
|
566
|
+
"at all.",
|
|
567
|
+
EXIT_UNREACHABLE,
|
|
568
|
+
)
|
|
569
|
+
raise AssertionError # pragma: no cover
|
|
570
|
+
except AdapterError as exc:
|
|
571
|
+
# An adapter that returned something invalid -- a receipt naming another
|
|
572
|
+
# source, a handler returning the wrong type. Our bug, not the caller's.
|
|
573
|
+
_fail(str(exc), EXIT_UNREACHABLE)
|
|
574
|
+
raise AssertionError # pragma: no cover
|
|
575
|
+
|
|
576
|
+
if json_output:
|
|
577
|
+
typer.echo(
|
|
578
|
+
json.dumps(
|
|
579
|
+
{"data": result.data, "receipt": result.receipt.model_dump(mode="json")},
|
|
580
|
+
indent=2,
|
|
581
|
+
)
|
|
582
|
+
)
|
|
583
|
+
raise typer.Exit(EXIT_OK)
|
|
584
|
+
|
|
585
|
+
typer.echo(json.dumps(result.data, indent=2))
|
|
586
|
+
_print_receipt(result.receipt)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _print_receipt(receipt: Receipt) -> None:
|
|
590
|
+
# The field names must stay whole and the values must stay VISIBLE. When
|
|
591
|
+
# every column is no_wrap, rich collapses the narrowest one to zero width
|
|
592
|
+
# instead of eliding it -- which rendered every receipt value, including the
|
|
593
|
+
# fallback flag, as an empty box at narrow widths (finding H5; the same bug
|
|
594
|
+
# commit be56f3d fixed for the doctor table). Giving the field column a
|
|
595
|
+
# ceiling and letting the value column fold keeps the evidence on screen.
|
|
596
|
+
table = Table(title="receipt", title_justify="left", show_header=False)
|
|
597
|
+
table.add_column("field", overflow="ellipsis", max_width=17)
|
|
598
|
+
table.add_column("value", overflow="fold", ratio=1)
|
|
599
|
+
table.add_row("source", receipt.source_id)
|
|
600
|
+
table.add_row("route", receipt.route)
|
|
601
|
+
table.add_row("source_version", receipt.source_version)
|
|
602
|
+
table.add_row("effective", f"{receipt.effective_from or '-'} .. {receipt.effective_to or 'open'}")
|
|
603
|
+
table.add_row("retrieved_at", receipt.retrieved_at.isoformat())
|
|
604
|
+
table.add_row("upstream_status", str(receipt.upstream_status))
|
|
605
|
+
table.add_row("raw_sha256", receipt.raw_sha256)
|
|
606
|
+
table.add_row("transform_version", receipt.transform_version)
|
|
607
|
+
table.add_row(
|
|
608
|
+
"fallback", receipt.fallback_name if receipt.fallback_used else "no (authority used)"
|
|
609
|
+
)
|
|
610
|
+
console.print(table)
|
|
611
|
+
|
|
612
|
+
for warning in receipt.warnings:
|
|
613
|
+
typer.echo(f"warning: {warning}")
|
|
614
|
+
typer.echo("does not prove:")
|
|
615
|
+
for claim in receipt.non_claims:
|
|
616
|
+
typer.echo(f" - {claim}")
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
# ---------------------------------------------------------------------------
|
|
620
|
+
# shortcuts
|
|
621
|
+
# ---------------------------------------------------------------------------
|
|
622
|
+
#
|
|
623
|
+
# `call` is the honest general form: any tool, any parameter, one syntax that
|
|
624
|
+
# does not change when an adapter does. It is also the reason a newcomer's first
|
|
625
|
+
# command is
|
|
626
|
+
#
|
|
627
|
+
# hc-source call codes.valid_on --param code=E11.9 --param date=2026-07-15
|
|
628
|
+
#
|
|
629
|
+
# which asks them to know a route name and a parameter grammar before they have
|
|
630
|
+
# seen the product do anything. These four wrap the questions people actually
|
|
631
|
+
# arrive with. They are thin on purpose: same tool, same guard, same receipt,
|
|
632
|
+
# same exit codes -- `call` remains the power-user path and the only one that
|
|
633
|
+
# reaches a third-party adapter's routes.
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _shortcut(tool: str, params: dict[str, Any], json_output: bool) -> None:
|
|
637
|
+
"""Run a tool exactly as `call` would, and print it exactly as `call` does.
|
|
638
|
+
|
|
639
|
+
Literally the same function -- "exactly as `call` would" used to be a
|
|
640
|
+
promise kept by a second copy of the error handling, and the copy was
|
|
641
|
+
missing the two adapter-load cases.
|
|
642
|
+
"""
|
|
643
|
+
_run_tool(tool, params, json_output)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
@app.command("valid-on")
|
|
647
|
+
def valid_on(
|
|
648
|
+
code: str = typer.Argument(..., help="ICD-10-CM code, e.g. E11.9."),
|
|
649
|
+
date: str = typer.Argument(..., help="Date of service, YYYY-MM-DD."),
|
|
650
|
+
json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
|
|
651
|
+
) -> None:
|
|
652
|
+
"""Was this ICD-10-CM code valid on this date of service? (offline)
|
|
653
|
+
|
|
654
|
+
Answered from the vendored release tables, so it works with no network and
|
|
655
|
+
no credentials. Shorthand for `call codes.valid_on`.
|
|
656
|
+
"""
|
|
657
|
+
_shortcut("codes.valid_on", {"code": code, "date": date}, json_output)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
@app.command("lookup-npi")
|
|
661
|
+
def lookup_npi(
|
|
662
|
+
npi: str = typer.Argument(..., help="10-digit NPI."),
|
|
663
|
+
json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
|
|
664
|
+
) -> None:
|
|
665
|
+
"""Look one NPI up in the NPPES registry. Shorthand for `call provider.lookup_npi`."""
|
|
666
|
+
_shortcut("provider.lookup_npi", {"npi": npi}, json_output)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
@app.command("check-npi")
|
|
670
|
+
def check_npi(
|
|
671
|
+
npi: str = typer.Argument(..., help="10-digit NPI."),
|
|
672
|
+
json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
|
|
673
|
+
) -> None:
|
|
674
|
+
"""Screen one NPI against the OIG LEIE exclusion list.
|
|
675
|
+
|
|
676
|
+
A miss never clears a provider -- about 89.5% of LEIE records carry no NPI.
|
|
677
|
+
The receipt says so. Shorthand for `call leie.check_npi`.
|
|
678
|
+
"""
|
|
679
|
+
_shortcut("leie.check_npi", {"npi": npi}, json_output)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
hcc_app = typer.Typer(help="CMS-HCC risk model shortcuts.", no_args_is_help=True)
|
|
683
|
+
app.add_typer(hcc_app, name="hcc")
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
@hcc_app.command("score")
|
|
687
|
+
def hcc_score(
|
|
688
|
+
dx: str = typer.Option(..., "--dx", help="Comma-separated ICD-10-CM codes."),
|
|
689
|
+
model: str = typer.Option(..., "--model", help="v24 or v28."),
|
|
690
|
+
year: int = typer.Option(..., "--year", help="Payment year, e.g. 2026."),
|
|
691
|
+
age: Optional[int] = typer.Option(None, "--age", help="Beneficiary age."),
|
|
692
|
+
sex: Optional[str] = typer.Option(None, "--sex", help="F or M."),
|
|
693
|
+
dual: str = typer.Option("non", "--dual", help="non, full, or partial."),
|
|
694
|
+
orig_disabled: bool = typer.Option(
|
|
695
|
+
False, "--orig-disabled", help="Originally disabled entitlement."
|
|
696
|
+
),
|
|
697
|
+
json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
|
|
698
|
+
) -> None:
|
|
699
|
+
"""Community continuing-enrollee RAF score. Shorthand for `call hcc.score`."""
|
|
700
|
+
params: dict[str, Any] = {
|
|
701
|
+
"diagnoses": dx,
|
|
702
|
+
"model": model,
|
|
703
|
+
"payment_year": year,
|
|
704
|
+
"dual": dual,
|
|
705
|
+
"orig_disabled": orig_disabled,
|
|
706
|
+
}
|
|
707
|
+
if age is not None:
|
|
708
|
+
params["age"] = age
|
|
709
|
+
if sex is not None:
|
|
710
|
+
params["sex"] = sex
|
|
711
|
+
_shortcut("hcc.score", params, json_output)
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
# ---------------------------------------------------------------------------
|
|
715
|
+
# init
|
|
716
|
+
# ---------------------------------------------------------------------------
|
|
717
|
+
|
|
718
|
+
#: The `actions/checkout` pin the scaffold writes, and the tag it names.
|
|
719
|
+
#:
|
|
720
|
+
#: A mutable `@v4` follows wherever that tag is republished, including to a
|
|
721
|
+
#: compromised republish -- which is the hole this repository's own workflows
|
|
722
|
+
#: closed, and which the scaffold went on writing into other people's
|
|
723
|
+
#: repositories afterwards. A product about pinning the things your build
|
|
724
|
+
#: depends on does not hand its users an unpinned dependency on the way in.
|
|
725
|
+
#:
|
|
726
|
+
#: `.github/` is not in the wheel, so this cannot be read off our workflows at
|
|
727
|
+
#: runtime; `tests/test_release_workflow.py` asserts the two agree, so the pair
|
|
728
|
+
#: moves together or the suite goes red.
|
|
729
|
+
CHECKOUT_ACTION_PIN = "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683"
|
|
730
|
+
CHECKOUT_ACTION_VERSION = "v4.2.2"
|
|
731
|
+
|
|
732
|
+
_CI_WORKFLOW = """\
|
|
733
|
+
# Written by `hc-source init --ci`.
|
|
734
|
+
#
|
|
735
|
+
# What this does: pins the public data sources your build depends on, and fails
|
|
736
|
+
# the build when one of them moves underneath you. `source-lock.json` is the pin;
|
|
737
|
+
# commit it and review its diffs like any other lockfile.
|
|
738
|
+
name: source-doctor
|
|
739
|
+
|
|
740
|
+
on:
|
|
741
|
+
pull_request:
|
|
742
|
+
push:
|
|
743
|
+
branches: [{branch}]
|
|
744
|
+
schedule:
|
|
745
|
+
# Drift happens on CMS's clock, not yours.
|
|
746
|
+
- cron: "17 6 * * *"
|
|
747
|
+
|
|
748
|
+
# This job reads public data sources and reports. It writes nothing, so it is
|
|
749
|
+
# given nothing to write with. GitHub's default token is read-write on most
|
|
750
|
+
# repositories; a workflow that runs on `pull_request` and does not narrow it
|
|
751
|
+
# is handing that token to whatever the pull request contains.
|
|
752
|
+
permissions:
|
|
753
|
+
contents: read
|
|
754
|
+
|
|
755
|
+
jobs:
|
|
756
|
+
doctor:
|
|
757
|
+
runs-on: ubuntu-latest
|
|
758
|
+
steps:
|
|
759
|
+
# Pinned to a commit, with the tag in the comment. A `@v4` follows
|
|
760
|
+
# wherever that tag points next -- including a compromised republish of
|
|
761
|
+
# it -- and this job runs before anything else in the workflow. Update
|
|
762
|
+
# the SHA and the comment together.
|
|
763
|
+
- uses: {checkout} # {checkout_version}
|
|
764
|
+
- uses: {action}
|
|
765
|
+
with:
|
|
766
|
+
lock-path: {lock_path}
|
|
767
|
+
# Warn rather than fail when a source is unreachable or a snapshot is
|
|
768
|
+
# stale on a scheduled run, and fail on a pull request. A nightly job
|
|
769
|
+
# exists to tell you CMS was down; a PR that could not read its inputs
|
|
770
|
+
# has verified nothing. "auto" is that rule.
|
|
771
|
+
on-unreachable: auto
|
|
772
|
+
on-stale: auto
|
|
773
|
+
"""
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
@app.command()
|
|
777
|
+
def init(
|
|
778
|
+
ci: bool = typer.Option(False, "--ci", help="Write a GitHub Actions workflow."),
|
|
779
|
+
path: Optional[Path] = typer.Option(
|
|
780
|
+
None, "--path", help="Where to write it (default: .github/workflows/source-doctor.yml)."
|
|
781
|
+
),
|
|
782
|
+
action: str = typer.Option(
|
|
783
|
+
"OWNER/sourcelock/action@REF",
|
|
784
|
+
"--action",
|
|
785
|
+
help="The action reference to use. Pin REF to a commit SHA: a tag can be moved after you read it.",
|
|
786
|
+
),
|
|
787
|
+
branch: str = typer.Option("main", "--branch", help="Default branch to run on push."),
|
|
788
|
+
lock: Optional[Path] = typer.Option(
|
|
789
|
+
None, "--lock", help="Lockfile path to reference (default: source-lock.json)."
|
|
790
|
+
),
|
|
791
|
+
force: bool = typer.Option(False, "--force", help="Overwrite an existing file."),
|
|
792
|
+
) -> None:
|
|
793
|
+
"""Scaffold SourceLock into a repository.
|
|
794
|
+
|
|
795
|
+
Today this writes the CI workflow. It never overwrites without `--force`:
|
|
796
|
+
the file it would replace is the one deciding whether your build fails.
|
|
797
|
+
"""
|
|
798
|
+
if not ci:
|
|
799
|
+
_fail("nothing to do: pass --ci to write the GitHub Actions workflow.")
|
|
800
|
+
|
|
801
|
+
target = path or Path(".github/workflows/source-doctor.yml")
|
|
802
|
+
if target.exists() and not force:
|
|
803
|
+
_fail(
|
|
804
|
+
f"{target} already exists. Pass --force to overwrite it, or --path to write "
|
|
805
|
+
"somewhere else -- this file decides whether your build fails, so it is not "
|
|
806
|
+
"replaced silently."
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
content = _CI_WORKFLOW.format(
|
|
810
|
+
branch=branch,
|
|
811
|
+
action=action,
|
|
812
|
+
lock_path=(lock or Path(DEFAULT_LOCK_FILENAME)).as_posix(),
|
|
813
|
+
checkout=CHECKOUT_ACTION_PIN,
|
|
814
|
+
checkout_version=CHECKOUT_ACTION_VERSION,
|
|
815
|
+
)
|
|
816
|
+
try:
|
|
817
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
818
|
+
target.write_text(content, encoding="utf-8")
|
|
819
|
+
except OSError as exc:
|
|
820
|
+
_fail(f"cannot write {target}: {_os_error_reason(exc)}")
|
|
821
|
+
raise AssertionError # pragma: no cover
|
|
822
|
+
|
|
823
|
+
typer.echo(f"wrote {target}")
|
|
824
|
+
if "OWNER/sourcelock" in action:
|
|
825
|
+
typer.echo(
|
|
826
|
+
"! edit the `uses:` line: OWNER/sourcelock/action@REF is a placeholder. "
|
|
827
|
+
"Pin REF to a commit SHA: a tag can be moved after you read it."
|
|
828
|
+
)
|
|
829
|
+
typer.echo("next: `hc-source lock init`, then commit source-lock.json with the workflow.")
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
# ---------------------------------------------------------------------------
|
|
833
|
+
# receipt
|
|
834
|
+
# ---------------------------------------------------------------------------
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
@app.command()
|
|
838
|
+
def receipt(
|
|
839
|
+
verify: Path = typer.Option(..., "--verify", help="Path to a receipt JSON file."),
|
|
840
|
+
) -> None:
|
|
841
|
+
"""Validate a receipt file against the Receipt schema.
|
|
842
|
+
|
|
843
|
+
Schema validation only. Signature verification is not implemented yet:
|
|
844
|
+
a valid receipt here proves shape, not authorship.
|
|
845
|
+
"""
|
|
846
|
+
try:
|
|
847
|
+
raw = Path(verify).read_text(encoding="utf-8")
|
|
848
|
+
except OSError as exc:
|
|
849
|
+
_fail(f"cannot read {verify}: {type(exc).__name__}")
|
|
850
|
+
raise AssertionError # pragma: no cover
|
|
851
|
+
|
|
852
|
+
try:
|
|
853
|
+
parsed = Receipt.model_validate_json(raw)
|
|
854
|
+
except ValidationError as exc:
|
|
855
|
+
problems = "; ".join(
|
|
856
|
+
f"{'.'.join(str(p) for p in e['loc']) or '<receipt>'}: {e['msg']}" for e in exc.errors()
|
|
857
|
+
)
|
|
858
|
+
_fail(f"receipt is not valid -- {problems}")
|
|
859
|
+
raise AssertionError # pragma: no cover
|
|
860
|
+
|
|
861
|
+
typer.echo(f"receipt is valid against the Receipt schema (v{__version__}).")
|
|
862
|
+
typer.echo(f" source={parsed.source_id} route={parsed.route} version={parsed.source_version}")
|
|
863
|
+
typer.echo(f" raw_sha256={parsed.raw_sha256}")
|
|
864
|
+
typer.echo("signature verification: not implemented -- this checks shape, not authorship.")
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
# ---------------------------------------------------------------------------
|
|
868
|
+
# cache
|
|
869
|
+
# ---------------------------------------------------------------------------
|
|
870
|
+
|
|
871
|
+
cache_app = typer.Typer(help="Inspect and clear the local caches.", no_args_is_help=True)
|
|
872
|
+
app.add_typer(cache_app, name="cache")
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def _cache_report(stats: dict) -> None:
|
|
876
|
+
if not stats["enabled"]:
|
|
877
|
+
typer.echo(
|
|
878
|
+
f"caching is off ({cache.CACHE_DIR_ENV} is set to a disabling value). "
|
|
879
|
+
"Every fetch goes to the source."
|
|
880
|
+
)
|
|
881
|
+
return
|
|
882
|
+
ttl = stats["tool_cache_ttl_seconds"]
|
|
883
|
+
typer.echo(f"cache directory: {stats['path']}")
|
|
884
|
+
typer.echo(
|
|
885
|
+
f"HTTP entries: {stats['http_entries']} "
|
|
886
|
+
"(revalidated with ETag/Last-Modified on every use, so a hit is bytes "
|
|
887
|
+
"the source just confirmed)"
|
|
888
|
+
)
|
|
889
|
+
typer.echo(
|
|
890
|
+
f"result entries: {stats['tool_entries']} "
|
|
891
|
+
+ (
|
|
892
|
+
f"(replayed for up to {ttl:g}s without contacting anyone)"
|
|
893
|
+
if ttl > 0
|
|
894
|
+
else f"(the result cache is OFF; set {cache.TOOL_TTL_ENV} to a number of seconds)"
|
|
895
|
+
)
|
|
896
|
+
)
|
|
897
|
+
typer.echo(f"on disk: {stats['bytes'] / 1024:.1f} KiB")
|
|
898
|
+
|
|
899
|
+
uncacheable = stats["uncacheable_urls"]
|
|
900
|
+
if uncacheable:
|
|
901
|
+
# Otherwise "0 entries" reads as a broken cache. It usually is not: most
|
|
902
|
+
# of the CMS Coverage API answers with neither an ETag nor a
|
|
903
|
+
# Last-Modified, so there is nothing to revalidate against and storing
|
|
904
|
+
# the body would break the only promise this cache makes.
|
|
905
|
+
typer.echo(
|
|
906
|
+
f"\n{len(uncacheable)} endpoint(s) cannot be cached: upstream sends no "
|
|
907
|
+
"ETag and no Last-Modified, so a stored copy could never be re-confirmed. "
|
|
908
|
+
"These are fetched fresh every time, by design:"
|
|
909
|
+
)
|
|
910
|
+
for url in uncacheable[:10]:
|
|
911
|
+
typer.echo(f" {url}")
|
|
912
|
+
if len(uncacheable) > 10:
|
|
913
|
+
typer.echo(f" ... and {len(uncacheable) - 10} more (--json for all)")
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
@cache_app.command("info")
|
|
917
|
+
def cache_info(
|
|
918
|
+
json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
|
|
919
|
+
) -> None:
|
|
920
|
+
"""Where the cache is, what is in it, and what it is allowed to do."""
|
|
921
|
+
stats = cache.cache_stats()
|
|
922
|
+
if json_output:
|
|
923
|
+
typer.echo(json.dumps(stats, indent=2))
|
|
924
|
+
raise typer.Exit(EXIT_OK)
|
|
925
|
+
_cache_report(stats)
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
@cache_app.command("clear")
|
|
929
|
+
def cache_clear() -> None:
|
|
930
|
+
"""Delete every cached response and result."""
|
|
931
|
+
before = cache.clear_cache()
|
|
932
|
+
if not before["enabled"]:
|
|
933
|
+
typer.echo("caching is off; there was nothing to clear.")
|
|
934
|
+
raise typer.Exit(EXIT_OK)
|
|
935
|
+
typer.echo(
|
|
936
|
+
f"cleared {before['path']} -- {before['http_entries']} HTTP entry/entries, "
|
|
937
|
+
f"{before['tool_entries']} result(s), {before['bytes'] / 1024:.1f} KiB"
|
|
938
|
+
)
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
# ---------------------------------------------------------------------------
|
|
942
|
+
# mcp
|
|
943
|
+
# ---------------------------------------------------------------------------
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
@app.command()
|
|
947
|
+
def mcp() -> None:
|
|
948
|
+
"""Serve every discovered tool to an agent over MCP stdio."""
|
|
949
|
+
from .mcp_server import serve_stdio
|
|
950
|
+
|
|
951
|
+
serve_stdio()
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def main() -> None:
|
|
955
|
+
app()
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
if __name__ == "__main__": # pragma: no cover
|
|
959
|
+
main()
|