bazaar-compute-node 0.1.3__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 (62) hide show
  1. bazaar_compute_node/__init__.py +3 -0
  2. bazaar_compute_node/app/__init__.py +1 -0
  3. bazaar_compute_node/app/application.py +398 -0
  4. bazaar_compute_node/app/attachments.py +154 -0
  5. bazaar_compute_node/app/command.py +342 -0
  6. bazaar_compute_node/app/config.py +121 -0
  7. bazaar_compute_node/app/registry.py +120 -0
  8. bazaar_compute_node/app/transport.py +264 -0
  9. bazaar_compute_node/app/windows_pipe.py +463 -0
  10. bazaar_compute_node/app/wrapper.py +63 -0
  11. bazaar_compute_node/bcc.py +524 -0
  12. bazaar_compute_node/cli.py +382 -0
  13. bazaar_compute_node/contrib/__init__.py +1 -0
  14. bazaar_compute_node/contrib/codex_app_server/__init__.py +63 -0
  15. bazaar_compute_node/contrib/codex_app_server/approval.py +168 -0
  16. bazaar_compute_node/contrib/codex_app_server/client.py +408 -0
  17. bazaar_compute_node/contrib/codex_app_server/events.py +431 -0
  18. bazaar_compute_node/contrib/codex_app_server/plugin.py +15 -0
  19. bazaar_compute_node/contrib/codex_app_server/process.py +583 -0
  20. bazaar_compute_node/contrib/codex_app_server/protocol.py +103 -0
  21. bazaar_compute_node/contrib/codex_app_server/runtime.py +513 -0
  22. bazaar_compute_node/contrib/logging/__init__.py +5 -0
  23. bazaar_compute_node/contrib/logging/audit.py +61 -0
  24. bazaar_compute_node/contrib/logging/plugin.py +11 -0
  25. bazaar_compute_node/contrib/sqlite/__init__.py +14 -0
  26. bazaar_compute_node/contrib/sqlite/codec.py +768 -0
  27. bazaar_compute_node/contrib/sqlite/database.py +282 -0
  28. bazaar_compute_node/contrib/sqlite/migrations.py +646 -0
  29. bazaar_compute_node/contrib/sqlite/plugin.py +11 -0
  30. bazaar_compute_node/contrib/sqlite/repository.py +1059 -0
  31. bazaar_compute_node/contrib/wecom/__init__.py +1 -0
  32. bazaar_compute_node/contrib/wecom/channel.py +960 -0
  33. bazaar_compute_node/contrib/wecom/markdown.py +146 -0
  34. bazaar_compute_node/contrib/wecom/plugin.py +29 -0
  35. bazaar_compute_node/core/__init__.py +5 -0
  36. bazaar_compute_node/core/approval.py +51 -0
  37. bazaar_compute_node/core/audit.py +101 -0
  38. bazaar_compute_node/core/channel.py +121 -0
  39. bazaar_compute_node/core/client.py +30 -0
  40. bazaar_compute_node/core/command.py +85 -0
  41. bazaar_compute_node/core/concurrency.py +29 -0
  42. bazaar_compute_node/core/correlation.py +48 -0
  43. bazaar_compute_node/core/instruction.py +224 -0
  44. bazaar_compute_node/core/lifecycle.py +48 -0
  45. bazaar_compute_node/core/models/__init__.py +63 -0
  46. bazaar_compute_node/core/models/entities.py +514 -0
  47. bazaar_compute_node/core/models/states.py +369 -0
  48. bazaar_compute_node/core/observability.py +47 -0
  49. bazaar_compute_node/core/orchestration/__init__.py +5 -0
  50. bazaar_compute_node/core/orchestration/command.py +614 -0
  51. bazaar_compute_node/core/orchestration/services.py +135 -0
  52. bazaar_compute_node/core/orchestration/session.py +891 -0
  53. bazaar_compute_node/core/orchestration/turn.py +451 -0
  54. bazaar_compute_node/core/outcomes.py +51 -0
  55. bazaar_compute_node/core/paths.py +19 -0
  56. bazaar_compute_node/core/runtime.py +118 -0
  57. bazaar_compute_node/core/storage.py +167 -0
  58. bazaar_compute_node-0.1.3.dist-info/METADATA +178 -0
  59. bazaar_compute_node-0.1.3.dist-info/RECORD +62 -0
  60. bazaar_compute_node-0.1.3.dist-info/WHEEL +4 -0
  61. bazaar_compute_node-0.1.3.dist-info/entry_points.txt +15 -0
  62. bazaar_compute_node-0.1.3.dist-info/licenses/LICENSE +613 -0
