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.
Files changed (25) hide show
  1. generic_ml_cache_bootstrap/__init__.py +26 -0
  2. generic_ml_cache_bootstrap/application.py +274 -0
  3. generic_ml_cache_bootstrap/diagnostics.py +38 -0
  4. generic_ml_cache_bootstrap/discovery/__init__.py +8 -0
  5. generic_ml_cache_bootstrap/discovery/_entrypoints.py +45 -0
  6. generic_ml_cache_bootstrap/discovery/api_discover.py +61 -0
  7. generic_ml_cache_bootstrap/discovery/client_discover.py +274 -0
  8. generic_ml_cache_bootstrap/discovery/composite_adapter_catalog.py +38 -0
  9. generic_ml_cache_bootstrap/discovery/composite_adapter_resolver.py +51 -0
  10. generic_ml_cache_bootstrap/discovery/composition.py +148 -0
  11. generic_ml_cache_bootstrap/discovery/entrypoint_adapter_catalog.py +159 -0
  12. generic_ml_cache_bootstrap/discovery/entrypoint_adapter_resolver.py +54 -0
  13. generic_ml_cache_bootstrap/discovery/in_memory_adapter_registry.py +97 -0
  14. generic_ml_cache_bootstrap/discovery/static_adapter_catalog.py +36 -0
  15. generic_ml_cache_bootstrap/encryption.py +82 -0
  16. generic_ml_cache_bootstrap/persistence_backend.py +129 -0
  17. generic_ml_cache_bootstrap/progress.py +27 -0
  18. generic_ml_cache_bootstrap/py.typed +0 -0
  19. generic_ml_cache_bootstrap/store_status.py +40 -0
  20. generic_ml_cache_bootstrap/stub_runners.py +34 -0
  21. generic_ml_cache_bootstrap-0.29.0.dist-info/METADATA +74 -0
  22. generic_ml_cache_bootstrap-0.29.0.dist-info/RECORD +25 -0
  23. generic_ml_cache_bootstrap-0.29.0.dist-info/WHEEL +4 -0
  24. generic_ml_cache_bootstrap-0.29.0.dist-info/licenses/LICENSE +201 -0
  25. 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)