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.
Files changed (36) hide show
  1. {branchkit-0.1.1 → branchkit-0.2.0}/PKG-INFO +1 -1
  2. {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/__init__.py +2 -0
  3. {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/commands.py +10 -6
  4. {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/contracts_gen.py +3 -0
  5. {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/log_events_gen.py +1 -1
  6. {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/methods_gen.py +29 -1
  7. {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/plugin.py +48 -5
  8. {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/settings.py +70 -7
  9. {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/types_gen.py +29 -0
  10. {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/ui.py +13 -2
  11. {branchkit-0.1.1 → branchkit-0.2.0}/branchkit.egg-info/PKG-INFO +1 -1
  12. {branchkit-0.1.1 → branchkit-0.2.0}/branchkit.egg-info/SOURCES.txt +2 -1
  13. {branchkit-0.1.1 → branchkit-0.2.0}/pyproject.toml +1 -1
  14. {branchkit-0.1.1 → branchkit-0.2.0}/tests/test_kernel.py +25 -1
  15. branchkit-0.2.0/tests/test_settings_tabs.py +76 -0
  16. {branchkit-0.1.1 → branchkit-0.2.0}/LICENSE +0 -0
  17. {branchkit-0.1.1 → branchkit-0.2.0}/README.md +0 -0
  18. {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/actor.py +0 -0
  19. {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/closed_vocab_gen.py +0 -0
  20. {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/collection.py +0 -0
  21. {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/collection_log.py +0 -0
  22. {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/correlation.py +0 -0
  23. {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/debug.py +0 -0
  24. {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/effects.py +0 -0
  25. {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/harness.py +0 -0
  26. {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/hud.py +0 -0
  27. {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/listen.py +0 -0
  28. {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/log.py +0 -0
  29. {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/mirror.py +0 -0
  30. {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/proxy.py +0 -0
  31. {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/settings_route.py +0 -0
  32. {branchkit-0.1.1 → branchkit-0.2.0}/branchkit/upstream.py +0 -0
  33. {branchkit-0.1.1 → branchkit-0.2.0}/branchkit.egg-info/dependency_links.txt +0 -0
  34. {branchkit-0.1.1 → branchkit-0.2.0}/branchkit.egg-info/top_level.txt +0 -0
  35. {branchkit-0.1.1 → branchkit-0.2.0}/setup.cfg +0 -0
  36. {branchkit-0.1.1 → branchkit-0.2.0}/tests/test_collection.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: branchkit
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: BranchKit plugin SDK for Python
5
5
  Author: BranchKit
6
6
  License-Expression: MIT
@@ -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` are merged
73
- into the action object."""
74
- self._spec["action"] = {"type": type, **(params or {})}
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" or
106
- "exclusive"). Valid only on a literal-prefix + single-tail-capture
107
- pattern. See docs/design/DESIGN_DISCOVERABLE_PREFIX.md."""
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 managed plugin's RPC channel hit the consecutive-timeout threshold and was marked degraded.", "since": "0.1.0", "source": "plugins", "severity": "warn", "redaction": "none"},
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
- self._plugin_id: str = os.environ.get("BRANCHKIT_PLUGIN_ID", "unknown")
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
- danger_style = f"{style or ''}color:#c44;border-color:#c44;"
142
- confirm = _button(confirm_text, confirm_click, class_, danger_style)
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: branchkit
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: BranchKit plugin SDK for Python
5
5
  Author: BranchKit
6
6
  License-Expression: MIT
@@ -30,4 +30,5 @@ branchkit.egg-info/SOURCES.txt
30
30
  branchkit.egg-info/dependency_links.txt
31
31
  branchkit.egg-info/top_level.txt
32
32
  tests/test_collection.py
33
- tests/test_kernel.py
33
+ tests/test_kernel.py
34
+ tests/test_settings_tabs.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "branchkit"
7
- version = "0.1.1"
7
+ version = "0.2.0"
8
8
  description = "BranchKit plugin SDK for Python"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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
- self.assertEqual(spec["action"], {"type": "browser.open", "force": True})
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