@@ -0,0 +1,342 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections.abc import Awaitable, Callable, Mapping
5
+ from datetime import UTC, datetime
6
+ from time import time_ns
7
+
8
+ from ..core.command import (
9
+ ICommandService,
10
+ SessionNotFoundError,
11
+ )
12
+ from ..core.lifecycle import TimeoutBudget
13
+ from ..core.models import InboundMessage, OutboundMessage
14
+
15
+
16
+ def serialize_inbound(message: InboundMessage) -> dict[str, object]:
17
+ return {
18
+ "seq": message.seq,
19
+ "message_id": message.message_id,
20
+ "session_id": message.session_id,
21
+ "channel_session_id": message.channel_session_id,
22
+ "channel": message.channel,
23
+ "received_at_ms": message.received_at_ms,
24
+ "provider_time_ms": message.provider_time_ms,
25
+ "sender": message.sender,
26
+ "message_type": message.message_type,
27
+ "canonical_target": message.canonical_target,
28
+ "target_kind": message.target_kind.value,
29
+ "mentions_agent": message.mentions_agent,
30
+ "notifies_runtime": message.notifies_runtime,
31
+ "attachments": [
32
+ {
33
+ "attachment_id": attachment.attachment_id,
34
+ "name": attachment.name,
35
+ "kind": attachment.kind,
36
+ "state": attachment.state,
37
+ "media_type": attachment.media_type,
38
+ "relative_path": attachment.relative_path,
39
+ "size_bytes": attachment.size_bytes,
40
+ "error": attachment.error,
41
+ }
42
+ for attachment in message.attachments
43
+ ],
44
+ "body": message.body,
45
+ "reply_to_message_id": message.reply_to_message_id,
46
+ }
47
+
48
+
49
+ def serialize_outbound(message: OutboundMessage) -> dict[str, object]:
50
+ return {
51
+ "outbound_message_id": message.outbound_message_id,
52
+ "command_id": message.command_id,
53
+ "session_id": message.session_id,
54
+ "channel_session_id": message.channel_session_id,
55
+ "target": message.target,
56
+ "reply_to_message_id": message.reply_to_message_id,
57
+ "body": message.body,
58
+ "state": message.state.value,
59
+ "fresh_check_state": message.fresh_check_state.value,
60
+ "created_at_ms": message.created_at_ms,
61
+ "snapshot_seq": message.snapshot_seq,
62
+ "current_inbound_seq": message.current_inbound_seq,
63
+ "provider_attempted_at_ms": message.provider_attempted_at_ms,
64
+ "completed_at_ms": message.completed_at_ms,
65
+ "draft_saved_at_ms": message.draft_saved_at_ms,
66
+ "error_kind": message.error_kind,
67
+ "error_message": message.error_message,
68
+ "next_action": message.next_action,
69
+ }
70
+
71
+
72
+ class CommandDispatchError(ValueError):
73
+ def __init__(
74
+ self,
75
+ code: str,
76
+ message: str,
77
+ *,
78
+ draft_saved: bool = False,
79
+ next_action: str | None = None,
80
+ ) -> None:
81
+ super().__init__(message)
82
+ self.code = code
83
+ self.message = message
84
+ self.draft_saved = draft_saved
85
+ self.next_action = next_action
86
+
87
+
88
+ ControlHandler = Callable[[Mapping[str, object]], Awaitable[Mapping[str, object]]]
89
+ SessionBindingValidator = Callable[[str, Mapping[str, object]], Awaitable[None]]
90
+
91
+
92
+ class CommandDispatcher:
93
+ """Translate local JSON requests into core command results."""
94
+
95
+ def __init__(
96
+ self,
97
+ service: ICommandService,
98
+ *,
99
+ timeout_budget: TimeoutBudget,
100
+ control_handler: ControlHandler | None = None,
101
+ session_binding_validator: SessionBindingValidator | None = None,
102
+ ) -> None:
103
+ self._service = service
104
+ self._timeout_budget = timeout_budget
105
+ self._control_handler = control_handler
106
+ self._session_binding_validator = session_binding_validator
107
+ self._accepting = False
108
+ self._in_flight: set[asyncio.Task[object]] = set()
109
+ self._drained = asyncio.Event()
110
+ self._drained.set()
111
+
112
+ @property
113
+ def accepting(self) -> bool:
114
+ return self._accepting
115
+
116
+ def start_accepting(self) -> None:
117
+ self._accepting = True
118
+ self._drained.clear()
119
+
120
+ def stop_accepting(self) -> None:
121
+ self._accepting = False
122
+ if not self._in_flight:
123
+ self._drained.set()
124
+
125
+ async def drain(self, *, timeout: float) -> None:
126
+ if timeout <= 0:
127
+ raise ValueError("timeout must be positive")
128
+ self.stop_accepting()
129
+ if not self._in_flight:
130
+ return
131
+ try:
132
+ await asyncio.wait_for(asyncio.shield(self._drained.wait()), timeout)
133
+ return
134
+ except TimeoutError:
135
+ pass
136
+ current_task = asyncio.current_task()
137
+ pending = tuple(
138
+ task
139
+ for task in self._in_flight
140
+ if task is not current_task and not task.done()
141
+ )
142
+ for task in pending:
143
+ task.cancel()
144
+ if pending:
145
+ await asyncio.gather(*pending, return_exceptions=True)
146
+
147
+ async def __call__(self, request: Mapping[str, object]) -> Mapping[str, object]:
148
+ if not self._accepting:
149
+ return {
150
+ "ok": False,
151
+ "code": "SERVICE_NOT_READY",
152
+ "error": "command service is not accepting requests",
153
+ }
154
+ current_task = asyncio.current_task()
155
+ if current_task is not None:
156
+ self._in_flight.add(current_task)
157
+ self._drained.clear()
158
+ try:
159
+ kind = request.get("kind")
160
+ if kind == "command":
161
+ async with asyncio.timeout(self._timeout_budget.command_seconds):
162
+ return await self._dispatch_command(request)
163
+ if kind == "control" and self._control_handler is not None:
164
+ result = await self._control_handler(request)
165
+ return {"ok": True, "result": dict(result)}
166
+ raise CommandDispatchError(
167
+ "INVALID_COMMAND", "request kind is not supported"
168
+ )
169
+ except asyncio.CancelledError:
170
+ raise
171
+ except CommandDispatchError as error:
172
+ response: dict[str, object] = {
173
+ "ok": False,
174
+ "code": error.code,
175
+ "error": error.message,
176
+ }
177
+ if error.draft_saved:
178
+ response["draft_saved"] = True
179
+ if error.next_action is not None:
180
+ response["next_action"] = error.next_action
181
+ return response
182
+ except SessionNotFoundError as error:
183
+ return {
184
+ "ok": False,
185
+ "code": "SESSION_NOT_FOUND",
186
+ "error": str(error),
187
+ }
188
+ except TimeoutError as error:
189
+ return {
190
+ "ok": False,
191
+ "code": "COMMAND_TIMEOUT",
192
+ "error": str(error) or "command timed out",
193
+ }
194
+ except ValueError as error:
195
+ return {
196
+ "ok": False,
197
+ "code": "INVALID_COMMAND",
198
+ "error": str(error),
199
+ }
200
+ except Exception as error: # noqa: BLE001
201
+ return {
202
+ "ok": False,
203
+ "code": "COMMAND_FAILED",
204
+ "error": str(error),
205
+ }
206
+ finally:
207
+ if current_task is not None:
208
+ self._in_flight.discard(current_task)
209
+ if not self._in_flight:
210
+ self._drained.set()
211
+
212
+ async def _dispatch_command(
213
+ self, request: Mapping[str, object]
214
+ ) -> Mapping[str, object]:
215
+ session_id = request.get("session_id")
216
+ if not isinstance(session_id, str) or not session_id:
217
+ raise CommandDispatchError(
218
+ "SESSION_REQUIRED", "session_id must be a non-empty string"
219
+ )
220
+ command = request.get("command")
221
+ if not isinstance(command, str) or not command:
222
+ raise CommandDispatchError(
223
+ "COMMAND_REQUIRED", "command must be a non-empty string"
224
+ )
225
+ if self._session_binding_validator is not None:
226
+ await self._session_binding_validator(session_id, request)
227
+
228
+ if command == "check":
229
+ result = await self._service.check(session_id)
230
+ return {
231
+ "ok": True,
232
+ "result": {
233
+ "messages": [
234
+ serialize_inbound(message) for message in result.messages
235
+ ],
236
+ "referenced_messages": [
237
+ serialize_inbound(message)
238
+ for message in result.referenced_messages
239
+ ],
240
+ "snapshot_seq": result.snapshot_seq,
241
+ "delivered_through_seq": result.delivered_through_seq,
242
+ },
243
+ }
244
+
245
+ if command == "read":
246
+ target = request.get("target")
247
+ if not isinstance(target, str) or not target:
248
+ raise CommandDispatchError(
249
+ "TARGET_REQUIRED", "target must be a non-empty string"
250
+ )
251
+ around_message_id = request.get("around_message_id")
252
+ if around_message_id is not None and not isinstance(around_message_id, str):
253
+ raise CommandDispatchError(
254
+ "INVALID_AROUND_MESSAGE", "around_message_id must be a string"
255
+ )
256
+ limit = request.get("limit", 100)
257
+ if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0:
258
+ raise CommandDispatchError(
259
+ "INVALID_LIMIT", "limit must be a positive integer"
260
+ )
261
+ result = await self._service.read(
262
+ session_id,
263
+ target=target,
264
+ around_message_id=around_message_id,
265
+ limit=limit,
266
+ )
267
+ return {
268
+ "ok": True,
269
+ "result": {
270
+ "messages": [
271
+ serialize_inbound(message) for message in result.messages
272
+ ],
273
+ "referenced_messages": [
274
+ serialize_inbound(message)
275
+ for message in result.referenced_messages
276
+ ],
277
+ "snapshot_seq": result.snapshot_seq,
278
+ "first_seq": result.first_seq,
279
+ "last_seq": result.last_seq,
280
+ },
281
+ }
282
+
283
+ if command == "send":
284
+ target = request.get("target")
285
+ body = request.get("body")
286
+ command_id = request.get("command_id")
287
+ reply_to_message_id = request.get("reply_to_message_id")
288
+ created_at_ms = request.get("created_at_ms", time_ns() // 1_000_000)
289
+ if not isinstance(target, str) or not target:
290
+ raise CommandDispatchError(
291
+ "TARGET_REQUIRED", "target must be a non-empty string"
292
+ )
293
+ if not isinstance(body, str):
294
+ raise CommandDispatchError("BODY_REQUIRED", "body must be text")
295
+ if not isinstance(command_id, str) or not command_id:
296
+ raise CommandDispatchError(
297
+ "COMMAND_ID_REQUIRED", "command_id must be a non-empty string"
298
+ )
299
+ if reply_to_message_id is not None and (
300
+ not isinstance(reply_to_message_id, str) or not reply_to_message_id
301
+ ):
302
+ raise CommandDispatchError(
303
+ "INVALID_REPLY_TO",
304
+ "reply_to_message_id must be a non-empty string",
305
+ )
306
+ if (
307
+ isinstance(created_at_ms, bool)
308
+ or not isinstance(created_at_ms, int)
309
+ or created_at_ms < 0
310
+ ):
311
+ raise CommandDispatchError(
312
+ "INVALID_CREATED_AT", "created_at_ms must be non-negative"
313
+ )
314
+ result = await self._service.send(
315
+ session_id=session_id,
316
+ command_id=command_id,
317
+ target=target,
318
+ body=body,
319
+ created_at_ms=created_at_ms,
320
+ reply_to_message_id=reply_to_message_id,
321
+ )
322
+ return {
323
+ "ok": True,
324
+ "result": {"outbound": serialize_outbound(result)},
325
+ }
326
+
327
+ if command == "unfollow":
328
+ target = request.get("target")
329
+ if not isinstance(target, str) or not target:
330
+ raise CommandDispatchError(
331
+ "TARGET_REQUIRED", "target must be a non-empty string"
332
+ )
333
+ changed = await self._service.unfollow(session_id, target=target)
334
+ return {"ok": True, "result": {"changed": changed}}
335
+
336
+ raise CommandDispatchError("UNKNOWN_COMMAND", f"unsupported command: {command}")
337
+
338
+
339
+ def format_message_time(timestamp_ms: int) -> str:
340
+ return datetime.fromtimestamp(timestamp_ms / 1000, tz=UTC).strftime(
341
+ "%Y-%m-%d %H:%M:%S"
342
+ )
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+
3
+ import tomllib
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from ..core.paths import resolve_data_dir
8
+ from ..core.runtime import RuntimeSandboxMode
9
+
10
+ CONFIG_FILENAME = "config.toml"
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class NodeConfiguration:
15
+ """Optional startup settings loaded from the node's persistent config."""
16
+
17
+ channel: str | None = None
18
+ runtime: str | None = None
19
+ storage: str | None = None
20
+ audit: str | None = None
21
+ endpoint: str | None = None
22
+ model: str | None = None
23
+ effort: str | None = None
24
+ sandbox_mode: RuntimeSandboxMode = RuntimeSandboxMode.WORKSPACE_WRITE
25
+ network_access: bool = True
26
+ runtime_env_include: tuple[str, ...] = ()
27
+ wecom_bot_id: str | None = None
28
+ wecom_websocket_url: str | None = None
29
+
30
+
31
+ class ConfigurationError(ValueError):
32
+ """Raised when the persistent bcn configuration is invalid."""
33
+
34
+
35
+ def resolve_config_path() -> Path:
36
+ return resolve_data_dir() / CONFIG_FILENAME
37
+
38
+
39
+ def load_node_configuration() -> NodeConfiguration:
40
+ path = resolve_config_path()
41
+ try:
42
+ with path.open("rb") as config_file:
43
+ payload = tomllib.load(config_file)
44
+ except FileNotFoundError:
45
+ return NodeConfiguration()
46
+ except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError) as error:
47
+ raise ConfigurationError(f"cannot read {path}: {error}") from error
48
+
49
+ node = payload.get("node", {})
50
+ if not isinstance(node, dict):
51
+ raise ConfigurationError("[node] must be a TOML table")
52
+ runtime = payload.get("runtime", {})
53
+ if not isinstance(runtime, dict):
54
+ raise ConfigurationError("[runtime] must be a TOML table")
55
+ runtime_env = runtime.get("env", {})
56
+ if not isinstance(runtime_env, dict):
57
+ raise ConfigurationError("[runtime.env] must be a TOML table")
58
+ channel = payload.get("channel", {})
59
+ if not isinstance(channel, dict):
60
+ raise ConfigurationError("[channel] must be a TOML table")
61
+ wecom = channel.get("wecom", {})
62
+ if not isinstance(wecom, dict):
63
+ raise ConfigurationError("[channel.wecom] must be a TOML table")
64
+ sandbox_mode = runtime.get("sandbox_mode", RuntimeSandboxMode.WORKSPACE_WRITE.value)
65
+ if not isinstance(sandbox_mode, str):
66
+ raise ConfigurationError("runtime.sandbox_mode must be text")
67
+ try:
68
+ parsed_sandbox_mode = RuntimeSandboxMode(sandbox_mode)
69
+ except ValueError as error:
70
+ allowed = ", ".join(mode.value for mode in RuntimeSandboxMode)
71
+ raise ConfigurationError(
72
+ f"runtime.sandbox_mode must be one of: {allowed}"
73
+ ) from error
74
+ network_access = runtime.get("network_access", True)
75
+ if not isinstance(network_access, bool):
76
+ raise ConfigurationError("runtime.network_access must be a boolean")
77
+ return NodeConfiguration(
78
+ channel=_optional_text(node.get("channel"), "node.channel"),
79
+ runtime=_optional_text(node.get("runtime"), "node.runtime"),
80
+ storage=_optional_text(node.get("storage"), "node.storage"),
81
+ audit=_optional_text(node.get("audit"), "node.audit"),
82
+ endpoint=_optional_text(node.get("endpoint"), "node.endpoint"),
83
+ model=_optional_text(runtime.get("model"), "runtime.model"),
84
+ effort=_optional_text(runtime.get("effort"), "runtime.effort"),
85
+ sandbox_mode=parsed_sandbox_mode,
86
+ network_access=network_access,
87
+ runtime_env_include=_text_list(
88
+ runtime_env.get("include", []), "runtime.env.include"
89
+ ),
90
+ wecom_bot_id=_optional_text(wecom.get("bot_id"), "channel.wecom.bot_id"),
91
+ wecom_websocket_url=_optional_text(
92
+ wecom.get("websocket_url"), "channel.wecom.websocket_url"
93
+ ),
94
+ )
95
+
96
+
97
+ def _optional_text(value: object, field_name: str) -> str | None:
98
+ if value is None:
99
+ return None
100
+ if not isinstance(value, str) or not value:
101
+ raise ConfigurationError(f"{field_name} must be non-empty text")
102
+ return value
103
+
104
+
105
+ def _text_list(value: object, field_name: str) -> tuple[str, ...]:
106
+ if not isinstance(value, list) or any(
107
+ not isinstance(item, str) or not item for item in value
108
+ ):
109
+ raise ConfigurationError(f"{field_name} must be an array of non-empty text")
110
+ if len(set(value)) != len(value):
111
+ raise ConfigurationError(f"{field_name} cannot contain duplicates")
112
+ return tuple(value)
113
+
114
+
115
+ __all__ = [
116
+ "CONFIG_FILENAME",
117
+ "ConfigurationError",
118
+ "NodeConfiguration",
119
+ "load_node_configuration",
120
+ "resolve_config_path",
121
+ ]
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Mapping
4
+ from dataclasses import dataclass
5
+ from importlib.metadata import EntryPoint, entry_points
6
+ from typing import Any, cast
7
+
8
+ from ..core.channel import ChannelContext, IChannel
9
+ from ..core.observability import IAudit
10
+ from ..core.runtime import IRuntime, RuntimeCommandContext
11
+ from ..core.storage import IStorage
12
+ from .command import ControlHandler
13
+
14
+ CHANNEL_ENTRY_POINT_GROUP = "bazaar_compute_node.channels"
15
+ RUNTIME_ENTRY_POINT_GROUP = "bazaar_compute_node.runtimes"
16
+ STORAGE_ENTRY_POINT_GROUP = "bazaar_compute_node.storages"
17
+ AUDIT_ENTRY_POINT_GROUP = "bazaar_compute_node.audits"
18
+ CONTROL_ENTRY_POINT_GROUP = "bazaar_compute_node.controls"
19
+
20
+ ChannelFactory = Callable[[ChannelContext], IChannel]
21
+ RuntimeFactory = Callable[[RuntimeCommandContext], IRuntime]
22
+ StorageFactory = Callable[[], IStorage]
23
+ AuditFactory = Callable[[], IAudit]
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class AdapterFactories:
28
+ channel: ChannelFactory
29
+ runtime: RuntimeFactory
30
+ storage: StorageFactory
31
+ audit: AuditFactory
32
+ control: Callable[[Mapping[str, object]], ControlHandler] | None = None
33
+
34
+
35
+ class ProviderLoadError(RuntimeError):
36
+ """A selected provider is missing or has an invalid entry point."""
37
+
38
+
39
+ class AdapterRegistry:
40
+ """Discover provider factories through Python package entry points."""
41
+
42
+ def load(
43
+ self,
44
+ *,
45
+ channel: str,
46
+ runtime: str,
47
+ storage: str = "sqlite",
48
+ audit: str = "logging",
49
+ ) -> AdapterFactories:
50
+ control = self._load_optional(
51
+ CONTROL_ENTRY_POINT_GROUP,
52
+ f"{channel}+{runtime}+{storage}",
53
+ )
54
+ return AdapterFactories(
55
+ channel=cast(
56
+ ChannelFactory,
57
+ self._load(CHANNEL_ENTRY_POINT_GROUP, channel),
58
+ ),
59
+ runtime=cast(
60
+ RuntimeFactory,
61
+ self._load(RUNTIME_ENTRY_POINT_GROUP, runtime),
62
+ ),
63
+ storage=cast(
64
+ StorageFactory,
65
+ self._load(STORAGE_ENTRY_POINT_GROUP, storage),
66
+ ),
67
+ audit=cast(
68
+ AuditFactory,
69
+ self._load(AUDIT_ENTRY_POINT_GROUP, audit),
70
+ ),
71
+ control=cast(
72
+ Callable[[Mapping[str, object]], ControlHandler] | None,
73
+ control,
74
+ ),
75
+ )
76
+
77
+ def _load(self, group: str, name: str) -> Any:
78
+ entry_point = self._find(group, name)
79
+ if entry_point is None:
80
+ raise ProviderLoadError(
81
+ f"provider '{name}' is not installed for entry point group '{group}'"
82
+ )
83
+ try:
84
+ factory = entry_point.load()
85
+ except Exception as error:
86
+ raise ProviderLoadError(
87
+ f"failed to load provider '{name}' from '{group}': {error}"
88
+ ) from error
89
+ if not callable(factory):
90
+ raise ProviderLoadError(f"provider '{name}' from '{group}' is not callable")
91
+ return factory
92
+
93
+ def _load_optional(self, group: str, name: str) -> Any | None:
94
+ entry_point = self._find(group, name)
95
+ if entry_point is None:
96
+ return None
97
+ try:
98
+ factory = entry_point.load()
99
+ except Exception as error:
100
+ raise ProviderLoadError(
101
+ f"failed to load optional provider '{name}' from '{group}': {error}"
102
+ ) from error
103
+ if not callable(factory):
104
+ raise ProviderLoadError(
105
+ f"optional provider '{name}' from '{group}' is not callable"
106
+ )
107
+ return factory
108
+
109
+ @staticmethod
110
+ def _find(group: str, name: str) -> EntryPoint | None:
111
+ if not name:
112
+ raise ProviderLoadError(f"provider name for '{group}' is empty")
113
+ return next(
114
+ (
115
+ candidate
116
+ for candidate in entry_points(group=group)
117
+ if candidate.name == name
118
+ ),
119
+ None,
120
+ )