generic-ml-cache-bootstrap 0.29.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.
- generic_ml_cache_bootstrap/__init__.py +26 -0
- generic_ml_cache_bootstrap/application.py +274 -0
- generic_ml_cache_bootstrap/diagnostics.py +38 -0
- generic_ml_cache_bootstrap/discovery/__init__.py +8 -0
- generic_ml_cache_bootstrap/discovery/_entrypoints.py +45 -0
- generic_ml_cache_bootstrap/discovery/api_discover.py +61 -0
- generic_ml_cache_bootstrap/discovery/client_discover.py +274 -0
- generic_ml_cache_bootstrap/discovery/composite_adapter_catalog.py +38 -0
- generic_ml_cache_bootstrap/discovery/composite_adapter_resolver.py +51 -0
- generic_ml_cache_bootstrap/discovery/composition.py +148 -0
- generic_ml_cache_bootstrap/discovery/entrypoint_adapter_catalog.py +159 -0
- generic_ml_cache_bootstrap/discovery/entrypoint_adapter_resolver.py +54 -0
- generic_ml_cache_bootstrap/discovery/in_memory_adapter_registry.py +97 -0
- generic_ml_cache_bootstrap/discovery/static_adapter_catalog.py +36 -0
- generic_ml_cache_bootstrap/encryption.py +82 -0
- generic_ml_cache_bootstrap/persistence_backend.py +129 -0
- generic_ml_cache_bootstrap/progress.py +27 -0
- generic_ml_cache_bootstrap/py.typed +0 -0
- generic_ml_cache_bootstrap/store_status.py +40 -0
- generic_ml_cache_bootstrap/stub_runners.py +34 -0
- generic_ml_cache_bootstrap-0.29.0.dist-info/METADATA +74 -0
- generic_ml_cache_bootstrap-0.29.0.dist-info/RECORD +25 -0
- generic_ml_cache_bootstrap-0.29.0.dist-info/WHEEL +4 -0
- generic_ml_cache_bootstrap-0.29.0.dist-info/licenses/LICENSE +201 -0
- generic_ml_cache_bootstrap-0.29.0.dist-info/licenses/NOTICE +8 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Client discovery: report which configured clients are present and runnable.
|
|
4
|
+
|
|
5
|
+
This is **read-only and advisory**. Discovery never chooses a client, never
|
|
6
|
+
restricts which model may run, and never gates a call -- it only reports what it
|
|
7
|
+
found on this machine. The run is always the validator.
|
|
8
|
+
|
|
9
|
+
It is the detection half of "detection, not selection": the cache can tell a
|
|
10
|
+
caller *what is here*; deciding *what to use* stays with the caller.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import subprocess
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
from generic_ml_cache_core.application.domain.model.client_status import (
|
|
19
|
+
ClientStatus as ClientStatus,
|
|
20
|
+
)
|
|
21
|
+
from generic_ml_cache_core.application.domain.model.execution.execution_kind import ExecutionKind
|
|
22
|
+
from generic_ml_cache_core.application.domain.model.model_listing import (
|
|
23
|
+
ModelListing as ModelListing,
|
|
24
|
+
)
|
|
25
|
+
from generic_ml_cache_core.application.port.outbound.diagnostics_port import DiagnosticsPort
|
|
26
|
+
from generic_ml_cache_core.application.port.outbound.local_client_port import LocalClientPort
|
|
27
|
+
from generic_ml_cache_core.common.errors import ClientNotFound, UnknownClient
|
|
28
|
+
|
|
29
|
+
from generic_ml_cache_bootstrap.discovery.composition import (
|
|
30
|
+
catalog_and_resolver_for,
|
|
31
|
+
registered_local_names,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
_LIST_MODELS_EXIT = "list-models EXIT"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _resolve_local_client(name: str, whitelist: frozenset[str] | None = None) -> LocalClientPort:
|
|
38
|
+
"""Resolve a local-managed client adapter by client name, via the catalog +
|
|
39
|
+
resolver. Raises :class:`UnknownClient` if no local adapter serves ``name``."""
|
|
40
|
+
catalog, resolver = catalog_and_resolver_for(whitelist)
|
|
41
|
+
descriptors = list(catalog.find_by_client_name(name))
|
|
42
|
+
if not descriptors:
|
|
43
|
+
raise UnknownClient(f"unknown adapter {name!r}")
|
|
44
|
+
local = [d for d in descriptors if d.supports_mode(ExecutionKind.LOCAL_MANAGED)]
|
|
45
|
+
if not local:
|
|
46
|
+
raise UnknownClient(f"not a local managed adapter: {name!r}")
|
|
47
|
+
return resolver.resolve_local_client(local[0].adapter_id)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _probe_version(
|
|
51
|
+
argv: list[str], timeout: float, diag: DiagnosticsPort | None = None
|
|
52
|
+
) -> tuple[str | None, str | None]:
|
|
53
|
+
_t = time.perf_counter()
|
|
54
|
+
if diag:
|
|
55
|
+
diag.debug("probe-version ENTER", argv0=argv[0] if argv else "")
|
|
56
|
+
try:
|
|
57
|
+
proc = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
|
|
58
|
+
except Exception as exc: # noqa: BLE001 - any launch failure just means "unknown"
|
|
59
|
+
if diag:
|
|
60
|
+
diag.debug(
|
|
61
|
+
"probe-version FAILED — treating as unknown",
|
|
62
|
+
exc=exc,
|
|
63
|
+
duration_ms=round((time.perf_counter() - _t) * 1000, 1),
|
|
64
|
+
)
|
|
65
|
+
return None, f"version check failed: {exc}"
|
|
66
|
+
out = (proc.stdout or "").strip() or (proc.stderr or "").strip()
|
|
67
|
+
first = out.splitlines()[0].strip() if out else ""
|
|
68
|
+
version = first or None
|
|
69
|
+
detail = None if first else "no version output"
|
|
70
|
+
if diag:
|
|
71
|
+
diag.debug(
|
|
72
|
+
"probe-version EXIT",
|
|
73
|
+
version=version,
|
|
74
|
+
duration_ms=round((time.perf_counter() - _t) * 1000, 1),
|
|
75
|
+
)
|
|
76
|
+
return version, detail
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def probe(
|
|
80
|
+
name: str,
|
|
81
|
+
executable: str | None = None,
|
|
82
|
+
timeout: float = 10.0,
|
|
83
|
+
diag: DiagnosticsPort | None = None,
|
|
84
|
+
) -> ClientStatus:
|
|
85
|
+
"""Probe one registered client: is its executable present, and what version?
|
|
86
|
+
|
|
87
|
+
Never raises for an absent client -- absence is reported in the result.
|
|
88
|
+
"""
|
|
89
|
+
_t = time.perf_counter()
|
|
90
|
+
if diag:
|
|
91
|
+
diag.debug("probe ENTER", name=name)
|
|
92
|
+
adapter = _resolve_local_client(name)
|
|
93
|
+
try:
|
|
94
|
+
exe = adapter.resolve_executable(executable)
|
|
95
|
+
except ClientNotFound as exc:
|
|
96
|
+
result = ClientStatus(name=name, present=False, detail=str(exc))
|
|
97
|
+
if diag:
|
|
98
|
+
diag.debug(
|
|
99
|
+
"probe EXIT",
|
|
100
|
+
name=name,
|
|
101
|
+
present=False,
|
|
102
|
+
duration_ms=round((time.perf_counter() - _t) * 1000, 1),
|
|
103
|
+
)
|
|
104
|
+
return result
|
|
105
|
+
version, detail = _probe_version(adapter.version_argv(exe), timeout, diag=diag)
|
|
106
|
+
result = ClientStatus(name=name, present=True, executable=exe, version=version, detail=detail)
|
|
107
|
+
if diag:
|
|
108
|
+
diag.debug(
|
|
109
|
+
"probe EXIT",
|
|
110
|
+
name=name,
|
|
111
|
+
present=True,
|
|
112
|
+
version=version,
|
|
113
|
+
duration_ms=round((time.perf_counter() - _t) * 1000, 1),
|
|
114
|
+
)
|
|
115
|
+
return result
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def probe_all(
|
|
119
|
+
timeout: float = 10.0,
|
|
120
|
+
executables: dict[str, str] | None = None,
|
|
121
|
+
whitelist: frozenset[str] | None = None,
|
|
122
|
+
diag: DiagnosticsPort | None = None,
|
|
123
|
+
) -> list[ClientStatus]:
|
|
124
|
+
"""Probe every registered client, in name order.
|
|
125
|
+
|
|
126
|
+
``executables`` optionally maps a client name to the executable to probe
|
|
127
|
+
(e.g. from the ``[executables]`` config); a client absent from the mapping
|
|
128
|
+
falls back to its adapter's own ``PATH`` lookup.
|
|
129
|
+
``whitelist`` opts third-party plugins into loading (by entry-point name); the bundled adapters always load.
|
|
130
|
+
"""
|
|
131
|
+
_t = time.perf_counter()
|
|
132
|
+
if diag:
|
|
133
|
+
diag.debug("probe-all ENTER")
|
|
134
|
+
exe = executables or {}
|
|
135
|
+
results = [
|
|
136
|
+
probe(name, executable=exe.get(name), timeout=timeout, diag=diag)
|
|
137
|
+
for name in registered_local_names(whitelist)
|
|
138
|
+
]
|
|
139
|
+
if diag:
|
|
140
|
+
diag.debug(
|
|
141
|
+
"probe-all EXIT",
|
|
142
|
+
count=len(results),
|
|
143
|
+
duration_ms=round((time.perf_counter() - _t) * 1000, 1),
|
|
144
|
+
)
|
|
145
|
+
return results
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def list_models( # noqa: C901
|
|
149
|
+
name: str,
|
|
150
|
+
executable: str | None = None,
|
|
151
|
+
timeout: float = 30.0,
|
|
152
|
+
whitelist: frozenset[str] | None = None,
|
|
153
|
+
diag: DiagnosticsPort | None = None,
|
|
154
|
+
) -> ModelListing:
|
|
155
|
+
"""List one client's models by relaying its own listing command.
|
|
156
|
+
|
|
157
|
+
Never raises for an absent client or a client that cannot enumerate; both
|
|
158
|
+
are reported in the result. A relayed list reflects what the *authenticated*
|
|
159
|
+
client can reach, which is why it is preferred over any static catalog.
|
|
160
|
+
"""
|
|
161
|
+
_t = time.perf_counter()
|
|
162
|
+
if diag:
|
|
163
|
+
diag.debug("list-models ENTER", name=name)
|
|
164
|
+
adapter = _resolve_local_client(name, whitelist)
|
|
165
|
+
try:
|
|
166
|
+
exe = adapter.resolve_executable(executable)
|
|
167
|
+
except ClientNotFound as exc:
|
|
168
|
+
result = ModelListing(name=name, present=False, supported=False, reason=str(exc))
|
|
169
|
+
if diag:
|
|
170
|
+
diag.debug(
|
|
171
|
+
_LIST_MODELS_EXIT,
|
|
172
|
+
name=name,
|
|
173
|
+
present=False,
|
|
174
|
+
duration_ms=round((time.perf_counter() - _t) * 1000, 1),
|
|
175
|
+
)
|
|
176
|
+
return result
|
|
177
|
+
|
|
178
|
+
argv = adapter.models_argv(exe)
|
|
179
|
+
if argv is None:
|
|
180
|
+
result = ModelListing(
|
|
181
|
+
name=name,
|
|
182
|
+
present=True,
|
|
183
|
+
supported=False,
|
|
184
|
+
reason="this client has no model-listing command",
|
|
185
|
+
)
|
|
186
|
+
if diag:
|
|
187
|
+
diag.debug(
|
|
188
|
+
_LIST_MODELS_EXIT,
|
|
189
|
+
name=name,
|
|
190
|
+
supported=False,
|
|
191
|
+
duration_ms=round((time.perf_counter() - _t) * 1000, 1),
|
|
192
|
+
)
|
|
193
|
+
return result
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
proc = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
|
|
197
|
+
except Exception as exc: # noqa: BLE001 - any launch failure is just "couldn't list"
|
|
198
|
+
if diag:
|
|
199
|
+
diag.debug(
|
|
200
|
+
"list-models launch failed",
|
|
201
|
+
name=name,
|
|
202
|
+
exc=exc,
|
|
203
|
+
duration_ms=round((time.perf_counter() - _t) * 1000, 1),
|
|
204
|
+
)
|
|
205
|
+
result = ModelListing(
|
|
206
|
+
name=name, present=True, supported=True, reason=f"model listing failed: {exc}"
|
|
207
|
+
)
|
|
208
|
+
if diag:
|
|
209
|
+
diag.debug(
|
|
210
|
+
_LIST_MODELS_EXIT,
|
|
211
|
+
name=name,
|
|
212
|
+
duration_ms=round((time.perf_counter() - _t) * 1000, 1),
|
|
213
|
+
)
|
|
214
|
+
return result
|
|
215
|
+
|
|
216
|
+
if proc.returncode != 0:
|
|
217
|
+
err = (proc.stderr or proc.stdout or "").strip().splitlines()
|
|
218
|
+
detail = err[0].strip() if err else f"exit {proc.returncode}"
|
|
219
|
+
result = ModelListing(
|
|
220
|
+
name=name,
|
|
221
|
+
present=True,
|
|
222
|
+
supported=True,
|
|
223
|
+
reason=f"client exited {proc.returncode}: {detail}",
|
|
224
|
+
)
|
|
225
|
+
if diag:
|
|
226
|
+
diag.debug(
|
|
227
|
+
_LIST_MODELS_EXIT,
|
|
228
|
+
name=name,
|
|
229
|
+
returncode=proc.returncode,
|
|
230
|
+
duration_ms=round((time.perf_counter() - _t) * 1000, 1),
|
|
231
|
+
)
|
|
232
|
+
return result
|
|
233
|
+
|
|
234
|
+
result = ModelListing(
|
|
235
|
+
name=name, present=True, supported=True, models=adapter.parse_model_list(proc.stdout)
|
|
236
|
+
)
|
|
237
|
+
if diag:
|
|
238
|
+
diag.debug(
|
|
239
|
+
"list-models EXIT",
|
|
240
|
+
name=name,
|
|
241
|
+
model_count=len(result.models) if result.models else 0,
|
|
242
|
+
duration_ms=round((time.perf_counter() - _t) * 1000, 1),
|
|
243
|
+
)
|
|
244
|
+
return result
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def list_models_all(
|
|
248
|
+
timeout: float = 30.0,
|
|
249
|
+
executables: dict[str, str] | None = None,
|
|
250
|
+
whitelist: frozenset[str] | None = None,
|
|
251
|
+
diag: DiagnosticsPort | None = None,
|
|
252
|
+
) -> list[ModelListing]:
|
|
253
|
+
"""List models for every registered client, in name order.
|
|
254
|
+
|
|
255
|
+
``executables`` optionally maps a client name to the executable to use
|
|
256
|
+
(e.g. from the ``[executables]`` config); a client absent from the mapping
|
|
257
|
+
falls back to its adapter's own ``PATH`` lookup.
|
|
258
|
+
``whitelist`` opts third-party plugins into loading (by entry-point name); the bundled adapters always load.
|
|
259
|
+
"""
|
|
260
|
+
_t = time.perf_counter()
|
|
261
|
+
if diag:
|
|
262
|
+
diag.debug("list-models-all ENTER")
|
|
263
|
+
exe = executables or {}
|
|
264
|
+
results = [
|
|
265
|
+
list_models(name, executable=exe.get(name), timeout=timeout, whitelist=whitelist, diag=diag)
|
|
266
|
+
for name in registered_local_names(whitelist)
|
|
267
|
+
]
|
|
268
|
+
if diag:
|
|
269
|
+
diag.debug(
|
|
270
|
+
"list-models-all EXIT",
|
|
271
|
+
count=len(results),
|
|
272
|
+
duration_ms=round((time.perf_counter() - _t) * 1000, 1),
|
|
273
|
+
)
|
|
274
|
+
return results
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""CompositeAdapterCatalog — merge several catalogs into one view.
|
|
4
|
+
|
|
5
|
+
Used by composition to present entry-point adapters and in-process registered
|
|
6
|
+
adapters (e.g. test fakes) as a single universe. On an ``adapter_id`` collision
|
|
7
|
+
the first catalog wins.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Iterable, Sequence
|
|
13
|
+
|
|
14
|
+
from generic_ml_cache_core.application.domain.model.catalog.adapter_descriptor import (
|
|
15
|
+
AdapterDescriptor,
|
|
16
|
+
)
|
|
17
|
+
from generic_ml_cache_core.application.domain.model.execution.execution_kind import ExecutionKind
|
|
18
|
+
from generic_ml_cache_core.application.port.outbound.adapter_catalog_port import AdapterCatalogPort
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CompositeAdapterCatalog(AdapterCatalogPort):
|
|
22
|
+
"""An AdapterCatalogPort that unions several catalogs."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, catalogs: Iterable[AdapterCatalogPort]) -> None:
|
|
25
|
+
self._catalogs: list[AdapterCatalogPort] = list(catalogs)
|
|
26
|
+
|
|
27
|
+
def list_adapters(self) -> Sequence[AdapterDescriptor]:
|
|
28
|
+
merged: dict[str, AdapterDescriptor] = {}
|
|
29
|
+
for catalog in self._catalogs:
|
|
30
|
+
for descriptor in catalog.list_adapters():
|
|
31
|
+
merged.setdefault(descriptor.adapter_id, descriptor)
|
|
32
|
+
return list(merged.values())
|
|
33
|
+
|
|
34
|
+
def find_by_client_name(self, client_name: str) -> Sequence[AdapterDescriptor]:
|
|
35
|
+
return [d for d in self.list_adapters() if d.client_name == client_name]
|
|
36
|
+
|
|
37
|
+
def supports(self, client_name: str, mode: ExecutionKind) -> bool:
|
|
38
|
+
return any(catalog.supports(client_name, mode) for catalog in self._catalogs)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""CompositeAdapterResolver — try several resolvers in order.
|
|
4
|
+
|
|
5
|
+
The resolver counterpart to CompositeAdapterCatalog: composition merges the
|
|
6
|
+
entry-point resolver with the in-process registry resolver so a registered fake
|
|
7
|
+
and an installed adapter resolve through one object. The first resolver that
|
|
8
|
+
knows the id wins.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Iterable
|
|
14
|
+
|
|
15
|
+
from generic_ml_cache_core.application.port.outbound.adapter_resolver_port import (
|
|
16
|
+
AdapterResolverPort,
|
|
17
|
+
)
|
|
18
|
+
from generic_ml_cache_core.application.port.outbound.local_client_port import LocalClientPort
|
|
19
|
+
from generic_ml_cache_core.application.port.outbound.ml_runner_port import MlRunnerPort
|
|
20
|
+
from generic_ml_cache_core.common.errors import UnknownClient
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CompositeAdapterResolver(AdapterResolverPort):
|
|
24
|
+
"""An AdapterResolverPort that delegates to the first resolver that succeeds."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, resolvers: Iterable[AdapterResolverPort]) -> None:
|
|
27
|
+
self._resolvers: list[AdapterResolverPort] = list(resolvers)
|
|
28
|
+
|
|
29
|
+
def resolve_local_client(
|
|
30
|
+
self,
|
|
31
|
+
adapter_id: str,
|
|
32
|
+
executable_override: str | None = None,
|
|
33
|
+
timeout: float | None = None,
|
|
34
|
+
stream_path: str | None = None,
|
|
35
|
+
) -> LocalClientPort:
|
|
36
|
+
for resolver in self._resolvers:
|
|
37
|
+
try:
|
|
38
|
+
return resolver.resolve_local_client(
|
|
39
|
+
adapter_id, executable_override, timeout, stream_path
|
|
40
|
+
)
|
|
41
|
+
except UnknownClient:
|
|
42
|
+
continue
|
|
43
|
+
raise UnknownClient(f"no resolver for id {adapter_id!r}")
|
|
44
|
+
|
|
45
|
+
def resolve_runner(self, adapter_id: str) -> MlRunnerPort:
|
|
46
|
+
for resolver in self._resolvers:
|
|
47
|
+
try:
|
|
48
|
+
return resolver.resolve_runner(adapter_id)
|
|
49
|
+
except UnknownClient:
|
|
50
|
+
continue
|
|
51
|
+
raise UnknownClient(f"no resolver for id {adapter_id!r}")
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Default adapter-catalog composition, shared by the CLI and daemon drivers.
|
|
4
|
+
|
|
5
|
+
The recommended default wiring: entry-point adapters merged with any in-process
|
|
6
|
+
registered adapters, as a live catalog and resolver. Drivers opt into this (they
|
|
7
|
+
could compose differently) and add deployment policy such as a whitelist on top.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from generic_ml_cache_core.application.domain.model.catalog.adapter_boundary import AdapterBoundary
|
|
13
|
+
from generic_ml_cache_core.application.domain.model.execution.execution_kind import ExecutionKind
|
|
14
|
+
from generic_ml_cache_core.application.port.outbound.adapter_catalog_port import AdapterCatalogPort
|
|
15
|
+
from generic_ml_cache_core.application.port.outbound.adapter_resolver_port import (
|
|
16
|
+
AdapterResolverPort,
|
|
17
|
+
)
|
|
18
|
+
from generic_ml_cache_core.common.errors import UnknownClient
|
|
19
|
+
|
|
20
|
+
from generic_ml_cache_bootstrap.discovery.composite_adapter_catalog import CompositeAdapterCatalog
|
|
21
|
+
from generic_ml_cache_bootstrap.discovery.composite_adapter_resolver import CompositeAdapterResolver
|
|
22
|
+
from generic_ml_cache_bootstrap.discovery.entrypoint_adapter_catalog import EntryPointAdapterCatalog
|
|
23
|
+
from generic_ml_cache_bootstrap.discovery.entrypoint_adapter_resolver import (
|
|
24
|
+
EntryPointAdapterResolver,
|
|
25
|
+
)
|
|
26
|
+
from generic_ml_cache_bootstrap.discovery.in_memory_adapter_registry import default_registry
|
|
27
|
+
|
|
28
|
+
_PRIMARY_MODE = {
|
|
29
|
+
AdapterBoundary.LOCAL_CLI: ExecutionKind.LOCAL_MANAGED,
|
|
30
|
+
AdapterBoundary.API: ExecutionKind.API,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
_entrypoint_catalog: EntryPointAdapterCatalog | None = None
|
|
34
|
+
_catalog: CompositeAdapterCatalog | None = None
|
|
35
|
+
_resolver: CompositeAdapterResolver | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _entrypoint() -> EntryPointAdapterCatalog:
|
|
39
|
+
global _entrypoint_catalog
|
|
40
|
+
if _entrypoint_catalog is None:
|
|
41
|
+
_entrypoint_catalog = EntryPointAdapterCatalog()
|
|
42
|
+
return _entrypoint_catalog
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def default_catalog() -> AdapterCatalogPort:
|
|
46
|
+
"""Entry-point adapters merged with the in-process registry (a live view)."""
|
|
47
|
+
global _catalog
|
|
48
|
+
if _catalog is None:
|
|
49
|
+
_catalog = CompositeAdapterCatalog([_entrypoint(), default_registry()])
|
|
50
|
+
return _catalog
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def default_resolver() -> AdapterResolverPort:
|
|
54
|
+
"""Resolve an adapter id from the DEFAULT (trusted-only) catalog's vetted classes
|
|
55
|
+
or the in-process registry. Bound to the same entry-point catalog as
|
|
56
|
+
:func:`default_catalog`, so it constructs only adapters that catalog gated in
|
|
57
|
+
(X15) — never by re-loading an ungated plugin."""
|
|
58
|
+
global _resolver
|
|
59
|
+
if _resolver is None:
|
|
60
|
+
_resolver = CompositeAdapterResolver(
|
|
61
|
+
[EntryPointAdapterResolver(_entrypoint()), default_registry()]
|
|
62
|
+
)
|
|
63
|
+
return _resolver
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def catalog_and_resolver_for(
|
|
67
|
+
whitelist: frozenset[str] | None = None,
|
|
68
|
+
) -> tuple[AdapterCatalogPort, AdapterResolverPort]:
|
|
69
|
+
"""A catalog and a resolver that SHARE one gated entry-point catalog, so the
|
|
70
|
+
resolver constructs exactly the adapters the catalog vetted — one trust gate, one
|
|
71
|
+
place (X15). Use this whenever a caller both lists and resolves under the same
|
|
72
|
+
``whitelist``, so listing and resolution can never diverge."""
|
|
73
|
+
if whitelist is None:
|
|
74
|
+
return default_catalog(), default_resolver()
|
|
75
|
+
entrypoint = EntryPointAdapterCatalog(whitelist=whitelist)
|
|
76
|
+
registry = default_registry()
|
|
77
|
+
return (
|
|
78
|
+
CompositeAdapterCatalog([entrypoint, registry]),
|
|
79
|
+
CompositeAdapterResolver([EntryPointAdapterResolver(entrypoint), registry]),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def catalog_for(whitelist: frozenset[str] | None = None) -> AdapterCatalogPort:
|
|
84
|
+
"""The default catalog.
|
|
85
|
+
|
|
86
|
+
``whitelist`` opts third-party plugins (by entry-point name) into loading;
|
|
87
|
+
the bundled adapters always load. With no whitelist, only the trusted bundled
|
|
88
|
+
adapters plus any in-process registrations are visible — the safe default
|
|
89
|
+
(a package installed for another reason never silently becomes a client).
|
|
90
|
+
"""
|
|
91
|
+
if whitelist is None:
|
|
92
|
+
return default_catalog()
|
|
93
|
+
entrypoint = EntryPointAdapterCatalog(whitelist=whitelist)
|
|
94
|
+
return CompositeAdapterCatalog([entrypoint, default_registry()])
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _unknown_adapter(name: str, catalog: AdapterCatalogPort) -> UnknownClient:
|
|
98
|
+
known = ", ".join(sorted({d.client_name for d in catalog.list_adapters()})) or "(none)"
|
|
99
|
+
return UnknownClient(f"unknown adapter {name!r}; available: {known}")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_adapter(name: str, whitelist: frozenset[str] | None = None) -> object:
|
|
103
|
+
"""Resolve a constructed adapter by client name (a local client or an API
|
|
104
|
+
runner). A convenience over the catalog + resolver; raises :class:`UnknownClient`
|
|
105
|
+
if the client is absent. Lives here, in infrastructure — never in core."""
|
|
106
|
+
catalog, resolver = catalog_and_resolver_for(whitelist)
|
|
107
|
+
descriptors = catalog.find_by_client_name(name)
|
|
108
|
+
if not descriptors:
|
|
109
|
+
raise _unknown_adapter(name, catalog)
|
|
110
|
+
descriptor = descriptors[0]
|
|
111
|
+
if descriptor.boundary is AdapterBoundary.API:
|
|
112
|
+
return resolver.resolve_runner(descriptor.adapter_id)
|
|
113
|
+
return resolver.resolve_local_client(descriptor.adapter_id)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def execution_kind_for(client: str, whitelist: frozenset[str] | None = None) -> ExecutionKind:
|
|
117
|
+
"""The primary execution kind for a client (LOCAL_MANAGED for a CLI, API for a
|
|
118
|
+
provider). Raises :class:`UnknownClient` if the client is absent."""
|
|
119
|
+
catalog = catalog_for(whitelist)
|
|
120
|
+
descriptors = catalog.find_by_client_name(client)
|
|
121
|
+
if not descriptors:
|
|
122
|
+
raise _unknown_adapter(client, catalog)
|
|
123
|
+
return _PRIMARY_MODE[descriptors[0].boundary]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def registered_names(whitelist: frozenset[str] | None = None) -> list[str]:
|
|
127
|
+
return sorted({d.client_name for d in catalog_for(whitelist).list_adapters()})
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def registered_local_names(whitelist: frozenset[str] | None = None) -> list[str]:
|
|
131
|
+
return sorted(
|
|
132
|
+
{
|
|
133
|
+
d.client_name
|
|
134
|
+
for d in catalog_for(whitelist).list_adapters()
|
|
135
|
+
if d.supports_mode(ExecutionKind.LOCAL_MANAGED)
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def adapter_sources(whitelist: frozenset[str] | None = None) -> dict[str, str]:
|
|
141
|
+
"""Map each entry-point client name to the distribution that provided it."""
|
|
142
|
+
sources = _entrypoint().sources() # adapter_id -> "<package> <version>"
|
|
143
|
+
visible = {d.client_name for d in catalog_for(whitelist).list_adapters()}
|
|
144
|
+
out: dict[str, str] = {}
|
|
145
|
+
for descriptor in _entrypoint().list_adapters():
|
|
146
|
+
if descriptor.client_name in visible and descriptor.adapter_id in sources:
|
|
147
|
+
out[descriptor.client_name] = sources[descriptor.adapter_id]
|
|
148
|
+
return out
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""EntryPointAdapterCatalog — discover adapters from installed entry points.
|
|
4
|
+
|
|
5
|
+
The default catalog: it scans the ``gmlcache.adapters`` entry-point group, loads
|
|
6
|
+
each adapter class, and reads its ``descriptor()`` — without instantiating the
|
|
7
|
+
adapter. A broken or incompatible entry point is warned and skipped; bad
|
|
8
|
+
third-party code never crashes discovery. This is where the packaging-system
|
|
9
|
+
dependency lives, out of core.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import importlib.metadata
|
|
15
|
+
import warnings
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
from typing import cast
|
|
18
|
+
|
|
19
|
+
from generic_ml_cache_core.application.domain.model.catalog.adapter_descriptor import (
|
|
20
|
+
AdapterDescriptor,
|
|
21
|
+
)
|
|
22
|
+
from generic_ml_cache_core.application.domain.model.execution.execution_kind import ExecutionKind
|
|
23
|
+
from generic_ml_cache_core.application.port.outbound.adapter_catalog_port import AdapterCatalogPort
|
|
24
|
+
|
|
25
|
+
from generic_ml_cache_bootstrap.discovery._entrypoints import (
|
|
26
|
+
ADAPTER_CONTRACT_VERSION,
|
|
27
|
+
ADAPTER_ENTRYPOINT_GROUP,
|
|
28
|
+
describe_source,
|
|
29
|
+
distribution_name,
|
|
30
|
+
iter_entry_points,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Distributions whose plugins are trusted to load by default. The bundled
|
|
34
|
+
# adapters ship here; any other distribution declaring a gmlcache.adapters
|
|
35
|
+
# entry point is third-party and must be whitelisted by name to load (its module
|
|
36
|
+
# code runs at ep.load(), so the gate is BEFORE the load — ambient authority).
|
|
37
|
+
DEFAULT_TRUSTED_DISTRIBUTIONS = frozenset({"generic-ml-cache-adapters"})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class EntryPointAdapterCatalog(AdapterCatalogPort):
|
|
41
|
+
"""Catalog backed by the installed ``gmlcache.adapters`` entry points.
|
|
42
|
+
|
|
43
|
+
Trusted-distribution plugins always load. A third-party plugin loads only if
|
|
44
|
+
its entry-point name is in ``whitelist`` — otherwise it is never imported, so
|
|
45
|
+
a package installed for another reason cannot silently become a cache client.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
group: str = ADAPTER_ENTRYPOINT_GROUP,
|
|
51
|
+
*,
|
|
52
|
+
whitelist: frozenset[str] | None = None,
|
|
53
|
+
trusted_distributions: frozenset[str] = DEFAULT_TRUSTED_DISTRIBUTIONS,
|
|
54
|
+
) -> None:
|
|
55
|
+
self._group = group
|
|
56
|
+
self._whitelist = whitelist
|
|
57
|
+
self._trusted = trusted_distributions
|
|
58
|
+
self._descriptors: list[AdapterDescriptor] | None = None
|
|
59
|
+
self._sources: dict[str, str] = {}
|
|
60
|
+
# The vetted adapter classes, keyed by adapter_id — populated during the same
|
|
61
|
+
# gated scan that builds the descriptors, so the resolver can construct ONLY
|
|
62
|
+
# what this catalog already trusted, never re-loading an ungated plugin (X15).
|
|
63
|
+
self._classes: dict[str, type] = {}
|
|
64
|
+
|
|
65
|
+
def _is_allowed_to_load(self, entry_point: importlib.metadata.EntryPoint) -> bool:
|
|
66
|
+
"""Trust gate, evaluated BEFORE entry_point.load(): a trusted distribution
|
|
67
|
+
always loads; anything else only if its entry-point name is whitelisted."""
|
|
68
|
+
if distribution_name(entry_point) in self._trusted:
|
|
69
|
+
return True
|
|
70
|
+
return self._whitelist is not None and entry_point.name in self._whitelist
|
|
71
|
+
|
|
72
|
+
def _scan(self) -> list[AdapterDescriptor]:
|
|
73
|
+
descriptors: list[AdapterDescriptor] = []
|
|
74
|
+
for entry_point in iter_entry_points(self._group):
|
|
75
|
+
entry_point_name = entry_point.name
|
|
76
|
+
if not self._is_allowed_to_load(entry_point):
|
|
77
|
+
# Untrusted third-party, not whitelisted: never import it.
|
|
78
|
+
continue
|
|
79
|
+
try:
|
|
80
|
+
cls = entry_point.load()
|
|
81
|
+
except Exception as exc: # noqa: BLE001 — broken plugin must not crash discovery
|
|
82
|
+
warnings.warn(
|
|
83
|
+
f"gmlcache: could not load entry-point adapter {entry_point_name!r}: {exc}",
|
|
84
|
+
stacklevel=2,
|
|
85
|
+
)
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
declared = getattr(cls, "adapter_contract_version", None)
|
|
89
|
+
if declared is not None and declared != ADAPTER_CONTRACT_VERSION:
|
|
90
|
+
warnings.warn(
|
|
91
|
+
f"gmlcache: entry-point adapter {entry_point_name!r} targets contract {declared!r} "
|
|
92
|
+
f"but this core requires {ADAPTER_CONTRACT_VERSION!r}; skipping",
|
|
93
|
+
stacklevel=2,
|
|
94
|
+
)
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
describe = getattr(cls, "descriptor", None)
|
|
98
|
+
if not callable(describe):
|
|
99
|
+
warnings.warn(
|
|
100
|
+
f"gmlcache: entry-point adapter {entry_point_name!r} has no descriptor(); skipping",
|
|
101
|
+
stacklevel=2,
|
|
102
|
+
)
|
|
103
|
+
continue
|
|
104
|
+
try:
|
|
105
|
+
descriptor = cast(AdapterDescriptor, describe())
|
|
106
|
+
except Exception as exc: # noqa: BLE001
|
|
107
|
+
warnings.warn(
|
|
108
|
+
f"gmlcache: descriptor() failed for entry-point adapter {entry_point_name!r}: {exc}",
|
|
109
|
+
stacklevel=2,
|
|
110
|
+
)
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
if descriptor.adapter_id in self._classes:
|
|
114
|
+
# Two plugins claim the same adapter_id: ambiguous provenance. Reject
|
|
115
|
+
# deterministically rather than let iteration order pick a winner (X15)
|
|
116
|
+
# — a silent last-writer could let an untrusted plugin shadow a trusted
|
|
117
|
+
# one. Fail loud so the operator resolves the conflict.
|
|
118
|
+
raise ValueError(
|
|
119
|
+
f"gmlcache: duplicate adapter_id {descriptor.adapter_id!r} — provided by both "
|
|
120
|
+
f"{self._sources[descriptor.adapter_id]!r} and {describe_source(entry_point)!r}; "
|
|
121
|
+
"uninstall one of the conflicting plugins"
|
|
122
|
+
)
|
|
123
|
+
descriptors.append(descriptor)
|
|
124
|
+
self._sources[descriptor.adapter_id] = describe_source(entry_point)
|
|
125
|
+
self._classes[descriptor.adapter_id] = cls
|
|
126
|
+
return descriptors
|
|
127
|
+
|
|
128
|
+
def resolve_class(self, adapter_id: str) -> type | None:
|
|
129
|
+
"""The vetted class for ``adapter_id``, or None if this catalog does not trust
|
|
130
|
+
it. The resolver constructs from THIS — never by re-loading entry points — so
|
|
131
|
+
a plugin the catalog gated out is never imported at resolution time (X15)."""
|
|
132
|
+
self._ensure_loaded()
|
|
133
|
+
return self._classes.get(adapter_id)
|
|
134
|
+
|
|
135
|
+
def _ensure_loaded(self) -> list[AdapterDescriptor]:
|
|
136
|
+
if self._descriptors is None:
|
|
137
|
+
self._descriptors = self._scan()
|
|
138
|
+
return self._descriptors
|
|
139
|
+
|
|
140
|
+
def list_adapters(self) -> Sequence[AdapterDescriptor]:
|
|
141
|
+
return list(self._ensure_loaded())
|
|
142
|
+
|
|
143
|
+
def find_by_client_name(self, client_name: str) -> Sequence[AdapterDescriptor]:
|
|
144
|
+
return [
|
|
145
|
+
descriptor
|
|
146
|
+
for descriptor in self._ensure_loaded()
|
|
147
|
+
if descriptor.client_name == client_name
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
def supports(self, client_name: str, mode: ExecutionKind) -> bool:
|
|
151
|
+
return any(
|
|
152
|
+
descriptor.client_name == client_name and descriptor.supports_mode(mode)
|
|
153
|
+
for descriptor in self._ensure_loaded()
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def sources(self) -> dict[str, str]:
|
|
157
|
+
"""Map each ``adapter_id`` to the distribution that provided it (for doctor)."""
|
|
158
|
+
self._ensure_loaded()
|
|
159
|
+
return dict(self._sources)
|