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.
- bowire_plugin/__init__.py +69 -0
- bowire_plugin/_http.py +127 -0
- bowire_plugin/_manual_entry.py +165 -0
- bowire_plugin/_models.py +194 -0
- bowire_plugin/_plugin.py +95 -0
- bowire_plugin/_runtime.py +258 -0
- bowire_plugin/_settings.py +203 -0
- bowire_plugin-0.3.0.dist-info/METADATA +486 -0
- bowire_plugin-0.3.0.dist-info/RECORD +11 -0
- bowire_plugin-0.3.0.dist-info/WHEEL +4 -0
- bowire_plugin-0.3.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Copyright 2026 Küstenlogik
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""bowire-plugin — write a Bowire protocol plugin in Python.
|
|
4
|
+
|
|
5
|
+
A *sidecar* plugin is any executable that speaks JSON-RPC 2.0 over its
|
|
6
|
+
stdin/stdout to the Bowire host. This package hides the wire: subclass
|
|
7
|
+
:class:`BowirePlugin`, implement the protocol methods you need, and call
|
|
8
|
+
:func:`run`.
|
|
9
|
+
|
|
10
|
+
from bowire_plugin import BowirePlugin, ServiceInfo, MethodInfo, InvokeResult, run
|
|
11
|
+
|
|
12
|
+
class MyPlugin(BowirePlugin):
|
|
13
|
+
id = "myproto"
|
|
14
|
+
name = "MyProtocol"
|
|
15
|
+
|
|
16
|
+
def discover(self, server_url, show_internal, settings):
|
|
17
|
+
return [ServiceInfo("Things", methods=[MethodInfo("get")])]
|
|
18
|
+
|
|
19
|
+
def invoke(self, server_url, service, method, json_messages, show_internal, metadata,
|
|
20
|
+
settings):
|
|
21
|
+
return InvokeResult(response="hello", status="OK")
|
|
22
|
+
|
|
23
|
+
if __name__ == "__main__":
|
|
24
|
+
run(MyPlugin())
|
|
25
|
+
|
|
26
|
+
Ship the script + a ``sidecar.json`` manifest in a zip and install it
|
|
27
|
+
with ``bowire plugin install --file my-plugin.zip``.
|
|
28
|
+
"""
|
|
29
|
+
from ._http import run_http
|
|
30
|
+
from ._manual_entry import (
|
|
31
|
+
ManualCallKind,
|
|
32
|
+
ManualEntryForm,
|
|
33
|
+
ManualField,
|
|
34
|
+
manual_entry_problems,
|
|
35
|
+
)
|
|
36
|
+
from ._models import (
|
|
37
|
+
FieldInfo,
|
|
38
|
+
InvokeResult,
|
|
39
|
+
MessageInfo,
|
|
40
|
+
MethodInfo,
|
|
41
|
+
PluginSetting,
|
|
42
|
+
ServiceInfo,
|
|
43
|
+
SettingOption,
|
|
44
|
+
)
|
|
45
|
+
from ._plugin import BowirePlugin
|
|
46
|
+
from ._runtime import SIDECAR_PROTOCOL_VERSION, run
|
|
47
|
+
from ._settings import Settings
|
|
48
|
+
|
|
49
|
+
__version__ = "0.3.0"
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"SIDECAR_PROTOCOL_VERSION",
|
|
53
|
+
"BowirePlugin",
|
|
54
|
+
"FieldInfo",
|
|
55
|
+
"InvokeResult",
|
|
56
|
+
"ManualCallKind",
|
|
57
|
+
"ManualEntryForm",
|
|
58
|
+
"ManualField",
|
|
59
|
+
"MessageInfo",
|
|
60
|
+
"MethodInfo",
|
|
61
|
+
"PluginSetting",
|
|
62
|
+
"ServiceInfo",
|
|
63
|
+
"SettingOption",
|
|
64
|
+
"Settings",
|
|
65
|
+
"__version__",
|
|
66
|
+
"manual_entry_problems",
|
|
67
|
+
"run",
|
|
68
|
+
"run_http",
|
|
69
|
+
]
|
bowire_plugin/_http.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Copyright 2026 Küstenlogik
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""HTTP/SSE server transport — the MCP-style streamable-HTTP shape.
|
|
4
|
+
|
|
5
|
+
The same :class:`_Dispatcher` that powers the stdio runtime drives an
|
|
6
|
+
HTTP service here: JSON-RPC requests arrive as ``POST`` (the HTTP
|
|
7
|
+
response body carries the reply); server-initiated notifications
|
|
8
|
+
(``$/stream/data`` / ``$/channel/data`` …) stream back over one
|
|
9
|
+
long-lived SSE ``GET`` on the same path. Bowire's host wires to this
|
|
10
|
+
with a ``"transport": "http"`` sidecar manifest pointing at the URL.
|
|
11
|
+
|
|
12
|
+
Pure stdlib (``http.server``) — no extra dependencies.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import queue
|
|
18
|
+
import threading
|
|
19
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from ._plugin import BowirePlugin
|
|
23
|
+
from ._runtime import _Dispatcher
|
|
24
|
+
|
|
25
|
+
# Sentinel pushed onto the SSE queue to wake the GET loop for shutdown.
|
|
26
|
+
_STOP = object()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _HttpServer:
|
|
30
|
+
"""A running HTTP/SSE sidecar server. Reusable from tests
|
|
31
|
+
(start/port/shutdown); :func:`run_http` wraps it for the blocking
|
|
32
|
+
process entry point."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, plugin: BowirePlugin, host: str = "127.0.0.1", port: int = 8770) -> None:
|
|
35
|
+
# One SSE event queue drained by the connected GET client. emit()
|
|
36
|
+
# from the dispatcher (incl. its stream-pump threads) lands here.
|
|
37
|
+
self._sse: queue.Queue[Any] = queue.Queue()
|
|
38
|
+
self._dispatcher = _Dispatcher(plugin, self._sse.put)
|
|
39
|
+
self._stop = threading.Event()
|
|
40
|
+
|
|
41
|
+
dispatcher = self._dispatcher
|
|
42
|
+
sse = self._sse
|
|
43
|
+
stop = self._stop
|
|
44
|
+
|
|
45
|
+
class Handler(BaseHTTPRequestHandler):
|
|
46
|
+
protocol_version = "HTTP/1.1"
|
|
47
|
+
|
|
48
|
+
def log_message(self, *args: Any) -> None: # silence default stderr logging
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
def do_POST(self) -> None:
|
|
52
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
53
|
+
raw = self.rfile.read(length) if length else b""
|
|
54
|
+
try:
|
|
55
|
+
msg = json.loads(raw)
|
|
56
|
+
except json.JSONDecodeError:
|
|
57
|
+
self._send_json(400, {"error": "invalid json"})
|
|
58
|
+
return
|
|
59
|
+
reply, post = dispatcher.handle(msg.get("method"), msg.get("id"), msg.get("params") or {})
|
|
60
|
+
self._send_json(200, reply if reply is not None else {})
|
|
61
|
+
if post is not None:
|
|
62
|
+
post() # start streaming only after the ack response is sent
|
|
63
|
+
if dispatcher.stopped:
|
|
64
|
+
# shutdown handled — wake the SSE loop + stop serving
|
|
65
|
+
# (on a separate thread; can't stop from a handler).
|
|
66
|
+
stop.set()
|
|
67
|
+
sse.put(_STOP)
|
|
68
|
+
threading.Thread(target=self.server.shutdown, daemon=True).start()
|
|
69
|
+
|
|
70
|
+
def do_GET(self) -> None:
|
|
71
|
+
self.send_response(200)
|
|
72
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
73
|
+
self.send_header("Cache-Control", "no-cache")
|
|
74
|
+
self.send_header("Connection", "keep-alive")
|
|
75
|
+
self.end_headers()
|
|
76
|
+
while not stop.is_set():
|
|
77
|
+
try:
|
|
78
|
+
evt = sse.get(timeout=0.5)
|
|
79
|
+
except queue.Empty:
|
|
80
|
+
continue
|
|
81
|
+
if evt is _STOP:
|
|
82
|
+
break
|
|
83
|
+
try:
|
|
84
|
+
payload = json.dumps(evt, separators=(",", ":"), ensure_ascii=False)
|
|
85
|
+
self.wfile.write(f"data: {payload}\n\n".encode())
|
|
86
|
+
self.wfile.flush()
|
|
87
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
88
|
+
break
|
|
89
|
+
|
|
90
|
+
def _send_json(self, status: int, body: dict[str, Any]) -> None:
|
|
91
|
+
data = json.dumps(body, separators=(",", ":"), ensure_ascii=False).encode()
|
|
92
|
+
self.send_response(status)
|
|
93
|
+
self.send_header("Content-Type", "application/json")
|
|
94
|
+
self.send_header("Content-Length", str(len(data)))
|
|
95
|
+
self.end_headers()
|
|
96
|
+
self.wfile.write(data)
|
|
97
|
+
|
|
98
|
+
self._server = ThreadingHTTPServer((host, port), Handler)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def port(self) -> int:
|
|
102
|
+
return self._server.server_address[1]
|
|
103
|
+
|
|
104
|
+
def serve_forever(self) -> None:
|
|
105
|
+
try:
|
|
106
|
+
self._server.serve_forever()
|
|
107
|
+
finally:
|
|
108
|
+
self._stop.set()
|
|
109
|
+
self._dispatcher.join_streams(timeout=2)
|
|
110
|
+
self._server.server_close()
|
|
111
|
+
|
|
112
|
+
def shutdown(self) -> None:
|
|
113
|
+
self._stop.set()
|
|
114
|
+
self._sse.put(_STOP)
|
|
115
|
+
self._server.shutdown()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def run_http(plugin: BowirePlugin, *, host: str = "127.0.0.1", port: int = 8770) -> int:
|
|
119
|
+
"""Serve ``plugin`` over HTTP/SSE on ``host:port`` until the host
|
|
120
|
+
sends ``shutdown``. Blocks; returns 0.
|
|
121
|
+
|
|
122
|
+
Point a sidecar manifest at it:
|
|
123
|
+
|
|
124
|
+
{ "transport": "http", "url": "http://127.0.0.1:8770/" }
|
|
125
|
+
"""
|
|
126
|
+
_HttpServer(plugin, host, port).serve_forever()
|
|
127
|
+
return 0
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""How a call to the plugin is entered by hand (Kuestenlogik/Bowire#1003).
|
|
2
|
+
|
|
3
|
+
A plugin returns a :class:`ManualEntryForm` from
|
|
4
|
+
:meth:`~bowire_plugin.BowirePlugin.manual_entry`; the SDK puts it in the
|
|
5
|
+
``initialize`` reply as ``manualEntry``, and the workbench renders a Compose
|
|
6
|
+
form from it and offers exactly these method types in its freeform form.
|
|
7
|
+
The wire shape is the Bowire repo's docs/architecture/sidecar-plugins.md,
|
|
8
|
+
"A hand-entry form".
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from ._models import SettingOption
|
|
17
|
+
|
|
18
|
+
#: How a call kind runs: Unary is invoke, ServerStreaming is invokeStream,
|
|
19
|
+
#: ClientStreaming and Duplex open a channel.
|
|
20
|
+
METHOD_TYPES = ("Unary", "ServerStreaming", "ClientStreaming", "Duplex")
|
|
21
|
+
|
|
22
|
+
_PLACEHOLDER = re.compile(r"\{([^{}]+)\}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ManualCallKind:
|
|
27
|
+
"""One kind of call the plugin offers, e.g. produce or consume.
|
|
28
|
+
|
|
29
|
+
``service`` and ``method`` are templates: ``{key}`` is the value of the
|
|
30
|
+
field with that key.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
id: str
|
|
34
|
+
method_type: str
|
|
35
|
+
label: str | None = None
|
|
36
|
+
service: str = ""
|
|
37
|
+
method: str = ""
|
|
38
|
+
description: str | None = None
|
|
39
|
+
body_example: str | None = None
|
|
40
|
+
|
|
41
|
+
def to_dict(self) -> dict[str, Any]:
|
|
42
|
+
d: dict[str, Any] = {
|
|
43
|
+
"id": self.id,
|
|
44
|
+
"label": self.label or self.id,
|
|
45
|
+
"methodType": self.method_type,
|
|
46
|
+
"service": self.service,
|
|
47
|
+
"method": self.method,
|
|
48
|
+
}
|
|
49
|
+
if self.description is not None:
|
|
50
|
+
d["description"] = self.description
|
|
51
|
+
if self.body_example is not None:
|
|
52
|
+
d["bodyExample"] = self.body_example
|
|
53
|
+
return d
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class ManualField:
|
|
58
|
+
"""One field of the form.
|
|
59
|
+
|
|
60
|
+
``placement`` ``"template"`` feeds only the call kinds' templates;
|
|
61
|
+
``"metadata"`` (the default) goes into the call's metadata under ``key``.
|
|
62
|
+
``call_kinds`` limits the field to those kinds; empty means all.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
key: str
|
|
66
|
+
label: str | None = None
|
|
67
|
+
placement: str = "metadata"
|
|
68
|
+
description: str | None = None
|
|
69
|
+
placeholder: str | None = None
|
|
70
|
+
required: bool = False
|
|
71
|
+
type: str = "string"
|
|
72
|
+
options: list[SettingOption] = field(default_factory=list)
|
|
73
|
+
default_value: str | None = None
|
|
74
|
+
call_kinds: list[str] = field(default_factory=list)
|
|
75
|
+
|
|
76
|
+
def to_dict(self) -> dict[str, Any]:
|
|
77
|
+
d: dict[str, Any] = {
|
|
78
|
+
"key": self.key,
|
|
79
|
+
"label": self.label or self.key,
|
|
80
|
+
"placement": self.placement,
|
|
81
|
+
"required": self.required,
|
|
82
|
+
"type": self.type,
|
|
83
|
+
}
|
|
84
|
+
for name, value in (
|
|
85
|
+
("description", self.description),
|
|
86
|
+
("placeholder", self.placeholder),
|
|
87
|
+
("defaultValue", self.default_value),
|
|
88
|
+
):
|
|
89
|
+
if value is not None:
|
|
90
|
+
d[name] = value
|
|
91
|
+
if self.options:
|
|
92
|
+
d["options"] = [o.to_dict() for o in self.options]
|
|
93
|
+
if self.call_kinds:
|
|
94
|
+
d["callKinds"] = list(self.call_kinds)
|
|
95
|
+
return d
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def manual_entry_problems(form: ManualEntryForm) -> list[str]:
|
|
99
|
+
"""What is wrong with a form, or nothing — the check the host runs
|
|
100
|
+
before it serves one."""
|
|
101
|
+
problems: list[str] = []
|
|
102
|
+
if not form.call_kinds:
|
|
103
|
+
problems.append("it offers no call kind")
|
|
104
|
+
|
|
105
|
+
kind_ids: set[str] = set()
|
|
106
|
+
for kind in form.call_kinds:
|
|
107
|
+
if not kind.id:
|
|
108
|
+
problems.append("a call kind has no id")
|
|
109
|
+
elif kind.id in kind_ids:
|
|
110
|
+
problems.append(f"call kind '{kind.id}' is declared twice")
|
|
111
|
+
else:
|
|
112
|
+
kind_ids.add(kind.id)
|
|
113
|
+
if kind.method_type not in METHOD_TYPES:
|
|
114
|
+
problems.append(f"call kind '{kind.id}' has method type '{kind.method_type}'")
|
|
115
|
+
|
|
116
|
+
field_keys: set[str] = set()
|
|
117
|
+
for f in form.fields:
|
|
118
|
+
if not f.key:
|
|
119
|
+
problems.append("a field has no key")
|
|
120
|
+
elif f.key in field_keys:
|
|
121
|
+
problems.append(f"field '{f.key}' is declared twice")
|
|
122
|
+
else:
|
|
123
|
+
field_keys.add(f.key)
|
|
124
|
+
if f.type == "select" and not f.options:
|
|
125
|
+
problems.append(f"select field '{f.key}' has no options")
|
|
126
|
+
for kind_id in f.call_kinds:
|
|
127
|
+
if kind_id not in kind_ids:
|
|
128
|
+
problems.append(f"field '{f.key}' names unknown call kind '{kind_id}'")
|
|
129
|
+
|
|
130
|
+
for kind in form.call_kinds:
|
|
131
|
+
for template in (kind.service, kind.method):
|
|
132
|
+
for key in _PLACEHOLDER.findall(template or ""):
|
|
133
|
+
if key not in field_keys:
|
|
134
|
+
problems.append(
|
|
135
|
+
f"call kind '{kind.id}' uses the placeholder '{key}', which no field declares"
|
|
136
|
+
)
|
|
137
|
+
return problems
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class ManualEntryForm:
|
|
142
|
+
"""How a call to this plugin is entered by hand.
|
|
143
|
+
|
|
144
|
+
A form the host would refuse — an unknown method type, a placeholder no
|
|
145
|
+
field declares, a ``select`` without options — is refused here, when it
|
|
146
|
+
is declared, rather than silently never appearing in the workbench.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
call_kinds: list[ManualCallKind]
|
|
150
|
+
fields: list[ManualField] = field(default_factory=list)
|
|
151
|
+
url_placeholder: str | None = None
|
|
152
|
+
|
|
153
|
+
def __post_init__(self) -> None:
|
|
154
|
+
problems = manual_entry_problems(self)
|
|
155
|
+
if problems:
|
|
156
|
+
raise ValueError("manual entry form: " + "; ".join(problems))
|
|
157
|
+
|
|
158
|
+
def to_dict(self) -> dict[str, Any]:
|
|
159
|
+
d: dict[str, Any] = {
|
|
160
|
+
"callKinds": [k.to_dict() for k in self.call_kinds],
|
|
161
|
+
"fields": [f.to_dict() for f in self.fields],
|
|
162
|
+
}
|
|
163
|
+
if self.url_placeholder is not None:
|
|
164
|
+
d["urlPlaceholder"] = self.url_placeholder
|
|
165
|
+
return d
|
bowire_plugin/_models.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# Copyright 2026 Küstenlogik
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Data models mirroring Bowire's discovery / invocation shapes.
|
|
4
|
+
|
|
5
|
+
The host (a .NET process) deserializes these as camelCase JSON — the
|
|
6
|
+
same `BowireServiceInfo` / `BowireMethodInfo` / `InvokeResult` shapes the
|
|
7
|
+
in-tree .NET plugins emit. The ``to_dict`` methods produce exactly that
|
|
8
|
+
camelCase wire form.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class FieldInfo:
|
|
18
|
+
"""One field of a request/response message (mirrors BowireFieldInfo)."""
|
|
19
|
+
|
|
20
|
+
name: str
|
|
21
|
+
number: int = 0
|
|
22
|
+
type: str = "string"
|
|
23
|
+
label: str = "optional"
|
|
24
|
+
is_map: bool = False
|
|
25
|
+
is_repeated: bool = False
|
|
26
|
+
required: bool = False
|
|
27
|
+
description: str | None = None
|
|
28
|
+
source: str | None = None # REST-style: path/query/header/body
|
|
29
|
+
|
|
30
|
+
def to_dict(self) -> dict[str, Any]:
|
|
31
|
+
d: dict[str, Any] = {
|
|
32
|
+
"name": self.name,
|
|
33
|
+
"number": self.number,
|
|
34
|
+
"type": self.type,
|
|
35
|
+
"label": self.label,
|
|
36
|
+
"isMap": self.is_map,
|
|
37
|
+
"isRepeated": self.is_repeated,
|
|
38
|
+
"messageType": None,
|
|
39
|
+
"enumValues": None,
|
|
40
|
+
"required": self.required,
|
|
41
|
+
}
|
|
42
|
+
if self.description is not None:
|
|
43
|
+
d["description"] = self.description
|
|
44
|
+
if self.source is not None:
|
|
45
|
+
d["source"] = self.source
|
|
46
|
+
return d
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class MessageInfo:
|
|
51
|
+
"""A request or response message type (mirrors BowireMessageInfo)."""
|
|
52
|
+
|
|
53
|
+
name: str
|
|
54
|
+
full_name: str = ""
|
|
55
|
+
fields: list[FieldInfo] = field(default_factory=list)
|
|
56
|
+
|
|
57
|
+
def to_dict(self) -> dict[str, Any]:
|
|
58
|
+
return {
|
|
59
|
+
"name": self.name,
|
|
60
|
+
"fullName": self.full_name or self.name,
|
|
61
|
+
"fields": [f.to_dict() for f in self.fields],
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class MethodInfo:
|
|
67
|
+
"""A single method within a service (mirrors BowireMethodInfo)."""
|
|
68
|
+
|
|
69
|
+
name: str
|
|
70
|
+
full_name: str = ""
|
|
71
|
+
method_type: str = "Unary" # Unary | ServerStreaming | ClientStreaming | Duplex
|
|
72
|
+
input_type: MessageInfo | None = None
|
|
73
|
+
output_type: MessageInfo | None = None
|
|
74
|
+
client_streaming: bool = False
|
|
75
|
+
server_streaming: bool = False
|
|
76
|
+
summary: str | None = None
|
|
77
|
+
description: str | None = None
|
|
78
|
+
|
|
79
|
+
def to_dict(self) -> dict[str, Any]:
|
|
80
|
+
d: dict[str, Any] = {
|
|
81
|
+
"name": self.name,
|
|
82
|
+
"fullName": self.full_name or self.name,
|
|
83
|
+
"clientStreaming": self.client_streaming,
|
|
84
|
+
"serverStreaming": self.server_streaming,
|
|
85
|
+
"inputType": (self.input_type or MessageInfo(self.name + "Input")).to_dict(),
|
|
86
|
+
"outputType": (self.output_type or MessageInfo(self.name + "Output")).to_dict(),
|
|
87
|
+
"methodType": self.method_type,
|
|
88
|
+
}
|
|
89
|
+
if self.summary is not None:
|
|
90
|
+
d["summary"] = self.summary
|
|
91
|
+
if self.description is not None:
|
|
92
|
+
d["description"] = self.description
|
|
93
|
+
return d
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class ServiceInfo:
|
|
98
|
+
"""A discovered service + its methods (mirrors BowireServiceInfo)."""
|
|
99
|
+
|
|
100
|
+
name: str
|
|
101
|
+
package: str = ""
|
|
102
|
+
methods: list[MethodInfo] = field(default_factory=list)
|
|
103
|
+
source: str = "sidecar"
|
|
104
|
+
origin_url: str | None = None
|
|
105
|
+
description: str | None = None
|
|
106
|
+
version: str | None = None
|
|
107
|
+
|
|
108
|
+
def to_dict(self) -> dict[str, Any]:
|
|
109
|
+
d: dict[str, Any] = {
|
|
110
|
+
"name": self.name,
|
|
111
|
+
"package": self.package,
|
|
112
|
+
"methods": [m.to_dict() for m in self.methods],
|
|
113
|
+
"source": self.source,
|
|
114
|
+
}
|
|
115
|
+
if self.origin_url is not None:
|
|
116
|
+
d["originUrl"] = self.origin_url
|
|
117
|
+
if self.description is not None:
|
|
118
|
+
d["description"] = self.description
|
|
119
|
+
if self.version is not None:
|
|
120
|
+
d["version"] = self.version
|
|
121
|
+
return d
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class InvokeResult:
|
|
126
|
+
"""Result of a unary invocation (mirrors InvokeResult)."""
|
|
127
|
+
|
|
128
|
+
response: str | None = None
|
|
129
|
+
duration_ms: int = 0
|
|
130
|
+
status: str = "OK"
|
|
131
|
+
metadata: dict[str, str] = field(default_factory=dict)
|
|
132
|
+
|
|
133
|
+
def to_dict(self) -> dict[str, Any]:
|
|
134
|
+
return {
|
|
135
|
+
"response": self.response,
|
|
136
|
+
"durationMs": self.duration_ms,
|
|
137
|
+
"status": self.status,
|
|
138
|
+
"metadata": self.metadata,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass
|
|
143
|
+
class SettingOption:
|
|
144
|
+
"""One choice of a ``select`` setting (mirrors BowirePluginSettingOption).
|
|
145
|
+
|
|
146
|
+
``value`` is what the host sends back when the operator picks it;
|
|
147
|
+
``label`` is what the dialog shows, and defaults to the value.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
value: str
|
|
151
|
+
label: str | None = None
|
|
152
|
+
|
|
153
|
+
def to_dict(self) -> dict[str, Any]:
|
|
154
|
+
d: dict[str, Any] = {"value": self.value}
|
|
155
|
+
if self.label is not None:
|
|
156
|
+
d["label"] = self.label
|
|
157
|
+
return d
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass
|
|
161
|
+
class PluginSetting:
|
|
162
|
+
"""A setting the plugin contributes to the Bowire Settings dialog.
|
|
163
|
+
|
|
164
|
+
``type`` is ``"bool"``, ``"string"``, ``"number"`` or ``"select"``. A
|
|
165
|
+
``select`` needs ``options`` — without them the dialog has nothing to
|
|
166
|
+
offer, so declaring one is refused here rather than rendered empty.
|
|
167
|
+
|
|
168
|
+
The value comes back on every call as a :class:`~bowire_plugin.Settings`
|
|
169
|
+
argument; ``default_value`` is what its accessors return while nobody
|
|
170
|
+
has set one.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
key: str
|
|
174
|
+
label: str
|
|
175
|
+
description: str | None = None
|
|
176
|
+
type: str = "bool"
|
|
177
|
+
default_value: Any = None
|
|
178
|
+
options: list[SettingOption] = field(default_factory=list)
|
|
179
|
+
|
|
180
|
+
def __post_init__(self) -> None:
|
|
181
|
+
if self.type == "select" and not self.options:
|
|
182
|
+
raise ValueError(f"select setting '{self.key}' declares no options")
|
|
183
|
+
|
|
184
|
+
def to_dict(self) -> dict[str, Any]:
|
|
185
|
+
d: dict[str, Any] = {
|
|
186
|
+
"key": self.key,
|
|
187
|
+
"label": self.label,
|
|
188
|
+
"description": self.description,
|
|
189
|
+
"type": self.type,
|
|
190
|
+
"defaultValue": self.default_value,
|
|
191
|
+
}
|
|
192
|
+
if self.options:
|
|
193
|
+
d["options"] = [o.to_dict() for o in self.options]
|
|
194
|
+
return d
|
bowire_plugin/_plugin.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Copyright 2026 Küstenlogik
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The base class a Python sidecar plugin subclasses."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections.abc import Iterable, Iterator
|
|
7
|
+
|
|
8
|
+
from ._manual_entry import ManualEntryForm
|
|
9
|
+
from ._models import InvokeResult, PluginSetting, ServiceInfo
|
|
10
|
+
from ._settings import Settings
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BowirePlugin:
|
|
14
|
+
"""Subclass this and implement the methods you need, then hand an
|
|
15
|
+
instance to :func:`bowire_plugin.run`. The runtime drives the
|
|
16
|
+
JSON-RPC-over-stdio contract; you only write protocol logic.
|
|
17
|
+
|
|
18
|
+
Set :attr:`id`, :attr:`name`, and (optionally) :attr:`icon_svg` as
|
|
19
|
+
class attributes. The ``id`` must match the ``protocol.id`` in the
|
|
20
|
+
plugin's ``sidecar.json`` manifest.
|
|
21
|
+
|
|
22
|
+
Every call receives ``settings``: the values set for what
|
|
23
|
+
:meth:`settings` declared, read fresh for that call (see
|
|
24
|
+
:class:`~bowire_plugin.Settings`).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
#: Short protocol id (e.g. ``"zenoh"``). Must match sidecar.json.
|
|
28
|
+
id: str = ""
|
|
29
|
+
#: Display name shown in the workbench tab (e.g. ``"Zenoh"``).
|
|
30
|
+
name: str = ""
|
|
31
|
+
#: Inline SVG for the protocol tab. Optional — the host falls back
|
|
32
|
+
#: to a generic plug icon when empty.
|
|
33
|
+
icon_svg: str = ""
|
|
34
|
+
|
|
35
|
+
def settings(self) -> list[PluginSetting]:
|
|
36
|
+
"""Settings this plugin contributes to the Settings dialog.
|
|
37
|
+
|
|
38
|
+
Read once per process, for the ``initialize`` reply. The values come back
|
|
39
|
+
as the ``settings`` argument of every call, and the defaults
|
|
40
|
+
declared here are what its accessors return for a key nobody set.
|
|
41
|
+
"""
|
|
42
|
+
return []
|
|
43
|
+
|
|
44
|
+
def manual_entry(self) -> ManualEntryForm | None:
|
|
45
|
+
"""How a call to this plugin is entered by hand (Kuestenlogik/Bowire#1003).
|
|
46
|
+
|
|
47
|
+
Sent in the ``initialize`` reply as ``manualEntry``; the workbench
|
|
48
|
+
renders a Compose form from it. Default: none.
|
|
49
|
+
"""
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
def discover(
|
|
53
|
+
self, server_url: str, show_internal: bool, settings: Settings
|
|
54
|
+
) -> Iterable[ServiceInfo]:
|
|
55
|
+
"""Enumerate services + methods for ``server_url``.
|
|
56
|
+
|
|
57
|
+
Return an empty list to decline the URL (the host then tries
|
|
58
|
+
other plugins). Default: nothing discovered.
|
|
59
|
+
"""
|
|
60
|
+
return []
|
|
61
|
+
|
|
62
|
+
def invoke(
|
|
63
|
+
self,
|
|
64
|
+
server_url: str,
|
|
65
|
+
service: str,
|
|
66
|
+
method: str,
|
|
67
|
+
json_messages: list[str],
|
|
68
|
+
show_internal: bool,
|
|
69
|
+
metadata: dict[str, str],
|
|
70
|
+
settings: Settings,
|
|
71
|
+
) -> InvokeResult:
|
|
72
|
+
"""Perform a unary call and return the result. Default: an error
|
|
73
|
+
result noting the plugin didn't implement invoke."""
|
|
74
|
+
return InvokeResult(status="invoke not implemented by sidecar")
|
|
75
|
+
|
|
76
|
+
def invoke_stream(
|
|
77
|
+
self,
|
|
78
|
+
server_url: str,
|
|
79
|
+
service: str,
|
|
80
|
+
method: str,
|
|
81
|
+
json_messages: list[str],
|
|
82
|
+
show_internal: bool,
|
|
83
|
+
metadata: dict[str, str],
|
|
84
|
+
settings: Settings,
|
|
85
|
+
) -> Iterator[str]:
|
|
86
|
+
"""Server-streaming call: yield each message (a JSON string) as
|
|
87
|
+
it arrives. The runtime forwards every yielded value to the host
|
|
88
|
+
as a ``$/stream/data`` notification and sends ``$/stream/end``
|
|
89
|
+
when the generator finishes. Default: an empty stream."""
|
|
90
|
+
return iter(())
|
|
91
|
+
|
|
92
|
+
def shutdown(self) -> None:
|
|
93
|
+
"""Optional cleanup hook, called when the host asks the sidecar
|
|
94
|
+
to exit. Default: no-op."""
|
|
95
|
+
return
|