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