bowire-plugin 0.3.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.
@@ -0,0 +1,258 @@
1
+ # Copyright 2026 Küstenlogik
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """The JSON-RPC dispatch + stdio runtime that drive a :class:`BowirePlugin`.
4
+
5
+ Wire contract: NDJSON (one JSON-RPC 2.0 envelope per ``\\n``-terminated
6
+ UTF-8 line) over the process's stdin/stdout, exactly the framing Bowire's
7
+ host (and MCP's stdio transport) use. See the Bowire repo's
8
+ ``docs/architecture/sidecar-plugins.md`` for the full spec.
9
+
10
+ The transport-agnostic dispatch lives in :class:`_Dispatcher`; the stdio
11
+ runtime and the HTTP/SSE server (in ``_http``) both reuse it.
12
+
13
+ Methods handled: initialize, ping, shutdown, discover, invoke,
14
+ invokeStream (host-minted streamId in params; ack then emit
15
+ ``$/stream/data`` notifications + ``$/stream/end``).
16
+
17
+ The ``initialize`` reply carries ``protocolVersion`` +
18
+ ``capabilities`` (#416), so the host knows which contract it is talking to
19
+ and which calls it can skip.
20
+
21
+ ``discover`` / ``invoke`` / ``invokeStream`` params may carry a ``settings``
22
+ object (Kuestenlogik/Bowire#693): the values set for the settings the plugin
23
+ declared. Every call hands the plugin a fresh :class:`Settings` built from it,
24
+ so a changed value applies from the next call on.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import sys
30
+ import threading
31
+ from collections.abc import Callable
32
+ from typing import Any, TextIO
33
+
34
+ from ._models import PluginSetting
35
+ from ._plugin import BowirePlugin
36
+ from ._settings import Settings
37
+
38
+ # A notification sink: hands a fully-formed JSON-RPC notification
39
+ # envelope to whichever transport is in play (stdout line / SSE event).
40
+ EmitFn = Callable[[dict[str, Any]], None]
41
+
42
+ #: The sidecar wire-contract version this SDK speaks (#416). The host
43
+ #: accepts a sidecar inside its supported range and refuses one outside it
44
+ #: at the handshake, rather than failing at the first call. A sidecar that
45
+ #: sends none at all is tolerated as contract v1 — with a warning in the
46
+ #: host log on every boot, which is what this SDK used to earn.
47
+ SIDECAR_PROTOCOL_VERSION = 1
48
+
49
+
50
+ def _capabilities(plugin: BowirePlugin) -> dict[str, bool]:
51
+ """What this plugin can actually answer, read off the subclass.
52
+
53
+ A flag set to ``False`` lets the host skip the call entirely. The base
54
+ class ships a polite default for every method — ``discover`` returns
55
+ ``[]``, ``invoke`` an "not implemented" result — so "did the author
56
+ override it?" is the only honest answer to "can it do this?", and the
57
+ host gets to stop asking questions whose answer is a stub.
58
+
59
+ ``channels`` is always ``False``: the Python SDK has no channel surface
60
+ at all, so the host's ``openChannel`` would round-trip to a method-not-
61
+ found every time an operator opened a duplex method.
62
+ """
63
+ cls = type(plugin)
64
+ return {
65
+ "discover": cls.discover is not BowirePlugin.discover,
66
+ "invoke": cls.invoke is not BowirePlugin.invoke,
67
+ "invokeStream": cls.invoke_stream is not BowirePlugin.invoke_stream,
68
+ "channels": False,
69
+ }
70
+
71
+
72
+ class _Dispatcher:
73
+ """Transport-agnostic JSON-RPC dispatch over a :class:`BowirePlugin`.
74
+
75
+ :meth:`handle` turns one request into its reply envelope (or
76
+ ``None`` for notifications / post-shutdown). Streaming frames are
77
+ pushed through the injected :data:`EmitFn` so stdio and HTTP route
78
+ them to their own wire.
79
+ """
80
+
81
+ def __init__(self, plugin: BowirePlugin, emit: EmitFn) -> None:
82
+ self._plugin = plugin
83
+ self._emit = emit
84
+ self._stop = threading.Event()
85
+ self._stream_threads: list[threading.Thread] = []
86
+ self._declared: list[PluginSetting] | None = None
87
+
88
+ @property
89
+ def stopped(self) -> bool:
90
+ return self._stop.is_set()
91
+
92
+ def _declared_settings(self) -> list[PluginSetting]:
93
+ """What the plugin declared, asked once: the ``initialize`` reply and
94
+ every call's defaults have to agree on it. Built lazily rather than in
95
+ ``__init__`` so a plugin whose ``settings()`` raises fails the
96
+ handshake as a JSON-RPC error instead of the process start."""
97
+ if self._declared is None:
98
+ self._declared = list(self._plugin.settings())
99
+ return self._declared
100
+
101
+ def _call_settings(self, params: dict[str, Any]) -> Settings:
102
+ # Absent when nobody set anything — the common case, not an error.
103
+ raw = params.get("settings")
104
+ return Settings(raw if isinstance(raw, dict) else None, self._declared_settings())
105
+
106
+ def join_streams(self, timeout: float = 5) -> None:
107
+ """Let in-flight stream pumps flush their final frames on exit."""
108
+ for t in self._stream_threads:
109
+ t.join(timeout=timeout)
110
+
111
+ def handle(
112
+ self, method: str | None, req_id: Any, params: dict[str, Any]
113
+ ) -> tuple[dict[str, Any] | None, Callable[[], None] | None]:
114
+ """Return ``(reply, post_action)``. The caller writes ``reply``
115
+ (nothing if ``None``), then runs ``post_action`` if present.
116
+ Streaming starts in ``post_action`` so the ack is always on the
117
+ wire before the first ``$/stream/data`` frame."""
118
+ try:
119
+ result, post = self._invoke_method(method, params)
120
+ except _MethodNotFound:
121
+ return (None if req_id is None else _err(req_id, -32601, f"method not found: {method}")), None
122
+ except Exception as ex: # noqa: BLE001 — surface as JSON-RPC error, never crash the loop
123
+ return (None if req_id is None else _err(req_id, -32000, f"{type(ex).__name__}: {ex}")), None
124
+ reply = None if req_id is None else {"jsonrpc": "2.0", "id": req_id, "result": result}
125
+ return reply, post
126
+
127
+ def _invoke_method(
128
+ self, method: str | None, params: dict[str, Any]
129
+ ) -> tuple[Any, Callable[[], None] | None]:
130
+ if method == "initialize":
131
+ reply: dict[str, Any] = {
132
+ "name": self._plugin.name,
133
+ "id": self._plugin.id,
134
+ "iconSvg": self._plugin.icon_svg,
135
+ "settings": [s.to_dict() for s in self._declared_settings()],
136
+ "protocolVersion": SIDECAR_PROTOCOL_VERSION,
137
+ "capabilities": _capabilities(self._plugin),
138
+ }
139
+ # Kuestenlogik/Bowire#1003 - absent when the plugin declares none.
140
+ form = self._plugin.manual_entry()
141
+ if form is not None:
142
+ reply["manualEntry"] = form.to_dict()
143
+ return reply, None
144
+ if method == "ping":
145
+ return "pong", None
146
+ if method == "shutdown":
147
+ try:
148
+ self._plugin.shutdown()
149
+ finally:
150
+ self._stop.set()
151
+ return True, None
152
+ if method == "discover":
153
+ services = self._plugin.discover(
154
+ params.get("serverUrl", ""),
155
+ bool(params.get("showInternalServices", False)),
156
+ self._call_settings(params),
157
+ )
158
+ return [s.to_dict() for s in services], None
159
+ if method == "invoke":
160
+ return self._plugin.invoke(
161
+ params.get("serverUrl", ""),
162
+ params.get("service", ""),
163
+ params.get("method", ""),
164
+ list(params.get("jsonMessages") or []),
165
+ bool(params.get("showInternalServices", False)),
166
+ dict(params.get("metadata") or {}),
167
+ self._call_settings(params),
168
+ ).to_dict(), None
169
+ if method == "invokeStream":
170
+ stream_id = params.get("streamId")
171
+
172
+ def start_pump() -> None:
173
+ t = threading.Thread(target=self._pump_stream, args=(stream_id, params), daemon=True)
174
+ self._stream_threads.append(t)
175
+ t.start()
176
+
177
+ # ack now; the caller starts the pump after writing it
178
+ return {"streamId": stream_id}, start_pump
179
+ raise _MethodNotFound
180
+
181
+ def _pump_stream(self, stream_id: Any, params: dict[str, Any]) -> None:
182
+ error: dict[str, Any] | None = None
183
+ try:
184
+ for message in self._plugin.invoke_stream(
185
+ params.get("serverUrl", ""),
186
+ params.get("service", ""),
187
+ params.get("method", ""),
188
+ list(params.get("jsonMessages") or []),
189
+ bool(params.get("showInternalServices", False)),
190
+ dict(params.get("metadata") or {}),
191
+ self._call_settings(params),
192
+ ):
193
+ if self._stop.is_set():
194
+ break
195
+ self._emit({"jsonrpc": "2.0", "method": "$/stream/data",
196
+ "params": {"streamId": stream_id, "message": message}})
197
+ except Exception as ex: # noqa: BLE001
198
+ error = {"code": -32000, "message": f"{type(ex).__name__}: {ex}"}
199
+ finally:
200
+ self._emit({"jsonrpc": "2.0", "method": "$/stream/end",
201
+ "params": {"streamId": stream_id, "error": error}})
202
+
203
+
204
+ class _MethodNotFound(Exception):
205
+ pass
206
+
207
+
208
+ def _err(req_id: Any, code: int, message: str) -> dict[str, Any]:
209
+ return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}
210
+
211
+
212
+ class _StdioRuntime:
213
+ """NDJSON JSON-RPC over stdin/stdout, driving a :class:`_Dispatcher`."""
214
+
215
+ def __init__(self, plugin: BowirePlugin, stdin: TextIO, stdout: TextIO) -> None:
216
+ self._stdin = stdin
217
+ self._stdout = stdout
218
+ self._write_lock = threading.Lock()
219
+ self._dispatcher = _Dispatcher(plugin, self._write)
220
+
221
+ def _write(self, envelope: dict[str, Any]) -> None:
222
+ line = json.dumps(envelope, separators=(",", ":"), ensure_ascii=False)
223
+ with self._write_lock:
224
+ self._stdout.write(line)
225
+ self._stdout.write("\n")
226
+ self._stdout.flush()
227
+
228
+ def run(self) -> int:
229
+ for raw in self._stdin:
230
+ line = raw.strip()
231
+ if not line:
232
+ continue
233
+ try:
234
+ msg = json.loads(line)
235
+ except json.JSONDecodeError:
236
+ continue
237
+ if not isinstance(msg, dict):
238
+ continue
239
+
240
+ reply, post = self._dispatcher.handle(msg.get("method"), msg.get("id"), msg.get("params") or {})
241
+ if reply is not None:
242
+ self._write(reply)
243
+ if post is not None:
244
+ post() # start streaming only after the ack is on the wire
245
+ if self._dispatcher.stopped:
246
+ break
247
+
248
+ self._dispatcher.join_streams()
249
+ return 0
250
+
251
+
252
+ def run(plugin: BowirePlugin, *, stdin: TextIO | None = None, stdout: TextIO | None = None) -> int:
253
+ """Run ``plugin`` against the JSON-RPC-over-stdio contract until the
254
+ host sends ``shutdown`` (or stdin closes). Blocks; returns 0.
255
+
256
+ For the HTTP/SSE transport use :func:`bowire_plugin.run_http`.
257
+ """
258
+ return _StdioRuntime(plugin, stdin or sys.stdin, stdout or sys.stdout).run()
@@ -0,0 +1,203 @@
1
+ # Copyright 2026 Küstenlogik
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """The setting values the host sends with each call (Kuestenlogik/Bowire#693).
4
+
5
+ A plugin declares its settings once, in :meth:`BowirePlugin.settings`; the
6
+ host renders them in its Settings dialog and, on every ``discover`` /
7
+ ``invoke`` / ``invokeStream``, puts the values somebody actually set in a
8
+ ``settings`` object on the params. This module is the other half: what a
9
+ plugin reads those values through.
10
+
11
+ Three properties of the wire shape the API:
12
+
13
+ - **Per call, not per process.** The values travel with each request, so a
14
+ changed setting takes effect on the next call without a restart. The
15
+ runtime builds a fresh :class:`Settings` for every call.
16
+ - **Strings, whatever the declared type.** The dialog sends what a form
17
+ control produced; the plugin parses what it declared. The typed accessors
18
+ do that parse, with the same rules the host's own .NET accessors use.
19
+ - **Only keys that are set.** An absent key means "use the default you
20
+ declared" — so the accessors fall back to the declared ``default_value``,
21
+ and a plugin whose settings nobody touched sees an empty mapping.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import math
26
+ import re
27
+ from collections.abc import Callable, Iterable, Iterator, Mapping
28
+ from typing import Any, TypeVar
29
+
30
+ from ._models import PluginSetting
31
+
32
+ _T = TypeVar("_T")
33
+
34
+ # ASCII digits only: Python's int()/float() also accept other Unicode digits,
35
+ # underscores, "nan" and "inf", none of which a form control produces and none
36
+ # of which the host's invariant-culture parse accepts.
37
+ _INT = re.compile(r"[+-]?[0-9]+", re.ASCII)
38
+ _FLOAT = re.compile(r"[+-]?(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][+-]?[0-9]+)?", re.ASCII)
39
+
40
+
41
+ class _Unset:
42
+ """Marks "no explicit default given" — ``None`` is a legal default."""
43
+
44
+ def __repr__(self) -> str:
45
+ return "<declared default>"
46
+
47
+
48
+ _UNSET = _Unset()
49
+
50
+
51
+ def _parse_bool(text: str) -> bool | None:
52
+ t = text.strip().lower()
53
+ if t == "true":
54
+ return True
55
+ if t == "false":
56
+ return False
57
+ return None
58
+
59
+
60
+ def _parse_int(text: str) -> int | None:
61
+ t = text.strip()
62
+ return int(t) if _INT.fullmatch(t) else None
63
+
64
+
65
+ def _parse_float(text: str) -> float | None:
66
+ t = text.strip()
67
+ if not _FLOAT.fullmatch(t):
68
+ return None
69
+ value = float(t)
70
+ return value if math.isfinite(value) else None
71
+
72
+
73
+ # The declared default is any JSON value the plugin wrote, so it is coerced
74
+ # rather than parsed: ``default_value=5`` for a number setting is an int, not
75
+ # the string "5". A bool is never taken as a number, although Python's bool
76
+ # is an int — ``True`` as a probe window is a mistake, not ``1``.
77
+
78
+ def _coerce_str(value: Any) -> str | None:
79
+ if isinstance(value, str):
80
+ return value
81
+ if isinstance(value, bool):
82
+ return "true" if value else "false"
83
+ if isinstance(value, (int, float)):
84
+ return str(value)
85
+ return None
86
+
87
+
88
+ def _coerce_bool(value: Any) -> bool | None:
89
+ if isinstance(value, bool):
90
+ return value
91
+ return _parse_bool(value) if isinstance(value, str) else None
92
+
93
+
94
+ def _coerce_int(value: Any) -> int | None:
95
+ if isinstance(value, bool):
96
+ return None
97
+ if isinstance(value, int):
98
+ return value
99
+ if isinstance(value, float):
100
+ return int(value) if value.is_integer() else None
101
+ return _parse_int(value) if isinstance(value, str) else None
102
+
103
+
104
+ def _coerce_float(value: Any) -> float | None:
105
+ if isinstance(value, bool):
106
+ return None
107
+ if isinstance(value, (int, float)):
108
+ return float(value)
109
+ return _parse_float(value) if isinstance(value, str) else None
110
+
111
+
112
+ class Settings(Mapping[str, str]):
113
+ """The values set for this plugin's declared settings, for one call.
114
+
115
+ A read-only mapping of the raw strings the host sent — ``settings["key"]``,
116
+ ``"key" in settings``, ``settings.get("key")`` — plus typed accessors
117
+ that parse the string and fall back to the default the plugin declared::
118
+
119
+ def invoke(self, server_url, service, method, json_messages,
120
+ show_internal, metadata, settings):
121
+ window = settings.get_float("probeWindow") # 5.0 until somebody sets it
122
+ verbose = settings.get_bool("verbose")
123
+
124
+ Each accessor resolves in this order:
125
+
126
+ 1. the value the host sent, when it parses as the requested type;
127
+ 2. ``default``, when the caller passed one;
128
+ 3. the ``default_value`` declared for that key in
129
+ :meth:`BowirePlugin.settings`;
130
+ 4. otherwise :class:`KeyError` — the key has no value and nothing to fall
131
+ back to, which is a bug in the plugin rather than in its input.
132
+
133
+ A value that does not parse falls back exactly like an absent one, which
134
+ is what the host's .NET accessors do: an operator's typo in a probe
135
+ window degrades to the default instead of failing every discovery.
136
+ """
137
+
138
+ def __init__(
139
+ self,
140
+ values: Mapping[str, Any] | None = None,
141
+ declared: Iterable[PluginSetting] = (),
142
+ ) -> None:
143
+ # Strings on the wire; a number or bool is tolerated the way the host
144
+ # tolerates one in a hand-edited settings file. Anything else has no
145
+ # text form and is dropped rather than guessed at.
146
+ self._values: dict[str, str] = {}
147
+ for key, value in (values or {}).items():
148
+ text = _coerce_str(value)
149
+ if isinstance(key, str) and text is not None:
150
+ self._values[key] = text
151
+ self._defaults: dict[str, Any] = {s.key: s.default_value for s in declared}
152
+
153
+ def __getitem__(self, key: str) -> str:
154
+ return self._values[key]
155
+
156
+ def __iter__(self) -> Iterator[str]:
157
+ return iter(self._values)
158
+
159
+ def __len__(self) -> int:
160
+ return len(self._values)
161
+
162
+ def __repr__(self) -> str:
163
+ return f"Settings({self._values!r})"
164
+
165
+ def get_str(self, key: str, default: str | _Unset = _UNSET) -> str:
166
+ """The value as text. An empty string is a value someone set, not an
167
+ absent one."""
168
+ return self._resolve(key, _coerce_str, default, "a string")
169
+
170
+ def get_bool(self, key: str, default: bool | _Unset = _UNSET) -> bool:
171
+ """The value as a flag: ``true`` / ``false``, case-insensitive."""
172
+ return self._resolve(key, _coerce_bool, default, "a bool")
173
+
174
+ def get_int(self, key: str, default: int | _Unset = _UNSET) -> int:
175
+ """The value as a whole number (ASCII digits, optional sign)."""
176
+ return self._resolve(key, _coerce_int, default, "an int")
177
+
178
+ def get_float(self, key: str, default: float | _Unset = _UNSET) -> float:
179
+ """The value as a finite number, decimal point and exponent allowed."""
180
+ return self._resolve(key, _coerce_float, default, "a float")
181
+
182
+ def _resolve(
183
+ self,
184
+ key: str,
185
+ convert: Callable[[Any], _T | None],
186
+ default: _T | _Unset,
187
+ kind: str,
188
+ ) -> _T:
189
+ raw = self._values.get(key)
190
+ if raw is not None:
191
+ parsed = convert(raw)
192
+ if parsed is not None:
193
+ return parsed
194
+ if not isinstance(default, _Unset):
195
+ return default
196
+ if key in self._defaults:
197
+ declared = convert(self._defaults[key])
198
+ if declared is not None:
199
+ return declared
200
+ raise KeyError(
201
+ f"setting '{key}' has no value that reads as {kind} and declares no default "
202
+ f"that does; declare one in settings() or pass default="
203
+ )