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 ADDED
@@ -0,0 +1,5 @@
1
+ """SourceLock: deterministic, provenance-tracked access to public healthcare data sources."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1,500 @@
1
+ """Adapter discovery: built in, installed, and local.
2
+
3
+ An adapter is a module-level ``ADAPTER``. SourceLock finds them in three places
4
+ and treats all three identically once found:
5
+
6
+ 1. **built in** -- every module in this package. Drop a file here that defines
7
+ ``ADAPTER`` and it is discovered; there is no registry to edit, which is what
8
+ lets several people add routes at once without touching a shared file.
9
+ 2. **installed** -- any distribution advertising the ``sourcelock.adapters``
10
+ entry-point group. This is how you add a proprietary source without forking:
11
+ your licensed AMA CPT tables, your clearinghouse's payer list, your own
12
+ internal reference service. ``pip install`` it and ``hc-source tools`` shows
13
+ it beside the built-ins.
14
+ 3. **local** -- a directory of ``*.py`` files, for an adapter that belongs to one
15
+ repository and is not worth packaging. **Off unless you switch it on**, with
16
+ ``HC_SOURCE_LOCAL_ADAPTERS=1`` (meaning ``./.sourcelock/adapters``) or
17
+ ``HC_SOURCE_LOCAL_ADAPTERS=/path/you/trust``. Every file in that directory is
18
+ imported -- which is to say executed -- before anything about it has been
19
+ validated, so switching this on is a statement that you trust whoever can
20
+ write there. It defaulted to on, and combined with the ``pull_request``
21
+ trigger ``hc-source init --ci`` writes, that handed any pull request
22
+ arbitrary code execution on the runner. See :func:`local_adapter_dir`.
23
+
24
+ Rules enforced at discovery, for all three:
25
+
26
+ * the module defines ``ADAPTER = MyAdapter()`` (an entry point may also point
27
+ straight at the adapter object or at a zero-argument factory);
28
+ * the adapter passes :func:`~hc_source.interfaces.validate_adapter` -- same
29
+ contract check, same ``extra='forbid'`` requirement, same canary rules;
30
+ * every tool is invoked through :meth:`~hc_source.interfaces.ToolSpec.invoke`,
31
+ so the zero-PHI guard runs on a third-party route exactly as it does on
32
+ ``codes.valid_on``. There is no privileged path: an adapter you installed is
33
+ not more trusted than one we shipped, and neither is more trusted than the
34
+ guard;
35
+ * a module whose name starts with ``_`` and defines no ``ADAPTER`` is treated as
36
+ a private helper and skipped silently; any other module without an ``ADAPTER``
37
+ is reported as a load error, because it is almost always a typo;
38
+ * **a third-party adapter may not claim a built-in source id.** The built-in
39
+ wins and the newcomer is reported as a load error naming both. Shadowing
40
+ ``codes`` with something that answers differently would turn every receipt
41
+ that cites ``codes`` into a lie, and it would do it silently.
42
+
43
+ A load failure is never a disappearance. Every error is collected as an
44
+ :class:`AdapterLoadError` carrying where it came from, and ``tools``, ``call``,
45
+ ``doctor`` and the MCP server all surface it -- a source someone is paying for
46
+ must not go quiet.
47
+
48
+ Helper code shared between built-in adapters belongs in ``hc_source/``, not here.
49
+
50
+ See ``ADAPTER_GUIDE.md`` and ``examples/sourcelock-example-adapter/``.
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ import importlib
56
+ import importlib.util
57
+ import os
58
+ import pkgutil
59
+ import sys
60
+ from dataclasses import dataclass, field
61
+ from importlib import metadata
62
+ from pathlib import Path
63
+
64
+ from ..interfaces import (
65
+ AdapterError,
66
+ Canary,
67
+ CanaryObservation,
68
+ SourceAdapter,
69
+ ToolResult,
70
+ ToolSpec,
71
+ validate_adapter,
72
+ )
73
+
74
+ __all__ = [
75
+ "AdapterError",
76
+ "AdapterLoadError",
77
+ "Canary",
78
+ "CanaryObservation",
79
+ "DiscoveryResult",
80
+ "ENTRY_POINT_GROUP",
81
+ "LOCAL_ADAPTER_DIR",
82
+ "LOCAL_ADAPTER_ENV",
83
+ "ORIGIN_BUILTIN",
84
+ "SourceAdapter",
85
+ "ToolResult",
86
+ "ToolSpec",
87
+ "discover",
88
+ "discover_adapters",
89
+ "discovery_errors",
90
+ "find_tool",
91
+ "get_adapter",
92
+ "iter_tools",
93
+ "local_adapter_dir",
94
+ ]
95
+
96
+ ADAPTER_ATTR = "ADAPTER"
97
+
98
+ #: Entry-point group a distribution advertises to add a source.
99
+ ENTRY_POINT_GROUP = "sourcelock.adapters"
100
+
101
+ #: Where a repository-local adapter lives, relative to the working directory.
102
+ LOCAL_ADAPTER_DIR = ".sourcelock/adapters"
103
+
104
+ #: Override the local directory, or turn it off with ``0``/``off``/``no``.
105
+ LOCAL_ADAPTER_ENV = "HC_SOURCE_LOCAL_ADAPTERS"
106
+
107
+ ORIGIN_BUILTIN = "builtin"
108
+
109
+ #: Local files are executed under a namespace of their own so a file called
110
+ #: ``codes.py`` cannot be imported later as ``hc_source.adapters.codes``.
111
+ _LOCAL_MODULE_PREFIX = "sourcelock_local_adapters"
112
+
113
+ _OFF = {"0", "off", "no", "false", "none", ""}
114
+ _ON = {"1", "on", "yes", "true"}
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class AdapterLoadError:
119
+ """An adapter could not be loaded. Structured, so nothing vanishes quietly.
120
+
121
+ ``module`` names the thing that failed -- a dotted module for a built-in, an
122
+ ``entry-point`` label for an installed one, a path for a local file.
123
+ ``origin`` says which of the three discovery routes it came from, because
124
+ "the adapter I pip-installed is broken" and "the adapter you ship is broken"
125
+ are the same sentence otherwise. ``source_id`` is the id the failure cost
126
+ you, when it can be known without importing.
127
+ """
128
+
129
+ module: str
130
+ error: str
131
+ origin: str = ORIGIN_BUILTIN
132
+ source_id: str | None = None
133
+
134
+ def __str__(self) -> str:
135
+ where = "" if self.origin == ORIGIN_BUILTIN else f" [{self.origin}]"
136
+ return f"{self.module}{where}: {self.error}"
137
+
138
+ def as_dict(self) -> dict[str, str | None]:
139
+ return {
140
+ "module": self.module,
141
+ "error": self.error,
142
+ "origin": self.origin,
143
+ "source_id": self.source_id,
144
+ }
145
+
146
+
147
+ @dataclass
148
+ class DiscoveryResult:
149
+ adapters: list[SourceAdapter] = field(default_factory=list)
150
+ errors: list[AdapterLoadError] = field(default_factory=list)
151
+ #: source_id -> where it came from ("builtin", "entry-point:...", "local:...").
152
+ origins: dict[str, str] = field(default_factory=dict)
153
+
154
+ def origin(self, source_id: str) -> str:
155
+ return self.origins.get(source_id, ORIGIN_BUILTIN)
156
+
157
+ def third_party(self) -> list[SourceAdapter]:
158
+ return [a for a in self.adapters if self.origin(a.source_id) != ORIGIN_BUILTIN]
159
+
160
+
161
+ # ---------------------------------------------------------------------------
162
+ # discovery
163
+ # ---------------------------------------------------------------------------
164
+
165
+
166
+ def discover(*, strict: bool = False, reload: bool = False) -> DiscoveryResult:
167
+ """Import every adapter SourceLock can see, from all three routes.
168
+
169
+ A broken adapter is collected as an error rather than raised, so one bad
170
+ module does not hide the other routes. Pass ``strict=True`` to raise the
171
+ first error instead.
172
+
173
+ Order is fixed and matters: built-ins are claimed first, then entry points
174
+ sorted by name, then local files sorted by filename. Discovery is therefore
175
+ deterministic, and the first claimant of a source id keeps it.
176
+ """
177
+ result = DiscoveryResult()
178
+ _discover_builtin(result, strict=strict, reload=reload)
179
+ _discover_entry_points(result, strict=strict)
180
+ _discover_local(result, strict=strict)
181
+ result.adapters.sort(key=lambda a: a.source_id)
182
+ return result
183
+
184
+
185
+ def _discover_builtin(result: DiscoveryResult, *, strict: bool, reload: bool) -> None:
186
+ for info in sorted(pkgutil.iter_modules(__path__), key=lambda m: m.name):
187
+ if info.ispkg or info.name.startswith("__"):
188
+ continue
189
+ module_name = f"{__name__}.{info.name}"
190
+ try:
191
+ module = importlib.import_module(module_name)
192
+ if reload:
193
+ module = importlib.reload(module)
194
+ except Exception as exc: # noqa: BLE001 - report, never crash discovery
195
+ _record(
196
+ result,
197
+ AdapterLoadError(module_name, f"{type(exc).__name__}: {exc}", ORIGIN_BUILTIN),
198
+ strict,
199
+ )
200
+ continue
201
+
202
+ adapter = getattr(module, ADAPTER_ATTR, None)
203
+ if adapter is None:
204
+ if info.name.startswith("_"):
205
+ continue
206
+ _record(
207
+ result,
208
+ AdapterLoadError(
209
+ module_name, f"defines no module-level {ADAPTER_ATTR}", ORIGIN_BUILTIN
210
+ ),
211
+ strict,
212
+ )
213
+ continue
214
+
215
+ _claim(result, adapter, module=module_name, origin=ORIGIN_BUILTIN, strict=strict)
216
+
217
+
218
+ def _discover_entry_points(result: DiscoveryResult, *, strict: bool) -> None:
219
+ for entry in _entry_points():
220
+ label = f"{ENTRY_POINT_GROUP}:{entry.name}"
221
+ origin = f"entry-point:{_distribution_name(entry)}"
222
+ try:
223
+ loaded = entry.load()
224
+ except Exception as exc: # noqa: BLE001
225
+ _record(
226
+ result,
227
+ AdapterLoadError(
228
+ label, f"{type(exc).__name__}: {exc}", origin, source_id=entry.name
229
+ ),
230
+ strict,
231
+ )
232
+ continue
233
+
234
+ try:
235
+ adapter = _coerce(loaded, label)
236
+ except AdapterError as exc:
237
+ _record(result, AdapterLoadError(label, str(exc), origin, entry.name), strict)
238
+ continue
239
+
240
+ _claim(result, adapter, module=label, origin=origin, strict=strict, hint=entry.name)
241
+
242
+
243
+ def _discover_local(result: DiscoveryResult, *, strict: bool) -> None:
244
+ directory = local_adapter_dir()
245
+ if directory is None or not directory.is_dir():
246
+ return
247
+ for path in sorted(directory.glob("*.py")):
248
+ if path.name.startswith("__"):
249
+ continue
250
+ label = str(path)
251
+ origin = f"local:{path}"
252
+ try:
253
+ module = _load_local_module(path)
254
+ except Exception as exc: # noqa: BLE001
255
+ _record(
256
+ result,
257
+ AdapterLoadError(label, f"{type(exc).__name__}: {exc}", origin, path.stem),
258
+ strict,
259
+ )
260
+ continue
261
+
262
+ adapter = getattr(module, ADAPTER_ATTR, None)
263
+ if adapter is None:
264
+ if path.stem.startswith("_"):
265
+ continue
266
+ _record(
267
+ result,
268
+ AdapterLoadError(
269
+ label, f"defines no module-level {ADAPTER_ATTR}", origin, path.stem
270
+ ),
271
+ strict,
272
+ )
273
+ continue
274
+
275
+ _claim(result, adapter, module=label, origin=origin, strict=strict, hint=path.stem)
276
+
277
+
278
+ def local_adapter_dir() -> Path | None:
279
+ """The local adapter directory, or ``None`` -- which is the default.
280
+
281
+ **Unset means OFF.** Loading Python out of a directory is executing it, and
282
+ this used to default to ON at ``./.sourcelock/adapters``: every
283
+ non-underscore ``.py`` there was imported, before any contract check, as
284
+ part of ``tools``, ``call``, ``doctor`` and the MCP server. ``hc-source init
285
+ --ci`` writes a workflow with a ``pull_request`` trigger, so a pull request
286
+ that added ``.sourcelock/adapters/steal.py`` got arbitrary code execution
287
+ with the runner's token and network -- from a fork, with no review, on the
288
+ first CI run. "Your own repository" is not the trust level of a PR branch.
289
+
290
+ Opting in:
291
+
292
+ ============================================ =============================
293
+ unset, ``0``/``off``/``no``/``false``/``none`` off (the default)
294
+ ``1``/``on``/``yes``/``true`` ``./.sourcelock/adapters``
295
+ anything else that path, verbatim
296
+ ============================================ =============================
297
+
298
+ Whichever way you opt in, you are declaring that directory trusted to run
299
+ code in this process. Do not turn it on in a job that builds pull requests
300
+ from people who cannot already push to the repository.
301
+ """
302
+ raw = os.environ.get(LOCAL_ADAPTER_ENV)
303
+ if raw is None:
304
+ return None
305
+ value = raw.strip()
306
+ if value.lower() in _OFF:
307
+ return None
308
+ if value.lower() in _ON:
309
+ return Path(LOCAL_ADAPTER_DIR)
310
+ return Path(value)
311
+
312
+
313
+ def _entry_points():
314
+ try:
315
+ found = metadata.entry_points(group=ENTRY_POINT_GROUP)
316
+ except Exception: # noqa: BLE001 - a broken environment must not end discovery
317
+ return []
318
+ return sorted(found, key=lambda e: (e.name, e.value))
319
+
320
+
321
+ def _distribution_name(entry) -> str:
322
+ """Which installed distribution advertised this entry point.
323
+
324
+ ``EntryPoint.dist`` is populated by ``entry_points()`` but not by an
325
+ EntryPoint someone constructed directly (which is how the tests inject one),
326
+ so fall back to the module the value names -- never to nothing, because the
327
+ whole point of the origin label is telling a customer which package to fix.
328
+ """
329
+ dist = getattr(entry, "dist", None)
330
+ name = getattr(dist, "name", None)
331
+ if not name and dist is not None:
332
+ meta = getattr(dist, "metadata", None)
333
+ name = meta.get("Name") if meta is not None else None
334
+ return name or entry.value.split(":", 1)[0].split(".", 1)[0]
335
+
336
+
337
+ def _coerce(loaded, label: str) -> object:
338
+ """Accept the three shapes an entry point may point at.
339
+
340
+ A module with an ``ADAPTER``, the adapter object itself, or a zero-argument
341
+ factory. Anything else is a load error naming what we got, so a plugin
342
+ author reads a sentence instead of an ``AttributeError`` from our internals.
343
+ """
344
+ if isinstance(loaded, SourceAdapter):
345
+ return loaded
346
+ candidate = getattr(loaded, ADAPTER_ATTR, None)
347
+ if candidate is not None:
348
+ return candidate
349
+ if callable(loaded):
350
+ produced = loaded()
351
+ if produced is None:
352
+ raise AdapterError(f"{label}: the factory returned None")
353
+ return produced
354
+ raise AdapterError(
355
+ f"{label}: entry point resolved to {type(loaded).__name__}, which is neither a "
356
+ f"SourceAdapter, a module defining {ADAPTER_ATTR}, nor a factory returning one"
357
+ )
358
+
359
+
360
+ def _load_local_module(path: Path):
361
+ """Execute a local adapter file under a namespace of its own."""
362
+ name = f"{_LOCAL_MODULE_PREFIX}.{path.stem}"
363
+ spec = importlib.util.spec_from_file_location(name, path)
364
+ if spec is None or spec.loader is None:
365
+ raise ImportError(f"{path.name} is not loadable as a Python module")
366
+ module = importlib.util.module_from_spec(spec)
367
+ # Registered so pydantic can resolve the module's own annotations, but under
368
+ # `sourcelock_local_adapters.*` -- a local file called `codes.py` must never
369
+ # become importable as `hc_source.adapters.codes`.
370
+ sys.modules[name] = module
371
+ try:
372
+ spec.loader.exec_module(module)
373
+ except BaseException:
374
+ sys.modules.pop(name, None)
375
+ raise
376
+ return module
377
+
378
+
379
+ def _claim(
380
+ result: DiscoveryResult,
381
+ adapter,
382
+ *,
383
+ module: str,
384
+ origin: str,
385
+ strict: bool,
386
+ hint: str | None = None,
387
+ ) -> None:
388
+ """Validate an adapter and give it its source id, or say who already has it."""
389
+ try:
390
+ validated = validate_adapter(adapter, origin=module)
391
+ except Exception as exc: # noqa: BLE001
392
+ _record(
393
+ result,
394
+ AdapterLoadError(module, f"{type(exc).__name__}: {exc}", origin, hint),
395
+ strict,
396
+ )
397
+ return
398
+
399
+ source_id = validated.source_id
400
+ incumbent = result.origins.get(source_id)
401
+ if incumbent is not None:
402
+ _record(
403
+ result,
404
+ AdapterLoadError(
405
+ module,
406
+ (
407
+ f"source_id {source_id!r} is already provided by {incumbent}. "
408
+ "SourceLock will not let one adapter answer under another's name: "
409
+ "every receipt naming this source would become ambiguous, and the "
410
+ "ambiguity would be invisible. Give this adapter its own source_id "
411
+ "(and its own tool prefix) instead."
412
+ ),
413
+ origin,
414
+ source_id,
415
+ ),
416
+ strict,
417
+ )
418
+ return
419
+
420
+ result.adapters.append(validated)
421
+ result.origins[source_id] = origin
422
+
423
+
424
+ def _record(result: DiscoveryResult, error: AdapterLoadError, strict: bool) -> None:
425
+ if strict:
426
+ raise AdapterError(str(error))
427
+ result.errors.append(error)
428
+
429
+
430
+ # ---------------------------------------------------------------------------
431
+ # lookups
432
+ # ---------------------------------------------------------------------------
433
+
434
+
435
+ def discover_adapters(*, strict: bool = False) -> list[SourceAdapter]:
436
+ """Every valid adapter, sorted by source_id."""
437
+ return discover(strict=strict).adapters
438
+
439
+
440
+ def discovery_errors() -> list[AdapterLoadError]:
441
+ """Every adapter that could not be loaded, from any of the three routes.
442
+
443
+ Exists because ``discover_adapters`` throws its errors away, and every
444
+ caller that used it -- ``tools``, ``call``, the MCP server -- therefore
445
+ reported a broken adapter as an adapter that simply is not there. Doctor was
446
+ the only command that knew the difference. A source someone is paying for
447
+ disappearing quietly is not an acceptable failure mode for a product whose
448
+ entire claim is that it tells you when something moved.
449
+ """
450
+ return discover().errors
451
+
452
+
453
+ def _module_source_id(error: AdapterLoadError) -> str:
454
+ """The source id a failed adapter WOULD have provided.
455
+
456
+ A module that did not import has no ``source_id`` to ask. An entry point and
457
+ a local file carry the answer in their name; a built-in module carries it in
458
+ its basename, by the one-module-per-source rule.
459
+ """
460
+ if error.source_id:
461
+ return error.source_id
462
+ return error.module.rsplit(".", 1)[-1]
463
+
464
+
465
+ def get_adapter(source_id: str) -> SourceAdapter:
466
+ found = discover()
467
+ for adapter in found.adapters:
468
+ if adapter.source_id == source_id:
469
+ return adapter
470
+ for error in found.errors:
471
+ if _module_source_id(error) == source_id:
472
+ raise AdapterError(
473
+ f"the {source_id!r} adapter exists but failed to load -- {error}. It is "
474
+ "broken, not absent; fix the module rather than removing the source."
475
+ )
476
+ raise KeyError(f"no adapter with source_id {source_id!r}")
477
+
478
+
479
+ def iter_tools(adapters: list[SourceAdapter] | None = None) -> list[ToolSpec]:
480
+ """Every tool from every adapter, sorted by name."""
481
+ adapters = discover_adapters() if adapters is None else adapters
482
+ return sorted((t for a in adapters for t in a.tools()), key=lambda t: t.name)
483
+
484
+
485
+ def find_tool(name: str, adapters: list[SourceAdapter] | None = None) -> ToolSpec:
486
+ for tool in iter_tools(adapters):
487
+ if tool.name == name:
488
+ return tool
489
+ if adapters is None:
490
+ # "No such tool" is the wrong answer when the module that provides it
491
+ # blew up on import: it sends the caller to check their spelling instead
492
+ # of their install.
493
+ wanted_source = name.split(".", 1)[0]
494
+ for error in discovery_errors():
495
+ if _module_source_id(error) == wanted_source:
496
+ raise AdapterError(
497
+ f"the adapter that provides this tool failed to load -- {error}. The "
498
+ "route is broken, not missing."
499
+ )
500
+ raise KeyError(f"no tool named {name!r}")