induscode 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.
Files changed (167) hide show
  1. induscode/__init__.py +56 -0
  2. induscode/addons/__init__.py +176 -0
  3. induscode/addons/contract.py +923 -0
  4. induscode/addons/dispatch/__init__.py +43 -0
  5. induscode/addons/dispatch/event_dispatcher.py +348 -0
  6. induscode/addons/dispatch/tool_interceptor.py +349 -0
  7. induscode/addons/host.py +469 -0
  8. induscode/addons/loader.py +314 -0
  9. induscode/addons/manifest.py +232 -0
  10. induscode/addons/surface.py +199 -0
  11. induscode/boot/__init__.py +108 -0
  12. induscode/boot/auth_vault.py +323 -0
  13. induscode/boot/boot.py +210 -0
  14. induscode/boot/contract.py +223 -0
  15. induscode/boot/invocation.py +117 -0
  16. induscode/boot/runners/__init__.py +42 -0
  17. induscode/boot/runners/link_runner.py +82 -0
  18. induscode/boot/runners/oneshot_runner.py +85 -0
  19. induscode/boot/runners/registry.py +46 -0
  20. induscode/boot/runners/repl_runner.py +340 -0
  21. induscode/boot/runners/session.py +549 -0
  22. induscode/boot/stages.py +198 -0
  23. induscode/boot/upgrade/__init__.py +36 -0
  24. induscode/boot/upgrade/apply.py +125 -0
  25. induscode/boot/upgrade/upgrades.py +136 -0
  26. induscode/briefing/__init__.py +115 -0
  27. induscode/briefing/compose.py +414 -0
  28. induscode/briefing/contract.py +528 -0
  29. induscode/briefing/macros.py +721 -0
  30. induscode/briefing/skills.py +417 -0
  31. induscode/capability_deck/__init__.py +233 -0
  32. induscode/capability_deck/bridge_ledger/__init__.py +66 -0
  33. induscode/capability_deck/bridge_ledger/key.py +181 -0
  34. induscode/capability_deck/bridge_ledger/ledger.py +276 -0
  35. induscode/capability_deck/bridge_ledger/network.py +336 -0
  36. induscode/capability_deck/builtin_bridge.py +358 -0
  37. induscode/capability_deck/cards/__init__.py +116 -0
  38. induscode/capability_deck/cards/bg_process.py +482 -0
  39. induscode/capability_deck/cards/memory.py +226 -0
  40. induscode/capability_deck/cards/saas.py +280 -0
  41. induscode/capability_deck/cards/task.py +256 -0
  42. induscode/capability_deck/cards/todo.py +312 -0
  43. induscode/capability_deck/contract.py +450 -0
  44. induscode/capability_deck/manifest.py +126 -0
  45. induscode/capability_deck/provision.py +217 -0
  46. induscode/channels/__init__.py +146 -0
  47. induscode/channels/contract.py +585 -0
  48. induscode/channels/framer.py +132 -0
  49. induscode/channels/link/__init__.py +50 -0
  50. induscode/channels/link/dialog.py +246 -0
  51. induscode/channels/link/driver.py +308 -0
  52. induscode/channels/link/server.py +217 -0
  53. induscode/channels/oneshot.py +178 -0
  54. induscode/channels/ops.py +140 -0
  55. induscode/channels/session_ops.py +172 -0
  56. induscode/conductor/__init__.py +240 -0
  57. induscode/conductor/catalog.py +309 -0
  58. induscode/conductor/conductor.py +1084 -0
  59. induscode/conductor/contract.py +1035 -0
  60. induscode/conductor/matcher.py +291 -0
  61. induscode/conductor/serialize.py +575 -0
  62. induscode/conductor/signal_hub.py +382 -0
  63. induscode/conductor/skill_parse.py +294 -0
  64. induscode/conductor/transcript_store.py +449 -0
  65. induscode/console/__init__.py +236 -0
  66. induscode/console/app.py +1677 -0
  67. induscode/console/components/__init__.py +62 -0
  68. induscode/console/components/banner.py +499 -0
  69. induscode/console/components/banner_sweep.py +188 -0
  70. induscode/console/components/emblem.py +181 -0
  71. induscode/console/components/status_bar.py +102 -0
  72. induscode/console/contract.py +836 -0
  73. induscode/console/input/__init__.py +107 -0
  74. induscode/console/input/chord.py +197 -0
  75. induscode/console/input/dir_reader.py +113 -0
  76. induscode/console/input/intents.py +258 -0
  77. induscode/console/input/providers.py +469 -0
  78. induscode/console/mount.py +137 -0
  79. induscode/console/overlays/__init__.py +94 -0
  80. induscode/console/overlays/auth.py +503 -0
  81. induscode/console/overlays/pickers.py +526 -0
  82. induscode/console/overlays/router.py +129 -0
  83. induscode/console/overlays/sessions.py +232 -0
  84. induscode/console/reducer.py +145 -0
  85. induscode/console/resume_picker.py +156 -0
  86. induscode/console/slash_commands/__init__.py +78 -0
  87. induscode/console/slash_commands/builtins.py +254 -0
  88. induscode/console/slash_commands/dynamic.py +217 -0
  89. induscode/console/slash_commands/integrations.py +949 -0
  90. induscode/console/slash_commands/transcript.py +404 -0
  91. induscode/console/slash_commands/workbench.py +430 -0
  92. induscode/console/startup.py +434 -0
  93. induscode/console/theme/__init__.py +44 -0
  94. induscode/console/theme/adapter.py +168 -0
  95. induscode/console/theme/palette.py +128 -0
  96. induscode/console/theme/resolve.py +123 -0
  97. induscode/console/theme/tokens.py +185 -0
  98. induscode/console_slash/__init__.py +111 -0
  99. induscode/console_slash/contract.py +185 -0
  100. induscode/console_slash/registry.py +140 -0
  101. induscode/console_slash/resolve.py +194 -0
  102. induscode/console_slash/shared.py +172 -0
  103. induscode/entry.py +108 -0
  104. induscode/insight/__init__.py +153 -0
  105. induscode/insight/collector.py +73 -0
  106. induscode/insight/replay.py +305 -0
  107. induscode/insight/wrapper.py +1115 -0
  108. induscode/kit/__init__.py +82 -0
  109. induscode/kit/clipboard_image.py +215 -0
  110. induscode/kit/external_editor.py +120 -0
  111. induscode/kit/image.py +188 -0
  112. induscode/kit/shell.py +89 -0
  113. induscode/kit/tool_fetch.py +288 -0
  114. induscode/launch/__init__.py +224 -0
  115. induscode/launch/catalog.py +310 -0
  116. induscode/launch/contract.py +569 -0
  117. induscode/launch/credentials.py +852 -0
  118. induscode/launch/invocation/__init__.py +39 -0
  119. induscode/launch/invocation/attachments.py +281 -0
  120. induscode/launch/invocation/flags.py +210 -0
  121. induscode/launch/invocation/read.py +369 -0
  122. induscode/launch/invocation/usage.py +110 -0
  123. induscode/launch/oauth.py +808 -0
  124. induscode/launch/packages.py +299 -0
  125. induscode/launch/pickers.py +291 -0
  126. induscode/py.typed +0 -0
  127. induscode/runtime_bridge/__init__.py +166 -0
  128. induscode/runtime_bridge/bridges/__init__.py +66 -0
  129. induscode/runtime_bridge/bridges/_drive.py +268 -0
  130. induscode/runtime_bridge/bridges/builtins.py +177 -0
  131. induscode/runtime_bridge/bridges/claude_cli.py +198 -0
  132. induscode/runtime_bridge/bridges/codex_cli.py +203 -0
  133. induscode/runtime_bridge/bridges/indusagi_cli.py +217 -0
  134. induscode/runtime_bridge/broker.py +397 -0
  135. induscode/runtime_bridge/contract.py +734 -0
  136. induscode/runtime_bridge/sink.py +351 -0
  137. induscode/sessions/__init__.py +25 -0
  138. induscode/sessions/contract.py +119 -0
  139. induscode/sessions/library.py +350 -0
  140. induscode/settings/__init__.py +47 -0
  141. induscode/settings/contract.py +313 -0
  142. induscode/settings/manager.py +268 -0
  143. induscode/transcript_export/__init__.py +109 -0
  144. induscode/transcript_export/contract.py +522 -0
  145. induscode/transcript_export/publish.py +455 -0
  146. induscode/transcript_export/sgr.py +566 -0
  147. induscode/transcript_export/template.py +319 -0
  148. induscode/transcript_export/theme_bridge.py +325 -0
  149. induscode/window_budget/__init__.py +76 -0
  150. induscode/window_budget/budget/__init__.py +26 -0
  151. induscode/window_budget/budget/estimate.py +273 -0
  152. induscode/window_budget/budget/gate.py +60 -0
  153. induscode/window_budget/budget/slice.py +145 -0
  154. induscode/window_budget/condenser.py +170 -0
  155. induscode/window_budget/contract.py +329 -0
  156. induscode/window_budget/summarize/__init__.py +33 -0
  157. induscode/window_budget/summarize/condense.py +212 -0
  158. induscode/window_budget/summarize/prompt.py +241 -0
  159. induscode/workspace/__init__.py +30 -0
  160. induscode/workspace/brand.py +96 -0
  161. induscode/workspace/locator.py +269 -0
  162. induscode-0.1.0.dist-info/METADATA +97 -0
  163. induscode-0.1.0.dist-info/RECORD +167 -0
  164. induscode-0.1.0.dist-info/WHEEL +4 -0
  165. induscode-0.1.0.dist-info/entry_points.txt +3 -0
  166. induscode-0.1.0.dist-info/licenses/CREDITS.md +22 -0
  167. induscode-0.1.0.dist-info/licenses/NOTICE +7 -0
