branchkit 0.1.1__tar.gz → 0.2.0__tar.gz
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.
- {branchkit-0.1.1 → branchkit-0.2.0}/PKG-INFO +1 -1
- {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/__init__.py +2 -0
- {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/commands.py +10 -6
- {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/contracts_gen.py +3 -0
- {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/log_events_gen.py +1 -1
- {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/methods_gen.py +29 -1
- {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/plugin.py +48 -5
- {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/settings.py +70 -7
- {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/types_gen.py +29 -0
- {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/ui.py +13 -2
- {branchkit-0.1.1 → branchkit-0.2.0}/branchkit.egg-info/PKG-INFO +1 -1
- {branchkit-0.1.1 → branchkit-0.2.0}/branchkit.egg-info/SOURCES.txt +2 -1
- {branchkit-0.1.1 → branchkit-0.2.0}/pyproject.toml +1 -1
- {branchkit-0.1.1 → branchkit-0.2.0}/tests/test_kernel.py +25 -1
- branchkit-0.2.0/tests/test_settings_tabs.py +76 -0
- {branchkit-0.1.1 → branchkit-0.2.0}/LICENSE +0 -0
- {branchkit-0.1.1 → branchkit-0.2.0}/README.md +0 -0
- {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/actor.py +0 -0
- {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/closed_vocab_gen.py +0 -0
- {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/collection.py +0 -0
- {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/collection_log.py +0 -0
- {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/correlation.py +0 -0
- {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/debug.py +0 -0
- {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/effects.py +0 -0
- {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/harness.py +0 -0
- {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/hud.py +0 -0
- {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/listen.py +0 -0
- {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/log.py +0 -0
- {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/mirror.py +0 -0
- {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/proxy.py +0 -0
- {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/settings_route.py +0 -0
- {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/upstream.py +0 -0
- {branchkit-0.1.1 → branchkit-0.2.0}/branchkit.egg-info/dependency_links.txt +0 -0
- {branchkit-0.1.1 → branchkit-0.2.0}/branchkit.egg-info/top_level.txt +0 -0
- {branchkit-0.1.1 → branchkit-0.2.0}/setup.cfg +0 -0
- {branchkit-0.1.1 → branchkit-0.2.0}/tests/test_collection.py +0 -0
|
@@ -23,6 +23,7 @@ from . import proxy as _proxy
|
|
|
23
23
|
_proxy.install_proxy_from_env()
|
|
24
24
|
|
|
25
25
|
from .plugin import (
|
|
26
|
+
DetachedError,
|
|
26
27
|
PluginCore,
|
|
27
28
|
RecordingDisabledError,
|
|
28
29
|
RpcCallError,
|
|
@@ -59,6 +60,7 @@ from .settings_route import method_post, method_url
|
|
|
59
60
|
from .ui import (
|
|
60
61
|
Expr,
|
|
61
62
|
args,
|
|
63
|
+
js,
|
|
62
64
|
confirm_button,
|
|
63
65
|
expr,
|
|
64
66
|
input_value,
|
|
@@ -69,9 +69,10 @@ class CommandBuilder:
|
|
|
69
69
|
|
|
70
70
|
def action(self, type: str, params: dict | None = None) -> "CommandBuilder":
|
|
71
71
|
"""Set the action fired on match. `type` is the action's type (a
|
|
72
|
-
built-in like "key" or a dotted plugin action); `params`
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
built-in like "key" or a dotted plugin action); `params` nest under
|
|
73
|
+
the action object's `params` key — one dialect, see
|
|
74
|
+
DESIGN_ONE_PARAMS_DIALECT.md."""
|
|
75
|
+
self._spec["action"] = {"type": type, "params": params} if params else {"type": type}
|
|
75
76
|
return self
|
|
76
77
|
|
|
77
78
|
def requires_tags(self, *tags: str) -> "CommandBuilder":
|
|
@@ -102,9 +103,12 @@ class CommandBuilder:
|
|
|
102
103
|
return self
|
|
103
104
|
|
|
104
105
|
def discovery(self, mode: str) -> "CommandBuilder":
|
|
105
|
-
"""Declare the command's prefix-discovery affordance ("prefix"
|
|
106
|
-
"exclusive"
|
|
107
|
-
|
|
106
|
+
"""Declare the command's prefix-discovery affordance ("prefix",
|
|
107
|
+
"exclusive", or "select" — select points at entries via
|
|
108
|
+
platform-assigned alphabet codewords instead of their names). Valid
|
|
109
|
+
only on a literal-prefix + single-tail-capture pattern. See
|
|
110
|
+
docs/design/DESIGN_DISCOVERABLE_PREFIX.md and
|
|
111
|
+
DESIGN_SELECTION_PRIMITIVE.md."""
|
|
108
112
|
self._spec["discovery"] = mode
|
|
109
113
|
return self
|
|
110
114
|
|
|
@@ -587,12 +587,15 @@ METHOD_PIPELINES_STOP = "pipelines.stop" # since 0.1.0
|
|
|
587
587
|
METHOD_PIPELINES_WARM = "pipelines.warm" # since 0.1.0
|
|
588
588
|
METHOD_PLUGIN_DATA_EXPORT = "plugin.data.export" # since 0.1.0
|
|
589
589
|
METHOD_PLUGIN_DEBUG = "plugin.debug" # since 0.1.0
|
|
590
|
+
METHOD_PLUGIN_REPORT_HEALTH = "plugin.report_health" # since 0.1.0
|
|
590
591
|
METHOD_PRIVACY_GET_RECORDING = "privacy.get_recording" # stable, since 0.1.0
|
|
591
592
|
METHOD_PRIVACY_SET_RECORDING = "privacy.set_recording" # stable, since 0.1.0
|
|
592
593
|
METHOD_PRIVILEGES_LIST = "privileges.list" # since 0.1.0
|
|
593
594
|
METHOD_PRIVILEGES_RELINQUISH = "privileges.relinquish" # since 0.1.0
|
|
594
595
|
METHOD_PRIVILEGES_REQUEST = "privileges.request" # since 0.1.0
|
|
595
596
|
METHOD_RECOGNITION_BIAS_APPLY = "recognition.bias.apply" # since 0.1.0
|
|
597
|
+
METHOD_RECOGNITION_BIAS_GET = "recognition.bias.get" # since 0.1.0
|
|
598
|
+
METHOD_RECOGNITION_BIAS_SET = "recognition.bias.set" # since 0.1.0
|
|
596
599
|
METHOD_RECOGNITION_REDECODE = "recognition.redecode" # since 0.1.0
|
|
597
600
|
METHOD_SELECTION_PICK = "selection.pick" # since 0.1.0
|
|
598
601
|
METHOD_SELECTION_SET = "selection.set" # since 0.1.0
|
|
@@ -108,7 +108,7 @@ LOG_EVENT_REGISTRY: dict[str, LogEventMeta] = {
|
|
|
108
108
|
"native.window_skipped": {"name": "native.window_skipped", "summary": "World poller skipped a non-standard window (debug-level).", "since": "0.1.0", "source": "native", "severity": "debug", "redaction": "none"},
|
|
109
109
|
"native.world_poller_event": {"name": "native.world_poller_event", "summary": "World poller lifecycle event (started, seeded, batch processed, idle).", "since": "0.1.0", "source": "native", "severity": "debug", "redaction": "none"},
|
|
110
110
|
"operation.completed": {"name": "operation.completed", "summary": "An operation handler completed execution.", "since": "0.1.0", "source": "dispatch", "severity": "debug", "redaction": "none"},
|
|
111
|
-
"plugin.degraded": {"name": "plugin.degraded", "summary": "A
|
|
111
|
+
"plugin.degraded": {"name": "plugin.degraded", "summary": "A plugin is running but not fully working — either its RPC channel hit the consecutive-timeout threshold (reason absent), or the plugin reported the fault itself via plugin.report_health (reason present).", "since": "0.1.0", "source": "plugins", "severity": "warn", "redaction": "none"},
|
|
112
112
|
"plugin.diagnostic": {"name": "plugin.diagnostic", "summary": "A plugin emitted a warn- or error-level diagnostic via plugin.debug; cross-posted to actuator.log so plugin-level failures interleave with the actuator's view of dispatch / coordination.", "since": "0.2.0", "source": "plugins", "severity": "warn", "redaction": "none"},
|
|
113
113
|
"plugin.exited": {"name": "plugin.exited", "summary": "A managed plugin process exited (clean or crashed).", "since": "0.1.0", "source": "plugins", "severity": "info", "redaction": "none"},
|
|
114
114
|
"plugin.lifecycle_op": {"name": "plugin.lifecycle_op", "summary": "A plugin lifecycle operation (install, uninstall, update, reload, hot-reload, enable, disable).", "since": "0.1.0", "source": "plugins", "severity": "info", "redaction": "none"},
|
|
@@ -588,12 +588,15 @@ from .contracts_gen import (
|
|
|
588
588
|
METHOD_PIPELINES_WARM,
|
|
589
589
|
METHOD_PLUGIN_DATA_EXPORT,
|
|
590
590
|
METHOD_PLUGIN_DEBUG,
|
|
591
|
+
METHOD_PLUGIN_REPORT_HEALTH,
|
|
591
592
|
METHOD_PRIVACY_GET_RECORDING,
|
|
592
593
|
METHOD_PRIVACY_SET_RECORDING,
|
|
593
594
|
METHOD_PRIVILEGES_LIST,
|
|
594
595
|
METHOD_PRIVILEGES_RELINQUISH,
|
|
595
596
|
METHOD_PRIVILEGES_REQUEST,
|
|
596
597
|
METHOD_RECOGNITION_BIAS_APPLY,
|
|
598
|
+
METHOD_RECOGNITION_BIAS_GET,
|
|
599
|
+
METHOD_RECOGNITION_BIAS_SET,
|
|
597
600
|
METHOD_RECOGNITION_REDECODE,
|
|
598
601
|
METHOD_SELECTION_PICK,
|
|
599
602
|
METHOD_SELECTION_SET,
|
|
@@ -961,6 +964,8 @@ if TYPE_CHECKING:
|
|
|
961
964
|
PrivilegesRequestResponse,
|
|
962
965
|
ProcessInfo,
|
|
963
966
|
RecognitionBiasApplyResponse,
|
|
967
|
+
RecognitionBiasGetResponse,
|
|
968
|
+
RecognitionBiasSetResponse,
|
|
964
969
|
RecognitionRedecodeResponse,
|
|
965
970
|
RedecodeItem,
|
|
966
971
|
ReminderItem,
|
|
@@ -4198,10 +4203,11 @@ class MethodsMixin:
|
|
|
4198
4203
|
result = await self.call(METHOD_PIPELINES_GRAMMAR, params)
|
|
4199
4204
|
return result
|
|
4200
4205
|
|
|
4201
|
-
async def pipelines_inject(self, event_type: str, name: str, data: Any | None = None) -> PipelinesInjectResponse:
|
|
4206
|
+
async def pipelines_inject(self, event_type: str, name: str, stage: str, data: Any | None = None) -> PipelinesInjectResponse:
|
|
4202
4207
|
params: dict[str, Any] = {
|
|
4203
4208
|
"event_type": event_type,
|
|
4204
4209
|
"name": name,
|
|
4210
|
+
"stage": stage,
|
|
4205
4211
|
}
|
|
4206
4212
|
if data is not None:
|
|
4207
4213
|
params["data"] = data
|
|
@@ -4261,6 +4267,14 @@ class MethodsMixin:
|
|
|
4261
4267
|
params["tag"] = tag
|
|
4262
4268
|
await self.call(METHOD_PLUGIN_DEBUG, params)
|
|
4263
4269
|
|
|
4270
|
+
async def plugin_report_health(self, degraded: bool, reason: str | None = None) -> None:
|
|
4271
|
+
params: dict[str, Any] = {
|
|
4272
|
+
"degraded": degraded,
|
|
4273
|
+
}
|
|
4274
|
+
if reason is not None:
|
|
4275
|
+
params["reason"] = reason
|
|
4276
|
+
await self.call(METHOD_PLUGIN_REPORT_HEALTH, params)
|
|
4277
|
+
|
|
4264
4278
|
async def privacy_get_recording(self, name: str) -> PrivacyGetRecordingResponse:
|
|
4265
4279
|
params: dict[str, Any] = {
|
|
4266
4280
|
"name": name,
|
|
@@ -4304,6 +4318,20 @@ class MethodsMixin:
|
|
|
4304
4318
|
result = await self.call(METHOD_RECOGNITION_BIAS_APPLY, params)
|
|
4305
4319
|
return result
|
|
4306
4320
|
|
|
4321
|
+
async def recognition_bias_get(self) -> RecognitionBiasGetResponse:
|
|
4322
|
+
result = await self.call(METHOD_RECOGNITION_BIAS_GET)
|
|
4323
|
+
return result
|
|
4324
|
+
|
|
4325
|
+
async def recognition_bias_set(self, enabled: bool | None = None, strength: float | None = None) -> RecognitionBiasSetResponse:
|
|
4326
|
+
params: dict[str, Any] = {
|
|
4327
|
+
}
|
|
4328
|
+
if enabled is not None:
|
|
4329
|
+
params["enabled"] = enabled
|
|
4330
|
+
if strength is not None:
|
|
4331
|
+
params["strength"] = strength
|
|
4332
|
+
result = await self.call(METHOD_RECOGNITION_BIAS_SET, params)
|
|
4333
|
+
return result
|
|
4334
|
+
|
|
4307
4335
|
async def recognition_redecode(self, items: list["RedecodeItem"], model: str, stage: str, max_active: int | None = None) -> RecognitionRedecodeResponse:
|
|
4308
4336
|
params: dict[str, Any] = {
|
|
4309
4337
|
"items": items,
|
|
@@ -20,7 +20,8 @@ from typing import Any, Callable
|
|
|
20
20
|
|
|
21
21
|
from .closed_vocab_gen import ERROR_KIND_RECORDING_DISABLED
|
|
22
22
|
from .contracts_gen import API_VERSION as _COMPILED_API_VERSION
|
|
23
|
-
from .contracts_gen import HOOK_ON_ACTION
|
|
23
|
+
from .contracts_gen import HOOK_ON_ACTION, HOOK_RENDER_SETTINGS
|
|
24
|
+
|
|
24
25
|
from .actor import get_current_actor
|
|
25
26
|
from .correlation import get_current_correlation, reset_correlation, set_correlation
|
|
26
27
|
from .log import log
|
|
@@ -88,12 +89,28 @@ def error_kind_of(e: object) -> str | None:
|
|
|
88
89
|
return e.kind if isinstance(e, RpcCallError) else None
|
|
89
90
|
|
|
90
91
|
|
|
92
|
+
class DetachedError(RuntimeError):
|
|
93
|
+
"""Raised by every call on a detached plugin (Plugin(detached=True))."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, method: str) -> None:
|
|
96
|
+
super().__init__(f"detached plugin: no platform behind {method!r}")
|
|
97
|
+
|
|
98
|
+
|
|
91
99
|
class PluginCore:
|
|
100
|
+
|
|
92
101
|
"""Transport + dispatch. The public `Plugin` class (see `__init__.py`)
|
|
93
102
|
layers the generated method wrappers and the façade mixins on top."""
|
|
94
103
|
|
|
95
|
-
def __init__(self) -> None:
|
|
96
|
-
|
|
104
|
+
def __init__(self, *, detached: bool = False) -> None:
|
|
105
|
+
# detached: a plugin with no platform behind it, for tests. It never
|
|
106
|
+
# touches stdin or stdout: every call raises DetachedError at once,
|
|
107
|
+
# notifications go nowhere, mirrors never fetch, run() returns
|
|
108
|
+
# immediately. A host built on it exercises the plugin's own logic
|
|
109
|
+
# without a live actuator; swap a seam or a mirror for the platform
|
|
110
|
+
# behaviour a test needs.
|
|
111
|
+
self._detached = detached
|
|
112
|
+
self._plugin_id: str = "detached" if detached else os.environ.get("BRANCHKIT_PLUGIN_ID", "unknown")
|
|
113
|
+
|
|
97
114
|
self._handlers: dict[str, Callable] = {}
|
|
98
115
|
self._listeners: dict[str, list[Callable]] = {}
|
|
99
116
|
# on_pattern registrations, in registration order. A list rather than
|
|
@@ -102,6 +119,13 @@ class PluginCore:
|
|
|
102
119
|
self._pattern_listeners: list[tuple[str, Callable]] = []
|
|
103
120
|
self._pending: dict[int, asyncio.Future] = {}
|
|
104
121
|
self._action_handlers: dict[str, Callable] | None = None
|
|
122
|
+
# Non-None once settings_tab has installed the SDK's own
|
|
123
|
+
# render_settings handler; _settings_mirrors are refreshed by that
|
|
124
|
+
# handler before every render (settings.py).
|
|
125
|
+
self._settings_tabs: dict[str, Callable] | None = None
|
|
126
|
+
self._settings_css = ""
|
|
127
|
+
self._settings_mirrors: list = []
|
|
128
|
+
|
|
105
129
|
self._next_id = 1
|
|
106
130
|
self._closed = False
|
|
107
131
|
self._loop: asyncio.AbstractEventLoop | None = None
|
|
@@ -139,7 +163,13 @@ class PluginCore:
|
|
|
139
163
|
(`plugin.handle("m", fn)`) or as a decorator (`@plugin.handle("m")`).
|
|
140
164
|
|
|
141
165
|
`handle("on_action", ...)` and `handle_action(...)` are mutually
|
|
142
|
-
exclusive — both install a handler for the same RPC method.
|
|
166
|
+
exclusive — both install a handler for the same RPC method.
|
|
167
|
+
|
|
168
|
+
`render_settings` is not registrable here at all: the SDK owns that
|
|
169
|
+
hook and installs it through `settings_tab`, one renderer per
|
|
170
|
+
manifest tab. `handle` raises on it so a plugin cannot bypass the
|
|
171
|
+
dispatch."""
|
|
172
|
+
|
|
143
173
|
if fn is None:
|
|
144
174
|
def deco(f):
|
|
145
175
|
self.handle(method, f)
|
|
@@ -149,9 +179,14 @@ class PluginCore:
|
|
|
149
179
|
raise RuntimeError(
|
|
150
180
|
'plugin-sdk-py: cannot mix handle("on_action", ...) and handle_action(...) — pick one'
|
|
151
181
|
)
|
|
182
|
+
if method == HOOK_RENDER_SETTINGS:
|
|
183
|
+
raise RuntimeError(
|
|
184
|
+
"plugin-sdk-py: render_settings is the SDK's hook — register each tab with settings_tab(key, fn)"
|
|
185
|
+
)
|
|
152
186
|
self._handlers[method] = fn
|
|
153
187
|
return fn
|
|
154
188
|
|
|
189
|
+
|
|
155
190
|
def handle_action(self, action: str, fn: Callable | None = None):
|
|
156
191
|
"""Register a handler for a single dispatched action type. The SDK
|
|
157
192
|
installs an internal on_action handler that demuxes by
|
|
@@ -249,10 +284,13 @@ class PluginCore:
|
|
|
249
284
|
"""Send a request to the actuator and wait for the response.
|
|
250
285
|
Default timeout 10s (T1); override per call (T3). Raises
|
|
251
286
|
RpcCallError for wire errors, TimeoutError on expiry."""
|
|
287
|
+
if self._detached:
|
|
288
|
+
raise DetachedError(method)
|
|
252
289
|
if self._closed:
|
|
253
290
|
raise RuntimeError("plugin shutting down")
|
|
254
291
|
loop = asyncio.get_running_loop()
|
|
255
292
|
call_id = self._next_id
|
|
293
|
+
|
|
256
294
|
self._next_id += 1
|
|
257
295
|
fut: asyncio.Future = loop.create_future()
|
|
258
296
|
self._pending[call_id] = fut
|
|
@@ -318,8 +356,12 @@ class PluginCore:
|
|
|
318
356
|
and block until shutdown. Incoming requests are held until run()
|
|
319
357
|
is called (L4)."""
|
|
320
358
|
self._loop = asyncio.get_running_loop()
|
|
359
|
+
if self._detached:
|
|
360
|
+
self._ready.set()
|
|
361
|
+
return
|
|
321
362
|
log(self._plugin_id, "started (JSON-RPC over stdio)")
|
|
322
363
|
|
|
364
|
+
|
|
323
365
|
# Graceful SIGTERM/SIGINT (L3).
|
|
324
366
|
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
325
367
|
try:
|
|
@@ -359,8 +401,9 @@ class PluginCore:
|
|
|
359
401
|
# --- Internal ---
|
|
360
402
|
|
|
361
403
|
def _write(self, msg: dict) -> None:
|
|
362
|
-
if self._closed:
|
|
404
|
+
if self._closed or self._detached:
|
|
363
405
|
return
|
|
406
|
+
|
|
364
407
|
line = json.dumps(msg, separators=(",", ":")) + "\n"
|
|
365
408
|
with self._write_lock:
|
|
366
409
|
try:
|
|
@@ -9,16 +9,23 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
from typing import Any, Callable
|
|
11
11
|
|
|
12
|
+
from .contracts_gen import HOOK_RENDER_SETTINGS
|
|
12
13
|
from .log import log
|
|
13
14
|
|
|
14
15
|
|
|
16
|
+
|
|
15
17
|
class SettingsMirror:
|
|
16
18
|
def __init__(self, plugin, name: str):
|
|
17
19
|
# Internal — use plugin.settings(name).
|
|
18
20
|
self._plugin = plugin
|
|
19
21
|
self._name = name
|
|
20
22
|
self._mirror = plugin.mirror_collection(name)
|
|
23
|
+
# The SDK's render_settings hook refreshes every settings mirror
|
|
24
|
+
# before a tab draws (settings_tab) — the read-through render paths
|
|
25
|
+
# used to hand-roll.
|
|
26
|
+
plugin._settings_mirrors.append(self)
|
|
21
27
|
self._val: dict | None = None
|
|
28
|
+
|
|
22
29
|
self._on_change: list[Callable] = []
|
|
23
30
|
self_id = plugin.id
|
|
24
31
|
|
|
@@ -82,16 +89,72 @@ class SettingsMirror:
|
|
|
82
89
|
)
|
|
83
90
|
await self.refresh()
|
|
84
91
|
|
|
85
|
-
async def load(self) -> dict | None:
|
|
86
|
-
"""The composed settings via a synchronous read-through. Use at the
|
|
87
|
-
top of render paths: a render must read state at least as fresh as
|
|
88
|
-
whatever triggered it."""
|
|
89
|
-
await self.refresh()
|
|
90
|
-
return self.get()
|
|
91
|
-
|
|
92
92
|
|
|
93
93
|
class SettingsMixin:
|
|
94
94
|
def settings(self, name: str) -> SettingsMirror:
|
|
95
95
|
"""Typed mirror of a `preset: settings` collection. Must be called
|
|
96
96
|
before `run()` so the initial fetch lands."""
|
|
97
97
|
return SettingsMirror(self, name)
|
|
98
|
+
|
|
99
|
+
def settings_tab(self, key: str, fn: Callable | None = None):
|
|
100
|
+
"""Register the renderer for the manifest-declared settings tab
|
|
101
|
+
`key`. Usable directly (`plugin.settings_tab("k", fn)`) or as a
|
|
102
|
+
decorator (`@plugin.settings_tab("k")`). The renderer takes the
|
|
103
|
+
render_settings params and returns the tab's HTML FRAGMENT — the
|
|
104
|
+
platform's frame owns the container it is morphed into — and the
|
|
105
|
+
SDK attaches the stylesheet registered with `settings_css`. Plain
|
|
106
|
+
`def` or `async def`, as with `handle`.
|
|
107
|
+
|
|
108
|
+
The first call installs the SDK's own `render_settings` handler,
|
|
109
|
+
which on every render (1) refreshes every settings mirror created
|
|
110
|
+
with `settings()`, so the render reads state at least as fresh as
|
|
111
|
+
whatever woke it; (2) dispatches on `tab_key` — a key with no
|
|
112
|
+
renderer is an error, which the platform shows as the tab's error
|
|
113
|
+
state instead of a blank body; (3) returns the fragment with the
|
|
114
|
+
registered stylesheet.
|
|
115
|
+
|
|
116
|
+
A settings method returns None. The platform's method proxy
|
|
117
|
+
refuses any result — 422 with the `settings-method-result`
|
|
118
|
+
diagnostic, shown as a banner in the tab and logged to the plugin's
|
|
119
|
+
log — and re-renders the tab: a method changes state, and the
|
|
120
|
+
re-render that follows draws it.
|
|
121
|
+
|
|
122
|
+
This is the only way to install a render_settings handler:
|
|
123
|
+
`handle("render_settings", ...)` raises, so every tab goes through
|
|
124
|
+
this dispatch."""
|
|
125
|
+
if fn is None:
|
|
126
|
+
def deco(f):
|
|
127
|
+
self.settings_tab(key, f)
|
|
128
|
+
return f
|
|
129
|
+
return deco
|
|
130
|
+
if self._settings_tabs is None:
|
|
131
|
+
self._settings_tabs = {}
|
|
132
|
+
self._handlers[HOOK_RENDER_SETTINGS] = self._render_settings_tab
|
|
133
|
+
self._settings_tabs[key] = fn
|
|
134
|
+
return fn
|
|
135
|
+
|
|
136
|
+
def settings_css(self, css: str) -> None:
|
|
137
|
+
"""Register the stylesheet returned with every tab this plugin
|
|
138
|
+
renders. One sheet per plugin: the platform places it in a
|
|
139
|
+
`<style>` element it owns, outside the morph target."""
|
|
140
|
+
self._settings_css = css
|
|
141
|
+
|
|
142
|
+
async def _render_settings_tab(self, params: Any) -> dict:
|
|
143
|
+
req = params if isinstance(params, dict) else {}
|
|
144
|
+
key = req.get("tab_key", "")
|
|
145
|
+
fn = (self._settings_tabs or {}).get(key)
|
|
146
|
+
if fn is None:
|
|
147
|
+
raise RuntimeError(f'no renderer registered for settings tab "{key}"')
|
|
148
|
+
# Read through before drawing. A refresh failure is logged, not
|
|
149
|
+
# fatal: the mirror keeps its last snapshot and the tab still draws.
|
|
150
|
+
for m in list(self._settings_mirrors):
|
|
151
|
+
try:
|
|
152
|
+
await m.refresh()
|
|
153
|
+
except Exception as e:
|
|
154
|
+
log(self.id, f"settings read-through failed: {e}")
|
|
155
|
+
html = await self._invoke(fn, req)
|
|
156
|
+
resp = {"html": html}
|
|
157
|
+
if self._settings_css:
|
|
158
|
+
resp["css"] = self._settings_css
|
|
159
|
+
return resp
|
|
160
|
+
|
|
@@ -242,6 +242,7 @@ DirectoryEntry = TypedDict("DirectoryEntry", {
|
|
|
242
242
|
|
|
243
243
|
DiscoverItem = TypedDict("DiscoverItem", {
|
|
244
244
|
"id": str,
|
|
245
|
+
"speakable": bool,
|
|
245
246
|
"subtitle": NotRequired[str],
|
|
246
247
|
"tag": str,
|
|
247
248
|
"title": str,
|
|
@@ -4211,6 +4212,7 @@ PipelinesInjectRequest = TypedDict("PipelinesInjectRequest", {
|
|
|
4211
4212
|
"data": NotRequired[Any],
|
|
4212
4213
|
"event_type": str,
|
|
4213
4214
|
"name": str,
|
|
4215
|
+
"stage": str,
|
|
4214
4216
|
})
|
|
4215
4217
|
|
|
4216
4218
|
PipelinesInjectResponse = TypedDict("PipelinesInjectResponse", {
|
|
@@ -4272,6 +4274,15 @@ PluginDebugResponse = TypedDict("PluginDebugResponse", {
|
|
|
4272
4274
|
"ok": bool,
|
|
4273
4275
|
})
|
|
4274
4276
|
|
|
4277
|
+
PluginReportHealthRequest = TypedDict("PluginReportHealthRequest", {
|
|
4278
|
+
"degraded": bool,
|
|
4279
|
+
"reason": NotRequired[str],
|
|
4280
|
+
})
|
|
4281
|
+
|
|
4282
|
+
PluginReportHealthResponse = TypedDict("PluginReportHealthResponse", {
|
|
4283
|
+
"ok": bool,
|
|
4284
|
+
})
|
|
4285
|
+
|
|
4275
4286
|
PrivacyGetRecordingRequest = TypedDict("PrivacyGetRecordingRequest", {
|
|
4276
4287
|
"name": str,
|
|
4277
4288
|
})
|
|
@@ -4320,6 +4331,23 @@ RecognitionBiasApplyResponse = TypedDict("RecognitionBiasApplyResponse", {
|
|
|
4320
4331
|
"previous_provenance": str,
|
|
4321
4332
|
})
|
|
4322
4333
|
|
|
4334
|
+
RecognitionBiasGetResponse = TypedDict("RecognitionBiasGetResponse", {
|
|
4335
|
+
"enabled": bool,
|
|
4336
|
+
"provenance": str,
|
|
4337
|
+
"strength": float,
|
|
4338
|
+
})
|
|
4339
|
+
|
|
4340
|
+
RecognitionBiasSetRequest = TypedDict("RecognitionBiasSetRequest", {
|
|
4341
|
+
"enabled": NotRequired[bool],
|
|
4342
|
+
"strength": NotRequired[float],
|
|
4343
|
+
})
|
|
4344
|
+
|
|
4345
|
+
RecognitionBiasSetResponse = TypedDict("RecognitionBiasSetResponse", {
|
|
4346
|
+
"enabled": bool,
|
|
4347
|
+
"provenance": str,
|
|
4348
|
+
"strength": float,
|
|
4349
|
+
})
|
|
4350
|
+
|
|
4323
4351
|
RecognitionRedecodeRequest = TypedDict("RecognitionRedecodeRequest", {
|
|
4324
4352
|
"items": list["RedecodeItem"],
|
|
4325
4353
|
"max_active": NotRequired[int],
|
|
@@ -4738,6 +4766,7 @@ PipelineWarmedEventParams = TypedDict("PipelineWarmedEventParams", {
|
|
|
4738
4766
|
PluginDegradedEventParams = TypedDict("PluginDegradedEventParams", {
|
|
4739
4767
|
"consecutive_timeouts": int,
|
|
4740
4768
|
"plugin_id": str,
|
|
4769
|
+
"reason": NotRequired[str],
|
|
4741
4770
|
})
|
|
4742
4771
|
|
|
4743
4772
|
# Payload of the `_platform.plugin.disabled` event.
|
|
@@ -138,8 +138,11 @@ def confirm_button(
|
|
|
138
138
|
confirm_click = f"{method_post(method, payload_str)}; {sig} = false"
|
|
139
139
|
if then:
|
|
140
140
|
confirm_click += f"; {then}"
|
|
141
|
-
|
|
142
|
-
|
|
141
|
+
# The confirm click is the destructive one; `danger` is the platform
|
|
142
|
+
# stylesheet's variant class (button.danger), never an inline colour.
|
|
143
|
+
danger_class = f"{class_ or ''} danger".strip()
|
|
144
|
+
confirm = _button(confirm_text, confirm_click, danger_class, style)
|
|
145
|
+
|
|
143
146
|
cancel = _button("Cancel", f"{sig} = false", class_, style)
|
|
144
147
|
return (
|
|
145
148
|
f'<span data-signals:c_{sig_key}__ifmissing="false">'
|
|
@@ -147,3 +150,11 @@ def confirm_button(
|
|
|
147
150
|
f'<span data-show="{sig}" style="display:none;">{confirm}{cancel}</span>'
|
|
148
151
|
f"</span>"
|
|
149
152
|
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def js(v: Any) -> str:
|
|
156
|
+
"""Marshal one value to a JavaScript literal for a Datastar expression —
|
|
157
|
+
a string becomes a quoted string, a bool ``true``, a number itself. The
|
|
158
|
+
value-position twin of ``args``: a user string spliced into
|
|
159
|
+
``$sig = '...'`` must not be able to end the quote."""
|
|
160
|
+
return json.dumps(v)
|
|
@@ -42,7 +42,8 @@ class TestCommandBuilder(unittest.TestCase):
|
|
|
42
42
|
.build()
|
|
43
43
|
)
|
|
44
44
|
self.assertEqual(spec["pattern"], ["open", ["tab", "window"], "<t:ts>", "<n:text>"])
|
|
45
|
-
|
|
45
|
+
# One params dialect: the payload nests under "params", never flat.
|
|
46
|
+
self.assertEqual(spec["action"], {"type": "browser.open", "params": {"force": True}})
|
|
46
47
|
self.assertEqual(spec["requires_tags"], ["a"])
|
|
47
48
|
self.assertTrue(spec["cancels_bridge"])
|
|
48
49
|
self.assertEqual(spec["discovery"], "exclusive")
|
|
@@ -150,6 +151,15 @@ class TestUi(unittest.TestCase):
|
|
|
150
151
|
html = ui.confirm_button("Delete", "del_item", payload={"id": "x"})
|
|
151
152
|
self.assertIn("__ifmissing", html)
|
|
152
153
|
self.assertIn("Really delete?", html)
|
|
154
|
+
self.assertIn('class="danger"', html)
|
|
155
|
+
self.assertNotIn("#c44", html)
|
|
156
|
+
self.assertIn('class="sc-btn danger"', ui.confirm_button("Delete", "d", class_="sc-btn"))
|
|
157
|
+
|
|
158
|
+
def test_js_marshals_values(self):
|
|
159
|
+
self.assertEqual(ui.js("it's"), '"it\'s"')
|
|
160
|
+
self.assertEqual(ui.js(True), "true")
|
|
161
|
+
self.assertEqual(ui.js(3), "3")
|
|
162
|
+
|
|
153
163
|
|
|
154
164
|
|
|
155
165
|
class TestListOpts(unittest.TestCase):
|
|
@@ -299,3 +309,17 @@ class TestListenerHelpers(unittest.TestCase):
|
|
|
299
309
|
|
|
300
310
|
if __name__ == "__main__":
|
|
301
311
|
unittest.main()
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class TestDetachedPlugin(unittest.IsolatedAsyncioTestCase):
|
|
315
|
+
"""The no-platform contract tests build hosts on: calls raise at once,
|
|
316
|
+
notify and run are inert, a settings mirror can be constructed."""
|
|
317
|
+
|
|
318
|
+
async def test_detached_contract(self):
|
|
319
|
+
p = branchkit.Plugin(detached=True)
|
|
320
|
+
with self.assertRaises(branchkit.DetachedError):
|
|
321
|
+
await p.call("collection.get", {"name": "x"})
|
|
322
|
+
p.notify("events.emit", {"x": 1}) # goes nowhere, raises nothing
|
|
323
|
+
m = p.settings("plugin.test.config")
|
|
324
|
+
self.assertFalse(m.ready)
|
|
325
|
+
await asyncio.wait_for(p.run(), 1.0)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""The SDK-owned render_settings hook: one renderer per tab key, the
|
|
2
|
+
registered stylesheet on every response, an error for a key nobody
|
|
3
|
+
registered, and every settings mirror refreshed before the tab draws."""
|
|
4
|
+
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
import branchkit
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def fake_plugin(store: dict) -> branchkit.Plugin:
|
|
11
|
+
"""A Plugin whose actuator calls are answered in-process: the composed
|
|
12
|
+
settings read serves `store`."""
|
|
13
|
+
p = branchkit.Plugin()
|
|
14
|
+
|
|
15
|
+
async def call(method, params=None, *args, **kwargs):
|
|
16
|
+
if method == "collection.get":
|
|
17
|
+
return {"name": params["name"], "data": store}
|
|
18
|
+
raise AssertionError(f"unexpected method {method}")
|
|
19
|
+
|
|
20
|
+
p.call = call # type: ignore[method-assign]
|
|
21
|
+
return p
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TestSettingsTabs(unittest.IsolatedAsyncioTestCase):
|
|
25
|
+
async def render(self, p, tab_key):
|
|
26
|
+
fn = p._handlers["render_settings"]
|
|
27
|
+
return await fn({"tab_key": tab_key, "search": ""})
|
|
28
|
+
|
|
29
|
+
async def test_dispatch_css_and_read_through(self):
|
|
30
|
+
store = {"editor": "stale"}
|
|
31
|
+
p = fake_plugin(store)
|
|
32
|
+
mirror = p.settings("plugin.test.config")
|
|
33
|
+
p.settings_css(".alpha{}")
|
|
34
|
+
|
|
35
|
+
@p.settings_tab("alpha")
|
|
36
|
+
def alpha(req): # plain def — offloaded like any handler
|
|
37
|
+
return f'<p id="alpha">{(mirror.get() or {}).get("editor", "")}</p>'
|
|
38
|
+
|
|
39
|
+
@p.settings_tab("beta")
|
|
40
|
+
async def beta(req):
|
|
41
|
+
return f'<p id="beta">{req["tab_key"]}</p>'
|
|
42
|
+
|
|
43
|
+
# The store moved behind the mirror's back (no collection.updated);
|
|
44
|
+
# the render must still see the current value.
|
|
45
|
+
store["editor"] = "fresh"
|
|
46
|
+
resp = await self.render(p, "alpha")
|
|
47
|
+
self.assertEqual(resp, {"html": '<p id="alpha">fresh</p>', "css": ".alpha{}"})
|
|
48
|
+
resp = await self.render(p, "beta")
|
|
49
|
+
self.assertEqual(resp["html"], '<p id="beta">beta</p>')
|
|
50
|
+
with self.assertRaisesRegex(RuntimeError, '"nope"'):
|
|
51
|
+
await self.render(p, "nope")
|
|
52
|
+
|
|
53
|
+
async def test_renderer_error_propagates(self):
|
|
54
|
+
p = fake_plugin({})
|
|
55
|
+
|
|
56
|
+
@p.settings_tab("broken")
|
|
57
|
+
def broken(req):
|
|
58
|
+
raise ValueError("cannot draw")
|
|
59
|
+
|
|
60
|
+
with self.assertRaisesRegex(ValueError, "cannot draw"):
|
|
61
|
+
await self.render(p, "broken")
|
|
62
|
+
|
|
63
|
+
# The tab API is the only way in: a hand-written render_settings handler
|
|
64
|
+
# raises at registration whether or not a tab was registered first.
|
|
65
|
+
def test_handle_rejects_render_settings(self):
|
|
66
|
+
a = fake_plugin({})
|
|
67
|
+
with self.assertRaisesRegex(RuntimeError, "settings_tab"):
|
|
68
|
+
a.handle("render_settings", lambda params: {})
|
|
69
|
+
b = fake_plugin({})
|
|
70
|
+
b.settings_tab("x", lambda req: "")
|
|
71
|
+
with self.assertRaisesRegex(RuntimeError, "settings_tab"):
|
|
72
|
+
b.handle("render_settings", lambda params: {})
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if __name__ == "__main__":
|
|
76
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|