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
|
@@ -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)
|