@@ -0,0 +1,469 @@
1
+ """AddonHost — the assembly point that turns a directory of addon modules
2
+ into a single wired runtime.
3
+
4
+ This is the top of the addons subsystem: it composes the injectable
5
+ :class:`ModuleLoader` (discovery + load), the per-addon :class:`AddonSurface`
6
+ recorder, the :class:`EventDispatcher` (observe / transform / gate), and the
7
+ :class:`InterceptorChain` (tool-boundary enter / exit) into one coherent
8
+ host. Its job is purely *folding*: read every addon's recorded
9
+ :class:`RegisteredManifest` back out and merge the four contribution streams
10
+ into one shared registry, then build the dispatch + interception runtimes
11
+ over the merged subscriptions and interceptors.
12
+
13
+ The load pipeline, per discovered source:
14
+
15
+ 1. **Load.** Resolve the source path through the injected loader into an
16
+ :class:`AddonManifest`. A load failure is isolated into an
17
+ :class:`AddonFault` (``kind="load"``) and the source is skipped — one
18
+ broken addon never aborts the others.
19
+ 2. **Register.** Mint a fresh surface scoped to the addon's id and the
20
+ host's :class:`FrameworkHandles`, then invoke the addon's ``register``
21
+ against it. A raise is isolated as a ``register`` fault and the addon
22
+ contributes nothing.
23
+ 3. **Fold.** Read the surface's :class:`RegisteredManifest` and merge its
24
+ subscriptions / interceptors / commands / tools into the host's growing
25
+ registry. Registration order is preserved across addons, so the
26
+ dispatcher's middleware order and the chain's onion order equal load
27
+ order. A command or tool whose name is already claimed — by another addon
28
+ or by a reserved host action — is rejected as a ``conflict`` fault and
29
+ dropped; the first claimant wins.
30
+
31
+ After :meth:`AddonHost.load_all` resolves, the host exposes (on the returned
32
+ :class:`AddonSurfaceBundle`): the bound dispatcher over every addon's
33
+ subscriptions, the interceptor chain over every addon's tool interceptors,
34
+ the collected conflict-resolved commands/tools, and the loaded addon ids.
35
+ :meth:`AddonHost.on_fault` is a single fault stream fanning out load /
36
+ register / conflict faults plus the runtime handler faults the dispatcher and
37
+ chain raise.
38
+
39
+ The loader stays **injectable**: it defaults to the importlib-backed
40
+ :func:`create_module_loader`, but a test injects a scripted fake that returns
41
+ manifests with no real import and no disk — the host code only ever sees the
42
+ :class:`ModuleLoader` Protocol.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import inspect
48
+ from collections.abc import Callable, Iterable, Sequence
49
+ from dataclasses import dataclass
50
+ from typing import Any
51
+
52
+ from .contract import (
53
+ AddonCommand,
54
+ AddonDiscovery,
55
+ AddonFault,
56
+ AddonFaultListener,
57
+ AddonId,
58
+ AddonManifest,
59
+ AddonSource,
60
+ AddonTool,
61
+ EventDispatcher,
62
+ EventSubscription,
63
+ FrameworkHandles,
64
+ InterceptorChain,
65
+ ModuleLoader,
66
+ RegisteredManifest,
67
+ ToolInterceptor,
68
+ addon_fault,
69
+ addon_id,
70
+ )
71
+ from .dispatch import AddonEventDispatcher, AddonInterceptorChain
72
+ from .loader import create_module_loader
73
+ from .manifest import discover_sources
74
+ from .surface import create_surface
75
+
76
+ __all__ = [
77
+ "AddonHost",
78
+ "AddonRegistry",
79
+ "AddonSurfaceBundle",
80
+ "create_addon_host",
81
+ ]
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Reserved host action names
86
+ # ---------------------------------------------------------------------------
87
+
88
+ #: Slash-command names the host reserves for its own built-in actions, so an
89
+ #: addon cannot shadow a core command by registering the same name.
90
+ #:
91
+ #: Data-sourced as a frozen set rather than scattered string checks: the
92
+ #: conflict guard consults this set when folding an addon's commands, and a
93
+ #: name in it is rejected with a ``conflict`` fault. Reserving a new core
94
+ #: action is a one-line edit here.
95
+ _RESERVED_ACTION_NAMES: frozenset[str] = frozenset(
96
+ {
97
+ "help",
98
+ "quit",
99
+ "exit",
100
+ "clear",
101
+ "model",
102
+ "compact",
103
+ }
104
+ )
105
+
106
+
107
+ async def _maybe_await(value: Any) -> Any:
108
+ """Resolve a ``register`` return that may be a plain value or an awaitable
109
+ — the Python analogue of TS ``await`` over ``void | Promise<void>``."""
110
+ if inspect.isawaitable(value):
111
+ return await value
112
+ return value
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Host registry & result
117
+ # ---------------------------------------------------------------------------
118
+
119
+
120
+ @dataclass(frozen=True, slots=True, kw_only=True)
121
+ class AddonRegistry:
122
+ """The merged, conflict-resolved registry the host accumulates while
123
+ folding every addon's :class:`RegisteredManifest`.
124
+
125
+ One flat tuple per contribution stream, in load order. The host builds
126
+ the dispatch and interception runtimes from :attr:`subscriptions` and
127
+ :attr:`interceptors`; :attr:`commands` and :attr:`tools` are the
128
+ collected capabilities, already de-duped against earlier claimants and
129
+ the reserved set.
130
+ """
131
+
132
+ # Every addon's event subscriptions, in load (registration) order.
133
+ subscriptions: tuple[EventSubscription, ...]
134
+ # Every addon's tool interceptors, in load order.
135
+ interceptors: tuple[ToolInterceptor, ...]
136
+ # The collected, name-deduped slash commands.
137
+ commands: tuple[AddonCommand, ...]
138
+ # The collected, name-deduped contributed tools.
139
+ tools: tuple[AddonTool, ...]
140
+
141
+
142
+ @dataclass(frozen=True, slots=True, kw_only=True)
143
+ class AddonSurfaceBundle:
144
+ """The fully-wired addon runtime :meth:`AddonHost.load_all` produces.
145
+
146
+ Exposes the dispatch engine, the interceptor chain, the collected
147
+ capabilities, and the successfully-loaded addon ids (for
148
+ diagnostics/listing). Consumers wire :attr:`dispatch` into the agent's
149
+ event points, :attr:`interceptors` into the tool boundary, and surface
150
+ :attr:`commands` / :attr:`tools` into the session. Both runtimes are
151
+ wired to the originating host's single fault stream.
152
+ """
153
+
154
+ # The event dispatcher over every loaded addon's subscriptions.
155
+ dispatch: EventDispatcher
156
+ # The tool-boundary chain over every loaded addon's interceptors.
157
+ interceptors: InterceptorChain
158
+ # The collected, conflict-resolved slash commands.
159
+ commands: tuple[AddonCommand, ...]
160
+ # The collected, conflict-resolved contributed tools.
161
+ tools: tuple[AddonTool, ...]
162
+ # The ids of the addons that loaded and registered successfully, in order.
163
+ loaded: tuple[AddonId, ...]
164
+
165
+
166
+ # ---------------------------------------------------------------------------
167
+ # The host
168
+ # ---------------------------------------------------------------------------
169
+
170
+
171
+ class AddonHost:
172
+ """The addon host: discovers, loads, registers, and folds addons into a
173
+ single wired runtime.
174
+
175
+ Build one with :func:`create_addon_host` (defaults wire the live
176
+ runtime). Call :meth:`load_all` once with a workspace path (or pass a
177
+ full :class:`AddonDiscovery`) to populate the host, then read the
178
+ returned bundle's ``dispatch``, ``interceptors``, ``commands``, and
179
+ ``tools``. Faults raised during loading — and the runtime handler faults
180
+ the dispatcher/chain raise — fan out to :meth:`on_fault` listeners.
181
+
182
+ Every collaborator is injectable with a live default, so production code
183
+ constructs the host with nothing (or just ``handles``) and a test
184
+ overrides exactly the seam it needs — almost always ``loader`` with a
185
+ scripted fake.
186
+ """
187
+
188
+ def __init__(
189
+ self,
190
+ *,
191
+ loader: ModuleLoader | None = None,
192
+ handles: FrameworkHandles | None = None,
193
+ dispatcher: Callable[[Sequence[EventSubscription]], EventDispatcher] | None = None,
194
+ chain: Callable[[Sequence[ToolInterceptor]], InterceptorChain] | None = None,
195
+ reserved_actions: Iterable[str] | None = None,
196
+ ) -> None:
197
+ """
198
+ :param loader: the module loader turning a source path into an
199
+ :class:`AddonManifest`; defaults to the importlib-backed
200
+ :func:`create_module_loader` — a test injects a fake
201
+ :param handles: the framework handles threaded into every addon's
202
+ surface and command context; defaults to an empty bag (a
203
+ non-interactive run supplies fewer than an interactive TUI)
204
+ :param dispatcher: override how the :class:`EventDispatcher` is built
205
+ from the folded subscriptions; defaults to
206
+ :meth:`AddonEventDispatcher.from_subscriptions`
207
+ :param chain: override how the :class:`InterceptorChain` is built from
208
+ the folded interceptors; defaults to
209
+ :meth:`AddonInterceptorChain.from_interceptors`
210
+ :param reserved_actions: the reserved core action names addon commands
211
+ may not shadow; defaults to the host's frozen set
212
+ """
213
+ self._loader: ModuleLoader = loader if loader is not None else create_module_loader()
214
+ self._handles: FrameworkHandles = (
215
+ handles if handles is not None else FrameworkHandles()
216
+ )
217
+ self._build_dispatcher = (
218
+ dispatcher if dispatcher is not None else AddonEventDispatcher.from_subscriptions
219
+ )
220
+ self._build_chain = (
221
+ chain if chain is not None else AddonInterceptorChain.from_interceptors
222
+ )
223
+ # The reserved core action names addon commands may not shadow.
224
+ self._reserved: frozenset[str] = (
225
+ _RESERVED_ACTION_NAMES
226
+ if reserved_actions is None
227
+ else frozenset(reserved_actions)
228
+ )
229
+
230
+ # Live fault listeners, in registration order (dict = ordered set).
231
+ self._fault_listeners: dict[AddonFaultListener, None] = {}
232
+ # Accumulated subscriptions across every folded addon, in load order.
233
+ self._subscriptions: list[EventSubscription] = []
234
+ # Accumulated interceptors across every folded addon, in load order.
235
+ self._interceptors: list[ToolInterceptor] = []
236
+ # Accumulated, name-deduped commands.
237
+ self._commands: list[AddonCommand] = []
238
+ # Accumulated, name-deduped tools.
239
+ self._tools: list[AddonTool] = []
240
+ # The command names already claimed (reserved + earlier addon commands).
241
+ self._claimed_commands: set[str] = set(self._reserved)
242
+ # The tool names already claimed by earlier addons.
243
+ self._claimed_tools: set[str] = set()
244
+ # The ids of addons that loaded and registered successfully, in order.
245
+ self._loaded: list[AddonId] = []
246
+
247
+ def on_fault(self, listener: AddonFaultListener) -> Callable[[], None]:
248
+ """Register a listener for every :class:`AddonFault` the host raises —
249
+ load, register, and conflict faults during folding, plus the runtime
250
+ handler faults the built dispatcher and chain route. Returns an
251
+ idempotent unsubscribe.
252
+
253
+ Registering before :meth:`load_all` captures load-time faults; the
254
+ dispatcher and chain built by :meth:`load_all` are wired to this same
255
+ sink so a later dispatch fault reaches the same listeners.
256
+
257
+ :param listener: the fault sink
258
+ """
259
+ self._fault_listeners[listener] = None
260
+ active = True
261
+
262
+ def unsubscribe() -> None:
263
+ nonlocal active
264
+ if not active:
265
+ return
266
+ active = False
267
+ self._fault_listeners.pop(listener, None)
268
+
269
+ return unsubscribe
270
+
271
+ async def load_all(self, where: str | AddonDiscovery) -> AddonSurfaceBundle:
272
+ """Discover, load, register, and fold every addon under a directory
273
+ (or a full :class:`AddonDiscovery` config), then build and return the
274
+ wired runtime.
275
+
276
+ A bare string is treated as the workspace whose ``.indus/addons``
277
+ directory is scanned; an :class:`AddonDiscovery` is passed through to
278
+ :func:`discover_sources` unchanged. Each discovered source is loaded
279
+ through the injected loader and folded; failures are isolated into
280
+ faults. The returned :class:`AddonSurfaceBundle` exposes the dispatch
281
+ engine, the interceptor chain, and the collected capabilities — all
282
+ wired to this host's fault stream.
283
+
284
+ :param where: a workspace path, or a full discovery config
285
+ """
286
+ discovery = AddonDiscovery(workspace=where) if isinstance(where, str) else where
287
+ for source in discover_sources(discovery):
288
+ await self.load_one(source)
289
+ return self._bundle()
290
+
291
+ async def load_one(self, source: AddonSource) -> None:
292
+ """Load, register, and fold a single already-discovered
293
+ :class:`AddonSource`.
294
+
295
+ Public so a host can graft an explicitly-resolved addon (e.g. an
296
+ always-on bundled addon) without going through directory discovery.
297
+ Isolates load and register failures into faults and folds whatever
298
+ the addon recorded.
299
+
300
+ :param source: the discovered source (id + entry path)
301
+ """
302
+ module: AddonManifest
303
+ try:
304
+ module = await self._loader.load(source.path)
305
+ except Exception as cause:
306
+ self._report(
307
+ addon_fault(
308
+ "load",
309
+ f"Failed to load addon at {source.path}.",
310
+ addon=source.id,
311
+ cause=cause,
312
+ )
313
+ )
314
+ return
315
+
316
+ declared_id = getattr(module, "id", None)
317
+ ident = addon_id(declared_id) if isinstance(declared_id, str) else source.id
318
+ declared_version = getattr(module, "version", None)
319
+ version = declared_version if isinstance(declared_version, str) else None
320
+
321
+ surface = create_surface(ident, self._handles, version)
322
+ try:
323
+ await _maybe_await(module.register(surface))
324
+ except Exception as cause:
325
+ self._report(
326
+ addon_fault(
327
+ "register",
328
+ f'Addon "{ident}" threw during register().',
329
+ addon=ident,
330
+ cause=cause,
331
+ )
332
+ )
333
+ return
334
+
335
+ self._fold(ident, surface.manifest())
336
+ self._loaded.append(ident)
337
+
338
+ @property
339
+ def registry(self) -> AddonRegistry:
340
+ """The merged, conflict-resolved registry accumulated so far."""
341
+ return AddonRegistry(
342
+ subscriptions=tuple(self._subscriptions),
343
+ interceptors=tuple(self._interceptors),
344
+ commands=tuple(self._commands),
345
+ tools=tuple(self._tools),
346
+ )
347
+
348
+ def _bundle(self) -> AddonSurfaceBundle:
349
+ """Build the :class:`AddonSurfaceBundle` from the current accumulated
350
+ registry.
351
+
352
+ The dispatcher and chain are constructed via the injected factories
353
+ over the folded subscriptions/interceptors and wired to this host's
354
+ fault stream, so runtime handler faults reach the same listeners as
355
+ load-time faults.
356
+ """
357
+ dispatch = self._build_dispatcher(tuple(self._subscriptions))
358
+ interceptors = self._build_chain(tuple(self._interceptors))
359
+ self._wire_faults(dispatch)
360
+ self._wire_faults(interceptors)
361
+ return AddonSurfaceBundle(
362
+ dispatch=dispatch,
363
+ interceptors=interceptors,
364
+ commands=tuple(self._commands),
365
+ tools=tuple(self._tools),
366
+ loaded=tuple(self._loaded),
367
+ )
368
+
369
+ def _fold(self, addon: AddonId, manifest: RegisteredManifest) -> None:
370
+ """Fold one addon's recorded :class:`RegisteredManifest` into the host
371
+ registry.
372
+
373
+ Subscriptions and interceptors are appended unconditionally (order =
374
+ load order). Commands and tools are name-checked against the claimed
375
+ sets: a name already taken (by the reserved set or an earlier addon)
376
+ is rejected as a ``conflict`` fault and dropped, so the first
377
+ claimant wins.
378
+ """
379
+ self._subscriptions.extend(manifest.subscriptions)
380
+ self._interceptors.extend(manifest.interceptors)
381
+ for command in manifest.commands:
382
+ if command.name in self._claimed_commands:
383
+ self._report(
384
+ addon_fault(
385
+ "conflict",
386
+ (
387
+ f'Addon "{addon}" command "{command.name}" is a reserved action name.'
388
+ if command.name in self._reserved
389
+ else f'Addon "{addon}" command "{command.name}" conflicts with an already-registered command.'
390
+ ),
391
+ addon=addon,
392
+ )
393
+ )
394
+ continue
395
+ self._claimed_commands.add(command.name)
396
+ self._commands.append(command)
397
+ for tool in manifest.tools:
398
+ if tool.name in self._claimed_tools:
399
+ self._report(
400
+ addon_fault(
401
+ "conflict",
402
+ f'Addon "{addon}" tool "{tool.name}" conflicts with an already-registered tool.',
403
+ addon=addon,
404
+ )
405
+ )
406
+ continue
407
+ self._claimed_tools.add(tool.name)
408
+ self._tools.append(tool)
409
+
410
+ def _wire_faults(self, runtime: object) -> None:
411
+ """Forward a built runtime's fault stream into the host's listeners,
412
+ when the runtime exposes one.
413
+
414
+ The contract's :class:`EventDispatcher` always declares ``on_fault``;
415
+ the :class:`InterceptorChain` Protocol does not, though the default
416
+ :class:`AddonInterceptorChain` provides it. This probes for the
417
+ method so an injected dispatcher/chain that omits a fault stream is
418
+ simply not wired, rather than failing the host.
419
+ """
420
+ on_fault = getattr(runtime, "on_fault", None)
421
+ if callable(on_fault):
422
+ on_fault(self._report)
423
+
424
+ def _report(self, fault: AddonFault) -> None:
425
+ """Route a fault to every listener, guarding each listener against its
426
+ own raise."""
427
+ for listener in list(self._fault_listeners):
428
+ try:
429
+ listener(fault)
430
+ except Exception:
431
+ # A throwing fault listener must not break fan-out to the others.
432
+ pass
433
+
434
+
435
+ # ---------------------------------------------------------------------------
436
+ # Construction
437
+ # ---------------------------------------------------------------------------
438
+
439
+
440
+ def create_addon_host(
441
+ *,
442
+ loader: ModuleLoader | None = None,
443
+ handles: FrameworkHandles | None = None,
444
+ dispatcher: Callable[[Sequence[EventSubscription]], EventDispatcher] | None = None,
445
+ chain: Callable[[Sequence[ToolInterceptor]], InterceptorChain] | None = None,
446
+ reserved_actions: Iterable[str] | None = None,
447
+ ) -> AddonHost:
448
+ """Construct an :class:`AddonHost` with the given (all-optional)
449
+ dependencies.
450
+
451
+ The single sanctioned entry point: production code calls it with nothing
452
+ (or just ``handles``) and gets the importlib-backed loader and the
453
+ default dispatcher/chain factories; a test injects a fake
454
+ :class:`ModuleLoader` to exercise the full fold-and-wire pipeline with no
455
+ real import or disk.
456
+
457
+ :param loader: module loader override (defaults to importlib-backed)
458
+ :param handles: framework handles threaded into every addon surface
459
+ :param dispatcher: dispatcher factory override
460
+ :param chain: interceptor-chain factory override
461
+ :param reserved_actions: reserved core action names override
462
+ """
463
+ return AddonHost(
464
+ loader=loader,
465
+ handles=handles,
466
+ dispatcher=dispatcher,
467
+ chain=chain,
468
+ reserved_actions=reserved_actions,
469
+ )