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,43 @@
1
+ """Dispatch subsystem — public barrel.
2
+
3
+ The unified hook + tool-interception runtime, written against the frozen
4
+ ``induscode.addons.contract`` surface:
5
+
6
+ - :class:`AddonEventDispatcher` — the single fan-out / transform / veto
7
+ engine for the colon-named :data:`HookEvent` taxonomy. Folds every addon's
8
+ :class:`EventSubscription` records into one ordered middleware pass
9
+ (observe / transform / gate), short-circuits on the first veto, and derives
10
+ its reserved (gate-bearing) event set from a data table
11
+ (:data:`EVENT_TRAITS`) rather than a literal.
12
+ - :class:`AddonInterceptorChain` — the composable tool-call boundary. Folds
13
+ every addon's :class:`ToolInterceptor` stages around one tool execution
14
+ (enter forward, exit reverse), rewriting args/results or blocking the call,
15
+ replacing the old tool wrapper plus before/after hook runner.
16
+
17
+ Both isolate handler/stage faults into :class:`AddonFault` records routed to
18
+ listeners so a misbehaving addon never crashes the agent loop. The
19
+ :func:`subscription` and :func:`interceptor` helpers mint the contract's
20
+ recorded shapes for hosts/tests that assemble them outside the full surface
21
+ builder.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from .event_dispatcher import (
27
+ EVENT_TRAITS,
28
+ RESERVED_EVENTS,
29
+ AddonEventDispatcher,
30
+ EventTrait,
31
+ subscription,
32
+ )
33
+ from .tool_interceptor import AddonInterceptorChain, interceptor
34
+
35
+ __all__ = [
36
+ "AddonEventDispatcher",
37
+ "AddonInterceptorChain",
38
+ "EVENT_TRAITS",
39
+ "EventTrait",
40
+ "RESERVED_EVENTS",
41
+ "interceptor",
42
+ "subscription",
43
+ ]
@@ -0,0 +1,348 @@
1
+ """EventDispatcher — the single fan-out / transform / veto engine for the
2
+ unified colon-named event taxonomy.
3
+
4
+ One :class:`AddonEventDispatcher` replaces what were previously two parallel
5
+ systems (a broad "emit lifecycle events" bus and a low-level "trigger
6
+ mutation hooks" runner): every :data:`HookEvent` flows through one ordered
7
+ middleware pass, and an addon decides per-subscription whether it merely
8
+ *observes* the payload, *transforms* it, or *gates* (vetoes) the flow. The
9
+ three middleware kinds compose in one dispatch:
10
+
11
+ - ``observe`` — fire-and-forget. The handler sees the live payload and
12
+ returns nothing. It cannot alter the value or stop the chain; a raise is
13
+ isolated into an :class:`AddonFault` and the run continues.
14
+ - ``transform`` — the handler returns a replacement payload, which the
15
+ dispatcher threads into every later handler and ultimately back to the
16
+ caller as :attr:`DispatchOutcome.payload`. A transform that raises is
17
+ isolated and treated as a no-op (the prior payload is kept), so one bad
18
+ rewriter cannot drop the value.
19
+ - ``gate`` — the handler returns a :class:`GateDecision`; the first
20
+ ``stop=True`` short-circuits the run and is surfaced as
21
+ :attr:`DispatchOutcome.gate`. A gate that raises is isolated and treated as
22
+ "no veto", so an erroring guard **fails open** rather than wedging the
23
+ agent.
24
+
25
+ Ordering is registration order across every addon: subscriptions are appended
26
+ to a per-event list as the host folds each :class:`RegisteredManifest` in,
27
+ and a dispatch walks that list front-to-back. Because a gate stops the walk,
28
+ a gate registered earlier wins over a transform registered later — the host
29
+ controls precedence purely through load order, with no priority field to
30
+ reconcile.
31
+
32
+ Reserved (gate-bearing) events — the events that *guard* something the agent
33
+ is about to do and therefore honor a veto — are **data-sourced**, not a
34
+ hard-coded literal. The dispatcher derives the reserved set at import by
35
+ scanning a small :class:`EventTrait` table keyed by :data:`HookEvent`; an
36
+ event with the ``guards`` trait is reserved.
37
+ :attr:`AddonEventDispatcher.reserved` exposes the derived set so callers (and
38
+ tests) read the same source of truth the engine uses to decide whether a gate
39
+ decision is meaningful for an event.
40
+
41
+ The dispatcher is transport-agnostic: it knows nothing about the framework
42
+ agent loop, the tool boundary, or how an event's payload is produced. Callers
43
+ push events in; the dispatcher fans them out, folds transforms,
44
+ short-circuits on a veto, and routes handler faults to listeners.
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import inspect
50
+ from collections.abc import Callable, Sequence
51
+ from dataclasses import dataclass
52
+ from types import MappingProxyType
53
+ from typing import Any, Mapping
54
+
55
+ from ..contract import (
56
+ AddonFault,
57
+ AddonFaultListener,
58
+ AddonId,
59
+ DispatchOutcome,
60
+ EventSubscription,
61
+ GateDecision,
62
+ HookEvent,
63
+ HookHandler,
64
+ addon_fault,
65
+ )
66
+
67
+ __all__ = [
68
+ "AddonEventDispatcher",
69
+ "EVENT_TRAITS",
70
+ "EventTrait",
71
+ "RESERVED_EVENTS",
72
+ "subscription",
73
+ ]
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # Event traits (data-sourced reserved set)
78
+ # ---------------------------------------------------------------------------
79
+
80
+
81
+ @dataclass(frozen=True, slots=True, kw_only=True)
82
+ class EventTrait:
83
+ """The behavioral traits of a single :data:`HookEvent`, used to *derive*
84
+ facts about the taxonomy instead of restating them as standalone literals.
85
+
86
+ Today the only trait the engine consults is :attr:`guards`: whether the
87
+ event fronts an action the agent is about to take and therefore admits a
88
+ veto. The reserved (gate-bearing) set is computed from this table at
89
+ import, so adding or reclassifying an event is a one-row edit here —
90
+ never a second hand-maintained ``RESERVED`` constant to keep in sync.
91
+ """
92
+
93
+ #: Whether this event guards an imminent action and so honors a ``gate``
94
+ #: veto. ``True`` for events that straddle a side-effecting step the host
95
+ #: can refuse to take (run a tool, submit input, condense the transcript);
96
+ #: ``False`` for purely notificational lifecycle/transform events, where a
97
+ #: gate decision carries no action to stop.
98
+ guards: bool
99
+
100
+
101
+ #: The trait table for every :data:`HookEvent`, in taxonomy order.
102
+ #:
103
+ #: The dispatcher reads this once to build its reserved set; nothing else
104
+ #: encodes which events are gate-bearing. An event whose ``guards`` is True
105
+ #: is one whose ``stop=True`` gate decision the host is expected to act on:
106
+ #:
107
+ #: - ``tool:before`` — guards a tool execution (block the call).
108
+ #: - ``input:submit`` — guards user input entering the loop (drop the turn).
109
+ #: - ``compact:before`` — guards condensing the transcript (refuse the compaction).
110
+ #:
111
+ #: Every other event is notificational or transform-only; a gate handler may
112
+ #: still attach to it, but the engine reports its decision without treating
113
+ #: the event as reserved.
114
+ EVENT_TRAITS: Mapping[HookEvent, EventTrait] = MappingProxyType(
115
+ {
116
+ "session:start": EventTrait(guards=False),
117
+ "session:end": EventTrait(guards=False),
118
+ "turn:start": EventTrait(guards=False),
119
+ "turn:end": EventTrait(guards=False),
120
+ "tool:before": EventTrait(guards=True),
121
+ "tool:after": EventTrait(guards=False),
122
+ "chat:params": EventTrait(guards=False),
123
+ "chat:message": EventTrait(guards=False),
124
+ "shell:env": EventTrait(guards=False),
125
+ "input:submit": EventTrait(guards=True),
126
+ "context:build": EventTrait(guards=False),
127
+ "compact:build": EventTrait(guards=False),
128
+ "compact:before": EventTrait(guards=True),
129
+ }
130
+ )
131
+
132
+ #: The reserved (gate-bearing) events, derived from :data:`EVENT_TRAITS`.
133
+ #:
134
+ #: Computed once from the trait table — the single data source — rather than
135
+ #: declared as its own literal set. Callers obtain it through
136
+ #: :attr:`AddonEventDispatcher.reserved`; the engine consults the same set
137
+ #: when deciding whether a gate decision is actionable for an event.
138
+ RESERVED_EVENTS: frozenset[HookEvent] = frozenset(
139
+ event for event, trait in EVENT_TRAITS.items() if trait.guards
140
+ )
141
+
142
+
143
+ async def _maybe_await(value: Any) -> Any:
144
+ """Resolve a handler return that may be a plain value or an awaitable —
145
+ the Python analogue of TS ``await`` over ``T | Promise<T>``."""
146
+ if inspect.isawaitable(value):
147
+ return await value
148
+ return value
149
+
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # Dispatcher
153
+ # ---------------------------------------------------------------------------
154
+
155
+
156
+ class AddonEventDispatcher:
157
+ """The concrete :class:`EventDispatcher`: one engine per session, fed the
158
+ folded :class:`EventSubscription` lists of every loaded addon.
159
+
160
+ Build one with :meth:`from_subscriptions` over the host's collected
161
+ subscriptions (registration order preserved), then call :meth:`dispatch`
162
+ per event. Handler faults are routed to :meth:`on_fault` listeners and
163
+ never propagate into the agent loop.
164
+ """
165
+
166
+ def __init__(self, subscriptions: Sequence[EventSubscription]) -> None:
167
+ """
168
+ :param subscriptions: every addon's recorded subscriptions, in the
169
+ order the host folded them (registration order = dispatch order)
170
+ """
171
+ # Subscriptions grouped by event, each list in registration order.
172
+ self._by_event: dict[HookEvent, tuple[EventSubscription, ...]] = _group_by_event(
173
+ subscriptions
174
+ )
175
+ # Live fault listeners, in registration order (dict = ordered set).
176
+ self._fault_listeners: dict[AddonFaultListener, None] = {}
177
+
178
+ @classmethod
179
+ def from_subscriptions(
180
+ cls, subscriptions: Sequence[EventSubscription]
181
+ ) -> AddonEventDispatcher:
182
+ """Build a dispatcher from a flat list of subscriptions.
183
+
184
+ A thin alias for the constructor that reads well at the host's fold
185
+ site (the TS static ``from``; renamed — ``from`` is a Python keyword).
186
+
187
+ :param subscriptions: the folded subscriptions across all addons
188
+ """
189
+ return cls(subscriptions)
190
+
191
+ @property
192
+ def reserved(self) -> frozenset[HookEvent]:
193
+ """The reserved (gate-bearing) event set, derived from the trait table.
194
+
195
+ Exposed so callers and tests share the engine's own source of truth
196
+ for which events honor a veto, rather than re-deriving it from a
197
+ literal.
198
+ """
199
+ return RESERVED_EVENTS
200
+
201
+ async def dispatch(self, event: HookEvent, payload: Any) -> DispatchOutcome:
202
+ """Run an event through its subscribed middleware and return the
203
+ outcome.
204
+
205
+ Walks the event's subscriptions in registration order: ``transform``
206
+ handlers fold the payload forward, ``observe`` handlers see the
207
+ current payload, and the first ``gate`` handler that returns
208
+ ``stop=True`` short-circuits the walk. The returned
209
+ :class:`DispatchOutcome` carries the final (possibly transformed)
210
+ payload and, when a gate stopped the run, its decision. A handler
211
+ that raises is isolated into an :class:`AddonFault` routed to
212
+ listeners; the run continues (or, for a gate, fails open).
213
+
214
+ A gate decision is reported regardless of whether the event is
215
+ reserved, so a caller may inspect :attr:`DispatchOutcome.gate` for
216
+ any event; the :attr:`reserved` set tells the host which events it is
217
+ *expected* to act on.
218
+
219
+ :param event: the event to dispatch
220
+ :param payload: the initial payload threaded through the chain
221
+ """
222
+ subscriptions = self._by_event.get(event)
223
+ if not subscriptions:
224
+ return DispatchOutcome(payload=payload)
225
+
226
+ current = payload
227
+ for sub in subscriptions:
228
+ gate, current = await self._run(event, sub, current)
229
+ if gate is not None and gate.stop:
230
+ return DispatchOutcome(payload=current, gate=gate)
231
+ return DispatchOutcome(payload=current)
232
+
233
+ def has(self, event: HookEvent) -> bool:
234
+ """Whether any addon has subscribed to the given event."""
235
+ return bool(self._by_event.get(event))
236
+
237
+ def on_fault(self, listener: AddonFaultListener) -> Callable[[], None]:
238
+ """Register a listener for per-handler :class:`AddonFault` reports.
239
+
240
+ Returns an idempotent unsubscribe thunk; registering the same
241
+ listener twice collapses to one slot.
242
+
243
+ :param listener: the fault sink
244
+ """
245
+ self._fault_listeners[listener] = None
246
+ active = True
247
+
248
+ def unsubscribe() -> None:
249
+ nonlocal active
250
+ if not active:
251
+ return
252
+ active = False
253
+ self._fault_listeners.pop(listener, None)
254
+
255
+ return unsubscribe
256
+
257
+ async def _run(
258
+ self,
259
+ event: HookEvent,
260
+ sub: EventSubscription,
261
+ payload: Any,
262
+ ) -> tuple[GateDecision | None, Any]:
263
+ """Invoke one subscription's handler against the current payload,
264
+ isolating any raise, and return ``(gate_decision, next_payload)``.
265
+
266
+ ``observe`` returns nothing and the payload is left untouched;
267
+ ``transform`` folds its return value in; ``gate`` yields its decision
268
+ (a non-:class:`GateDecision` return degrades to "no opinion"). A
269
+ raise from any kind is converted to an :class:`AddonFault` routed to
270
+ listeners and then swallowed — observe/transform continue with the
271
+ prior payload, gate fails open (returns no veto).
272
+ """
273
+ handler: HookHandler = sub.handler
274
+ try:
275
+ match handler.kind:
276
+ case "observe":
277
+ await _maybe_await(handler.run(payload))
278
+ return None, payload
279
+ case "transform":
280
+ next_payload = await _maybe_await(handler.run(payload))
281
+ return None, next_payload
282
+ case "gate":
283
+ decision = await _maybe_await(handler.run(payload))
284
+ if isinstance(decision, GateDecision):
285
+ return decision, payload
286
+ return None, payload
287
+ except Exception as cause:
288
+ self._report(
289
+ addon_fault(
290
+ "handler",
291
+ f'Handler for "{event}" threw during dispatch.',
292
+ addon=sub.addon,
293
+ cause=cause,
294
+ )
295
+ )
296
+ return None, payload
297
+ # Unknown handler kind (malformed duck): inert, payload untouched.
298
+ return None, payload
299
+
300
+ def _report(self, fault: AddonFault) -> None:
301
+ """Route a fault to every listener, guarding each listener against its
302
+ own raise."""
303
+ for listener in list(self._fault_listeners):
304
+ try:
305
+ listener(fault)
306
+ except Exception:
307
+ # A throwing fault listener must not break fan-out to the others.
308
+ pass
309
+
310
+
311
+ # ---------------------------------------------------------------------------
312
+ # Helpers
313
+ # ---------------------------------------------------------------------------
314
+
315
+
316
+ def _group_by_event(
317
+ subscriptions: Sequence[EventSubscription],
318
+ ) -> dict[HookEvent, tuple[EventSubscription, ...]]:
319
+ """Group a flat subscription list into per-event tuples, preserving the
320
+ input order within each event so dispatch order equals registration
321
+ order."""
322
+ grouped: dict[HookEvent, list[EventSubscription]] = {}
323
+ for sub in subscriptions:
324
+ grouped.setdefault(sub.event, []).append(sub)
325
+ return {event: tuple(subs) for event, subs in grouped.items()}
326
+
327
+
328
+ # ---------------------------------------------------------------------------
329
+ # Construction helpers
330
+ # ---------------------------------------------------------------------------
331
+
332
+
333
+ def subscription(
334
+ addon: AddonId,
335
+ event: HookEvent,
336
+ handler: HookHandler,
337
+ ) -> EventSubscription:
338
+ """Record one event subscription, stamping it with the owning addon — the
339
+ shape the :class:`AddonSurface` produces and the dispatcher consumes.
340
+
341
+ Small convenience for hosts/tests assembling subscriptions by hand
342
+ without going through the full surface builder.
343
+
344
+ :param addon: the addon the subscription is attributed to
345
+ :param event: the event being hooked
346
+ :param handler: the observe/transform/gate middleware
347
+ """
348
+ return EventSubscription(event=event, handler=handler, addon=addon)