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.
- induscode/__init__.py +56 -0
- induscode/addons/__init__.py +176 -0
- induscode/addons/contract.py +923 -0
- induscode/addons/dispatch/__init__.py +43 -0
- induscode/addons/dispatch/event_dispatcher.py +348 -0
- induscode/addons/dispatch/tool_interceptor.py +349 -0
- induscode/addons/host.py +469 -0
- induscode/addons/loader.py +314 -0
- induscode/addons/manifest.py +232 -0
- induscode/addons/surface.py +199 -0
- induscode/boot/__init__.py +108 -0
- induscode/boot/auth_vault.py +323 -0
- induscode/boot/boot.py +210 -0
- induscode/boot/contract.py +223 -0
- induscode/boot/invocation.py +117 -0
- induscode/boot/runners/__init__.py +42 -0
- induscode/boot/runners/link_runner.py +82 -0
- induscode/boot/runners/oneshot_runner.py +85 -0
- induscode/boot/runners/registry.py +46 -0
- induscode/boot/runners/repl_runner.py +340 -0
- induscode/boot/runners/session.py +549 -0
- induscode/boot/stages.py +198 -0
- induscode/boot/upgrade/__init__.py +36 -0
- induscode/boot/upgrade/apply.py +125 -0
- induscode/boot/upgrade/upgrades.py +136 -0
- induscode/briefing/__init__.py +115 -0
- induscode/briefing/compose.py +414 -0
- induscode/briefing/contract.py +528 -0
- induscode/briefing/macros.py +721 -0
- induscode/briefing/skills.py +417 -0
- induscode/capability_deck/__init__.py +233 -0
- induscode/capability_deck/bridge_ledger/__init__.py +66 -0
- induscode/capability_deck/bridge_ledger/key.py +181 -0
- induscode/capability_deck/bridge_ledger/ledger.py +276 -0
- induscode/capability_deck/bridge_ledger/network.py +336 -0
- induscode/capability_deck/builtin_bridge.py +358 -0
- induscode/capability_deck/cards/__init__.py +116 -0
- induscode/capability_deck/cards/bg_process.py +482 -0
- induscode/capability_deck/cards/memory.py +226 -0
- induscode/capability_deck/cards/saas.py +280 -0
- induscode/capability_deck/cards/task.py +256 -0
- induscode/capability_deck/cards/todo.py +312 -0
- induscode/capability_deck/contract.py +450 -0
- induscode/capability_deck/manifest.py +126 -0
- induscode/capability_deck/provision.py +217 -0
- induscode/channels/__init__.py +146 -0
- induscode/channels/contract.py +585 -0
- induscode/channels/framer.py +132 -0
- induscode/channels/link/__init__.py +50 -0
- induscode/channels/link/dialog.py +246 -0
- induscode/channels/link/driver.py +308 -0
- induscode/channels/link/server.py +217 -0
- induscode/channels/oneshot.py +178 -0
- induscode/channels/ops.py +140 -0
- induscode/channels/session_ops.py +172 -0
- induscode/conductor/__init__.py +240 -0
- induscode/conductor/catalog.py +309 -0
- induscode/conductor/conductor.py +1084 -0
- induscode/conductor/contract.py +1035 -0
- induscode/conductor/matcher.py +291 -0
- induscode/conductor/serialize.py +575 -0
- induscode/conductor/signal_hub.py +382 -0
- induscode/conductor/skill_parse.py +294 -0
- induscode/conductor/transcript_store.py +449 -0
- induscode/console/__init__.py +236 -0
- induscode/console/app.py +1677 -0
- induscode/console/components/__init__.py +62 -0
- induscode/console/components/banner.py +499 -0
- induscode/console/components/banner_sweep.py +188 -0
- induscode/console/components/emblem.py +181 -0
- induscode/console/components/status_bar.py +102 -0
- induscode/console/contract.py +836 -0
- induscode/console/input/__init__.py +107 -0
- induscode/console/input/chord.py +197 -0
- induscode/console/input/dir_reader.py +113 -0
- induscode/console/input/intents.py +258 -0
- induscode/console/input/providers.py +469 -0
- induscode/console/mount.py +137 -0
- induscode/console/overlays/__init__.py +94 -0
- induscode/console/overlays/auth.py +503 -0
- induscode/console/overlays/pickers.py +526 -0
- induscode/console/overlays/router.py +129 -0
- induscode/console/overlays/sessions.py +232 -0
- induscode/console/reducer.py +145 -0
- induscode/console/resume_picker.py +156 -0
- induscode/console/slash_commands/__init__.py +78 -0
- induscode/console/slash_commands/builtins.py +254 -0
- induscode/console/slash_commands/dynamic.py +217 -0
- induscode/console/slash_commands/integrations.py +949 -0
- induscode/console/slash_commands/transcript.py +404 -0
- induscode/console/slash_commands/workbench.py +430 -0
- induscode/console/startup.py +434 -0
- induscode/console/theme/__init__.py +44 -0
- induscode/console/theme/adapter.py +168 -0
- induscode/console/theme/palette.py +128 -0
- induscode/console/theme/resolve.py +123 -0
- induscode/console/theme/tokens.py +185 -0
- induscode/console_slash/__init__.py +111 -0
- induscode/console_slash/contract.py +185 -0
- induscode/console_slash/registry.py +140 -0
- induscode/console_slash/resolve.py +194 -0
- induscode/console_slash/shared.py +172 -0
- induscode/entry.py +108 -0
- induscode/insight/__init__.py +153 -0
- induscode/insight/collector.py +73 -0
- induscode/insight/replay.py +305 -0
- induscode/insight/wrapper.py +1115 -0
- induscode/kit/__init__.py +82 -0
- induscode/kit/clipboard_image.py +215 -0
- induscode/kit/external_editor.py +120 -0
- induscode/kit/image.py +188 -0
- induscode/kit/shell.py +89 -0
- induscode/kit/tool_fetch.py +288 -0
- induscode/launch/__init__.py +224 -0
- induscode/launch/catalog.py +310 -0
- induscode/launch/contract.py +569 -0
- induscode/launch/credentials.py +852 -0
- induscode/launch/invocation/__init__.py +39 -0
- induscode/launch/invocation/attachments.py +281 -0
- induscode/launch/invocation/flags.py +210 -0
- induscode/launch/invocation/read.py +369 -0
- induscode/launch/invocation/usage.py +110 -0
- induscode/launch/oauth.py +808 -0
- induscode/launch/packages.py +299 -0
- induscode/launch/pickers.py +291 -0
- induscode/py.typed +0 -0
- induscode/runtime_bridge/__init__.py +166 -0
- induscode/runtime_bridge/bridges/__init__.py +66 -0
- induscode/runtime_bridge/bridges/_drive.py +268 -0
- induscode/runtime_bridge/bridges/builtins.py +177 -0
- induscode/runtime_bridge/bridges/claude_cli.py +198 -0
- induscode/runtime_bridge/bridges/codex_cli.py +203 -0
- induscode/runtime_bridge/bridges/indusagi_cli.py +217 -0
- induscode/runtime_bridge/broker.py +397 -0
- induscode/runtime_bridge/contract.py +734 -0
- induscode/runtime_bridge/sink.py +351 -0
- induscode/sessions/__init__.py +25 -0
- induscode/sessions/contract.py +119 -0
- induscode/sessions/library.py +350 -0
- induscode/settings/__init__.py +47 -0
- induscode/settings/contract.py +313 -0
- induscode/settings/manager.py +268 -0
- induscode/transcript_export/__init__.py +109 -0
- induscode/transcript_export/contract.py +522 -0
- induscode/transcript_export/publish.py +455 -0
- induscode/transcript_export/sgr.py +566 -0
- induscode/transcript_export/template.py +319 -0
- induscode/transcript_export/theme_bridge.py +325 -0
- induscode/window_budget/__init__.py +76 -0
- induscode/window_budget/budget/__init__.py +26 -0
- induscode/window_budget/budget/estimate.py +273 -0
- induscode/window_budget/budget/gate.py +60 -0
- induscode/window_budget/budget/slice.py +145 -0
- induscode/window_budget/condenser.py +170 -0
- induscode/window_budget/contract.py +329 -0
- induscode/window_budget/summarize/__init__.py +33 -0
- induscode/window_budget/summarize/condense.py +212 -0
- induscode/window_budget/summarize/prompt.py +241 -0
- induscode/workspace/__init__.py +30 -0
- induscode/workspace/brand.py +96 -0
- induscode/workspace/locator.py +269 -0
- induscode-0.1.0.dist-info/METADATA +97 -0
- induscode-0.1.0.dist-info/RECORD +167 -0
- induscode-0.1.0.dist-info/WHEEL +4 -0
- induscode-0.1.0.dist-info/entry_points.txt +3 -0
- induscode-0.1.0.dist-info/licenses/CREDITS.md +22 -0
- induscode-0.1.0.dist-info/licenses/NOTICE +7 -0
induscode/addons/host.py
ADDED
|
@@ -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
|
+
)
|