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/__init__.py
ADDED
|
@@ -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}")
|