meegxer-plugins 0.2.1a2__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.
- meegxer/plugins/__init__.py +6 -0
- meegxer/plugins/_sdk.py +310 -0
- meegxer/plugins/adapter.py +195 -0
- meegxer/plugins/cli.py +132 -0
- meegxer/plugins/conformance.py +176 -0
- meegxer/plugins/index.py +235 -0
- meegxer/plugins/py.typed +0 -0
- meegxer/plugins/script.py +206 -0
- meegxer/plugins/wire.py +156 -0
- meegxer/plugins/worker.py +359 -0
- meegxer_plugins-0.2.1a2.dist-info/METADATA +44 -0
- meegxer_plugins-0.2.1a2.dist-info/RECORD +15 -0
- meegxer_plugins-0.2.1a2.dist-info/WHEEL +4 -0
- meegxer_plugins-0.2.1a2.dist-info/entry_points.txt +6 -0
- meegxer_plugins-0.2.1a2.dist-info/licenses/LICENSE +674 -0
meegxer/plugins/_sdk.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""MeegXer plugin SDK for Python (protocol meegxer.plugin/0.2, spec 0.2.x).
|
|
2
|
+
|
|
3
|
+
A single-file, numpy-only implementation of spec/plugin-protocol.md for use inside
|
|
4
|
+
plugin images. Copy this file into your image and write::
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
from meegxer_plugin import Plugin, run
|
|
8
|
+
|
|
9
|
+
class Gain(Plugin):
|
|
10
|
+
manifest = {
|
|
11
|
+
"spec_version": "0.2.1", "name": "lab.gain", "version": "0.1.0",
|
|
12
|
+
"type": "stream", "inputs": [{"name": "in", "type": "signal"}],
|
|
13
|
+
"outputs": [{"name": "out", "type": "signal"}],
|
|
14
|
+
"params": [{"name": "k", "type": "float", "default": 2.0}],
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
def transform(self, data, info, model, state, params):
|
|
18
|
+
return data * params["k"], state or {}, []
|
|
19
|
+
|
|
20
|
+
if __name__ == "__main__":
|
|
21
|
+
run(Gain())
|
|
22
|
+
|
|
23
|
+
``data`` is a float64 array (n_channels, n_samples) in volts; ``info`` holds
|
|
24
|
+
``ch_labels``, ``sfreq``, ``stream_id``, ``bads`` plus ``sample_index`` and
|
|
25
|
+
``timestamp`` of the block. ``state`` is None on the first block. Return
|
|
26
|
+
``(data, state, events)``; events are dicts with ``sample_index``, ``label`` and
|
|
27
|
+
optionally ``duration`` and ``payload``. A plugin whose output lags its input
|
|
28
|
+
(look-ahead) returns ``(data, state, events, first)`` with ``first`` the sample index
|
|
29
|
+
of its first output sample (output may be shorter than the input), and returns what
|
|
30
|
+
it still holds at the end of the stream from ``flush(info, model, state, params)``.
|
|
31
|
+
Keep everything that must survive between blocks in ``state``: the host owns it,
|
|
32
|
+
saves it and restores it after a restart.
|
|
33
|
+
Never print to stdout (it carries the protocol); log to stderr.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import json
|
|
39
|
+
import math
|
|
40
|
+
import struct
|
|
41
|
+
import sys
|
|
42
|
+
import traceback
|
|
43
|
+
from typing import Any, BinaryIO, ClassVar
|
|
44
|
+
|
|
45
|
+
import numpy as np
|
|
46
|
+
|
|
47
|
+
SDK = "meegxer-python/0.1.0"
|
|
48
|
+
SPEC_VERSION = "0.2.5"
|
|
49
|
+
MAGIC = b"MXP1"
|
|
50
|
+
_DTYPES = {"float32", "float64", "int32", "int64", "uint8", "bool"}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Plugin:
|
|
54
|
+
"""Subclass and set ``manifest``; override ``transform`` (and ``fit``)."""
|
|
55
|
+
|
|
56
|
+
manifest: ClassVar[dict[str, Any]] = {}
|
|
57
|
+
|
|
58
|
+
def fit(
|
|
59
|
+
self, data: np.ndarray, info: dict[str, Any], params: dict[str, Any]
|
|
60
|
+
) -> dict:
|
|
61
|
+
raise NotImplementedError("this plugin does not implement fit")
|
|
62
|
+
|
|
63
|
+
def transform(
|
|
64
|
+
self,
|
|
65
|
+
data: np.ndarray,
|
|
66
|
+
info: dict[str, Any],
|
|
67
|
+
model: dict | None,
|
|
68
|
+
state: dict | None,
|
|
69
|
+
params: dict[str, Any],
|
|
70
|
+
) -> tuple[Any, ...]:
|
|
71
|
+
raise NotImplementedError
|
|
72
|
+
|
|
73
|
+
def flush(
|
|
74
|
+
self,
|
|
75
|
+
info: dict[str, Any],
|
|
76
|
+
model: dict | None,
|
|
77
|
+
state: dict | None,
|
|
78
|
+
params: dict[str, Any],
|
|
79
|
+
) -> tuple[Any, ...] | None:
|
|
80
|
+
"""End of stream: what a lagging plugin still holds, as transform's
|
|
81
|
+
4-tuple; None (the default) when nothing is held."""
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# -- wire format ----------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _read_exact(stream: BinaryIO, n: int) -> bytes:
|
|
89
|
+
buf = b""
|
|
90
|
+
while len(buf) < n:
|
|
91
|
+
chunk = stream.read(n - len(buf))
|
|
92
|
+
if not chunk:
|
|
93
|
+
raise EOFError
|
|
94
|
+
buf += chunk
|
|
95
|
+
return buf
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def read_frame(stream: BinaryIO) -> tuple[dict, dict[str, np.ndarray]]:
|
|
99
|
+
magic = stream.read(4)
|
|
100
|
+
if not magic:
|
|
101
|
+
raise EOFError
|
|
102
|
+
magic += _read_exact(stream, 4 - len(magic)) if len(magic) < 4 else b""
|
|
103
|
+
if magic != MAGIC:
|
|
104
|
+
raise ValueError(f"bad magic {magic!r}")
|
|
105
|
+
(hlen,) = struct.unpack("<I", _read_exact(stream, 4))
|
|
106
|
+
header = json.loads(_read_exact(stream, hlen))
|
|
107
|
+
(plen,) = struct.unpack("<Q", _read_exact(stream, 8))
|
|
108
|
+
payload = _read_exact(stream, plen)
|
|
109
|
+
arrays = {}
|
|
110
|
+
for d in header.pop("arrays", []):
|
|
111
|
+
if d["dtype"] not in _DTYPES:
|
|
112
|
+
raise ValueError(f"unknown dtype {d['dtype']}")
|
|
113
|
+
raw = payload[d["offset"] : d["offset"] + d["nbytes"]]
|
|
114
|
+
dtype = np.dtype(d["dtype"]).newbyteorder("<")
|
|
115
|
+
arrays[d["name"]] = np.frombuffer(raw, dtype=dtype).reshape(d["shape"]).copy()
|
|
116
|
+
return header, arrays
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def write_frame(stream: BinaryIO, header: dict, arrays: dict[str, np.ndarray]) -> None:
|
|
120
|
+
descs, chunks, offset = [], [], 0
|
|
121
|
+
for name, arr in arrays.items():
|
|
122
|
+
arr = np.asarray(arr)
|
|
123
|
+
dtype = "bool" if arr.dtype == np.bool_ else arr.dtype.name
|
|
124
|
+
if dtype not in _DTYPES:
|
|
125
|
+
raise TypeError(f"array {name!r} has unsupported dtype {arr.dtype}")
|
|
126
|
+
raw = np.ascontiguousarray(
|
|
127
|
+
arr, dtype=np.dtype(dtype).newbyteorder("<")
|
|
128
|
+
).tobytes()
|
|
129
|
+
descs.append(
|
|
130
|
+
{
|
|
131
|
+
"name": name,
|
|
132
|
+
"dtype": dtype,
|
|
133
|
+
"shape": list(arr.shape),
|
|
134
|
+
"offset": offset,
|
|
135
|
+
"nbytes": len(raw),
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
chunks.append(raw)
|
|
139
|
+
offset += len(raw)
|
|
140
|
+
head = json.dumps({**header, "arrays": descs}, allow_nan=False).encode()
|
|
141
|
+
payload = b"".join(chunks)
|
|
142
|
+
stream.write(MAGIC + struct.pack("<I", len(head)) + head)
|
|
143
|
+
stream.write(struct.pack("<Q", len(payload)) + payload)
|
|
144
|
+
stream.flush()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def to_wire(tree: Any, arrays: dict[str, np.ndarray], prefix: str) -> Any:
|
|
148
|
+
if isinstance(tree, np.ndarray):
|
|
149
|
+
name = f"{prefix}{len(arrays)}"
|
|
150
|
+
arrays[name] = tree
|
|
151
|
+
return {"mxarray": name}
|
|
152
|
+
if isinstance(tree, np.generic):
|
|
153
|
+
return to_wire(tree.item(), arrays, prefix)
|
|
154
|
+
if isinstance(tree, float) and not math.isfinite(tree):
|
|
155
|
+
return {
|
|
156
|
+
"mxfloat": "nan" if math.isnan(tree) else ("inf" if tree > 0 else "-inf")
|
|
157
|
+
}
|
|
158
|
+
if isinstance(tree, (list, tuple)):
|
|
159
|
+
return [to_wire(v, arrays, prefix) for v in tree]
|
|
160
|
+
if isinstance(tree, dict):
|
|
161
|
+
return {str(k): to_wire(v, arrays, prefix) for k, v in tree.items()}
|
|
162
|
+
return tree
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def from_wire(tree: Any, arrays: dict[str, np.ndarray]) -> Any:
|
|
166
|
+
if isinstance(tree, dict):
|
|
167
|
+
if set(tree) == {"mxarray"}:
|
|
168
|
+
return arrays[tree["mxarray"]]
|
|
169
|
+
if set(tree) == {"mxfloat"}:
|
|
170
|
+
return float(tree["mxfloat"])
|
|
171
|
+
return {k: from_wire(v, arrays) for k, v in tree.items()}
|
|
172
|
+
if isinstance(tree, list):
|
|
173
|
+
return [from_wire(v, arrays) for v in tree]
|
|
174
|
+
return tree
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# -- main loop ------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def run(
|
|
181
|
+
plugin: Plugin, stdin: BinaryIO | None = None, stdout: BinaryIO | None = None
|
|
182
|
+
) -> None:
|
|
183
|
+
"""Serve requests until ``shutdown`` or end of input."""
|
|
184
|
+
inp = stdin or sys.stdin.buffer
|
|
185
|
+
out = stdout or sys.stdout.buffer
|
|
186
|
+
sys.stdout = sys.stderr # stray print() calls must not corrupt the protocol
|
|
187
|
+
models: dict[str, dict] = {}
|
|
188
|
+
|
|
189
|
+
while True:
|
|
190
|
+
try:
|
|
191
|
+
header, arrays = read_frame(inp)
|
|
192
|
+
except EOFError:
|
|
193
|
+
return
|
|
194
|
+
except Exception as exc: # unreadable frame: we cannot resynchronise
|
|
195
|
+
print(f"meegxer_plugin: bad frame: {exc}", file=sys.stderr)
|
|
196
|
+
return
|
|
197
|
+
rid = header.get("id")
|
|
198
|
+
kind = header.get("type")
|
|
199
|
+
try:
|
|
200
|
+
if kind == "hello":
|
|
201
|
+
write_frame(
|
|
202
|
+
out,
|
|
203
|
+
{
|
|
204
|
+
"type": "hello",
|
|
205
|
+
"id": rid,
|
|
206
|
+
"spec_version": SPEC_VERSION,
|
|
207
|
+
"manifest": plugin.manifest,
|
|
208
|
+
"sdk": SDK,
|
|
209
|
+
},
|
|
210
|
+
{},
|
|
211
|
+
)
|
|
212
|
+
elif kind == "shutdown":
|
|
213
|
+
write_frame(out, {"type": "ok", "id": rid}, {})
|
|
214
|
+
return
|
|
215
|
+
elif kind == "load_model":
|
|
216
|
+
models[header["model_id"]] = from_wire(header["model"], arrays)
|
|
217
|
+
write_frame(out, {"type": "ok", "id": rid}, {})
|
|
218
|
+
elif kind == "fit":
|
|
219
|
+
info = dict(
|
|
220
|
+
header["info"],
|
|
221
|
+
sample_index=header.get("sample_index", 0),
|
|
222
|
+
timestamp=header.get("timestamp", 0.0),
|
|
223
|
+
)
|
|
224
|
+
model = plugin.fit(
|
|
225
|
+
arrays["data"].astype(np.float64), info, header["params"]
|
|
226
|
+
)
|
|
227
|
+
model_arrays: dict[str, np.ndarray] = {}
|
|
228
|
+
tree = to_wire(model, model_arrays, "m")
|
|
229
|
+
write_frame(
|
|
230
|
+
out, {"type": "model", "id": rid, "model": tree}, model_arrays
|
|
231
|
+
)
|
|
232
|
+
elif kind in ("transform", "flush"):
|
|
233
|
+
model_id = header.get("model_id")
|
|
234
|
+
if model_id is not None and model_id not in models:
|
|
235
|
+
write_frame(
|
|
236
|
+
out,
|
|
237
|
+
{
|
|
238
|
+
"type": "error",
|
|
239
|
+
"id": rid,
|
|
240
|
+
"error": "unknown_model",
|
|
241
|
+
"message": f"model {model_id} not loaded",
|
|
242
|
+
},
|
|
243
|
+
{},
|
|
244
|
+
)
|
|
245
|
+
continue
|
|
246
|
+
info = dict(header["info"])
|
|
247
|
+
if "sample_index" in header:
|
|
248
|
+
info.update(
|
|
249
|
+
sample_index=header["sample_index"],
|
|
250
|
+
timestamp=header["timestamp"],
|
|
251
|
+
)
|
|
252
|
+
state = header.get("state")
|
|
253
|
+
state = None if state is None else from_wire(state, arrays)
|
|
254
|
+
model = models.get(model_id) if model_id else None
|
|
255
|
+
if kind == "transform":
|
|
256
|
+
got = plugin.transform(
|
|
257
|
+
arrays["data"].astype(np.float64),
|
|
258
|
+
info,
|
|
259
|
+
model,
|
|
260
|
+
state,
|
|
261
|
+
header["params"],
|
|
262
|
+
)
|
|
263
|
+
first = header["sample_index"]
|
|
264
|
+
else:
|
|
265
|
+
got = plugin.flush(info, model, state, header["params"])
|
|
266
|
+
if got is None:
|
|
267
|
+
got = (np.zeros((len(info["ch_labels"]), 0)), state, [], 0)
|
|
268
|
+
first = 0
|
|
269
|
+
data, new_state, events = got[0], got[1], got[2]
|
|
270
|
+
if len(got) > 3:
|
|
271
|
+
first = int(got[3])
|
|
272
|
+
out_arrays: dict[str, np.ndarray] = {
|
|
273
|
+
"data": np.asarray(data, dtype=np.float32)
|
|
274
|
+
}
|
|
275
|
+
tree = to_wire(new_state or {}, out_arrays, "s")
|
|
276
|
+
evs = [{"duration": 0, "payload": {}, **e} for e in events]
|
|
277
|
+
write_frame(
|
|
278
|
+
out,
|
|
279
|
+
{
|
|
280
|
+
"type": "result",
|
|
281
|
+
"id": rid,
|
|
282
|
+
"state": tree,
|
|
283
|
+
"events": evs,
|
|
284
|
+
"sample_index": first,
|
|
285
|
+
},
|
|
286
|
+
out_arrays,
|
|
287
|
+
)
|
|
288
|
+
else:
|
|
289
|
+
write_frame(
|
|
290
|
+
out,
|
|
291
|
+
{
|
|
292
|
+
"type": "error",
|
|
293
|
+
"id": rid,
|
|
294
|
+
"error": "bad_request",
|
|
295
|
+
"message": f"unknown message type {kind!r}",
|
|
296
|
+
},
|
|
297
|
+
{},
|
|
298
|
+
)
|
|
299
|
+
except Exception as exc:
|
|
300
|
+
traceback.print_exc(file=sys.stderr)
|
|
301
|
+
write_frame(
|
|
302
|
+
out,
|
|
303
|
+
{
|
|
304
|
+
"type": "error",
|
|
305
|
+
"id": rid,
|
|
306
|
+
"error": "plugin_error",
|
|
307
|
+
"message": f"{type(exc).__name__}: {exc}",
|
|
308
|
+
},
|
|
309
|
+
{},
|
|
310
|
+
)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Expose a plugin as an ordinary Processor node.
|
|
2
|
+
|
|
3
|
+
The adapter class is created from the plugin's handshake manifest, so the rest of
|
|
4
|
+
MeegXer (graph, schedulers, conformance) cannot tell a plugin from a built-in. Each
|
|
5
|
+
node instance owns one worker; the host keeps model and state.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Any, ClassVar
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from meegxer.core.block import Block, Event, SignalInfo
|
|
16
|
+
from meegxer.core.manifest import Manifest
|
|
17
|
+
from meegxer.core.node import Params, Processor, TransformResult
|
|
18
|
+
from meegxer.core.serialization import State, hash_state
|
|
19
|
+
from meegxer.plugins.wire import tree_from_wire, tree_to_wire
|
|
20
|
+
from meegxer.plugins.worker import Launcher, PluginError, PluginWorker
|
|
21
|
+
|
|
22
|
+
RESERVED_PREFIXES = ("meegxer.",)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _info_dict(info: SignalInfo) -> dict[str, Any]:
|
|
26
|
+
return {
|
|
27
|
+
"ch_labels": list(info.ch_labels),
|
|
28
|
+
"sfreq": info.sfreq,
|
|
29
|
+
"stream_id": info.stream_id,
|
|
30
|
+
"bads": list(info.bads),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PluginProcessor(Processor):
|
|
35
|
+
"""Base class of generated plugin adapters."""
|
|
36
|
+
|
|
37
|
+
launcher: ClassVar[Launcher]
|
|
38
|
+
worker_options: ClassVar[dict[str, Any]] = {}
|
|
39
|
+
origin: ClassVar[dict[str, Any]] = {} # image or command (sessions)
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def provenance(cls) -> dict[str, Any]:
|
|
43
|
+
return dict(cls.origin)
|
|
44
|
+
|
|
45
|
+
def __init__(self) -> None:
|
|
46
|
+
self._worker: PluginWorker | None = None
|
|
47
|
+
self._model_ids: dict[int, tuple[State, str]] = {}
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def worker(self) -> PluginWorker:
|
|
51
|
+
if self._worker is None:
|
|
52
|
+
self._worker = PluginWorker(self.launcher, **self.worker_options)
|
|
53
|
+
return self._worker
|
|
54
|
+
|
|
55
|
+
def close(self) -> None:
|
|
56
|
+
if self._worker is not None:
|
|
57
|
+
self._worker.close()
|
|
58
|
+
self._worker = None
|
|
59
|
+
|
|
60
|
+
def _model_id(self, model: State) -> str:
|
|
61
|
+
cached = self._model_ids.get(id(model))
|
|
62
|
+
if cached is not None and cached[0] is model:
|
|
63
|
+
return cached[1]
|
|
64
|
+
model_id = hash_state(model)
|
|
65
|
+
self._model_ids = {id(model): (model, model_id)} # keep only the current model
|
|
66
|
+
return model_id
|
|
67
|
+
|
|
68
|
+
def fit(self, calibration: Block, params: Params) -> State:
|
|
69
|
+
reply = self.worker.request(
|
|
70
|
+
{
|
|
71
|
+
"type": "fit",
|
|
72
|
+
"params": dict(params),
|
|
73
|
+
"info": _info_dict(calibration.info),
|
|
74
|
+
"sample_index": calibration.sample_index,
|
|
75
|
+
"timestamp": calibration.timestamp,
|
|
76
|
+
},
|
|
77
|
+
{"data": calibration.data.astype(np.float32)},
|
|
78
|
+
)
|
|
79
|
+
if reply.type != "model":
|
|
80
|
+
raise PluginError(f"expected a model reply, got {reply.type!r}")
|
|
81
|
+
model = tree_from_wire(reply.header.get("model", {}), reply.arrays)
|
|
82
|
+
if not isinstance(model, dict):
|
|
83
|
+
raise PluginError("model must be an object")
|
|
84
|
+
return model
|
|
85
|
+
|
|
86
|
+
def transform(
|
|
87
|
+
self, block: Block, model: State, state: State | None, params: Params
|
|
88
|
+
) -> TransformResult:
|
|
89
|
+
result = self._exchange("transform", block.info, model, state, params, block)
|
|
90
|
+
assert result is not None
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
def flush(
|
|
94
|
+
self, info: SignalInfo, model: State, state: State | None, params: Params
|
|
95
|
+
) -> TransformResult | None:
|
|
96
|
+
"""End of stream: ask a lagging plugin for what it still holds. Plugins
|
|
97
|
+
built for protocol 0.1 refuse the message: they hold nothing."""
|
|
98
|
+
try:
|
|
99
|
+
result = self._exchange("flush", info, model, state, params, None)
|
|
100
|
+
except PluginError as exc:
|
|
101
|
+
if "bad_request" in str(exc):
|
|
102
|
+
return None
|
|
103
|
+
raise
|
|
104
|
+
if result is None or not isinstance(result.block, Block):
|
|
105
|
+
return result
|
|
106
|
+
return result if result.block.n_samples else None
|
|
107
|
+
|
|
108
|
+
def _exchange(
|
|
109
|
+
self,
|
|
110
|
+
kind: str,
|
|
111
|
+
info: SignalInfo,
|
|
112
|
+
model: State,
|
|
113
|
+
state: State | None,
|
|
114
|
+
params: Params,
|
|
115
|
+
block: Block | None,
|
|
116
|
+
) -> TransformResult | None:
|
|
117
|
+
arrays: dict[str, Any] = {}
|
|
118
|
+
if block is not None:
|
|
119
|
+
arrays["data"] = block.data.astype(np.float32)
|
|
120
|
+
state_tree = None if state is None else tree_to_wire(state, arrays, "s")
|
|
121
|
+
models: dict[str, tuple[Any, dict[str, Any]]] = {}
|
|
122
|
+
model_id = None
|
|
123
|
+
if self.manifest.requires_fit:
|
|
124
|
+
model_id = self._model_id(model)
|
|
125
|
+
model_arrays: dict[str, Any] = {}
|
|
126
|
+
models[model_id] = (tree_to_wire(model, model_arrays, "m"), model_arrays)
|
|
127
|
+
header: dict[str, Any] = {
|
|
128
|
+
"type": kind,
|
|
129
|
+
"params": dict(params),
|
|
130
|
+
"info": _info_dict(info),
|
|
131
|
+
"model_id": model_id,
|
|
132
|
+
"state": state_tree,
|
|
133
|
+
}
|
|
134
|
+
if block is not None:
|
|
135
|
+
header.update(sample_index=block.sample_index, timestamp=block.timestamp)
|
|
136
|
+
reply = self.worker.request(header, arrays, models=models)
|
|
137
|
+
if reply.type != "result" or "data" not in reply.arrays:
|
|
138
|
+
raise PluginError(f"expected a result with data, got {reply.header}")
|
|
139
|
+
if isinstance(reply.header.get("info"), dict):
|
|
140
|
+
new = reply.header["info"]
|
|
141
|
+
info = SignalInfo(
|
|
142
|
+
tuple(new["ch_labels"]),
|
|
143
|
+
float(new.get("sfreq", info.sfreq)),
|
|
144
|
+
new.get("stream_id", info.stream_id),
|
|
145
|
+
tuple(new.get("bads", ())),
|
|
146
|
+
)
|
|
147
|
+
data = np.asarray(reply.arrays["data"], dtype=np.float64)
|
|
148
|
+
if data.ndim == 1: # an empty result may lose its channel axis
|
|
149
|
+
data = data.reshape(info.n_channels, -1)
|
|
150
|
+
first = int(reply.header.get("sample_index",
|
|
151
|
+
block.sample_index if block else 0)) # fmt: skip
|
|
152
|
+
if block is not None:
|
|
153
|
+
timestamp = block.timestamp + (first - block.sample_index) / info.sfreq
|
|
154
|
+
else:
|
|
155
|
+
timestamp = 0.0
|
|
156
|
+
out = Block(data, info, first, timestamp)
|
|
157
|
+
new_state = tree_from_wire(reply.header.get("state") or {}, reply.arrays)
|
|
158
|
+
events = tuple(Event(**e) for e in reply.header.get("events", []))
|
|
159
|
+
return TransformResult(out, new_state, events)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def plugin_class(
|
|
163
|
+
manifest: dict[str, Any],
|
|
164
|
+
launcher: Launcher,
|
|
165
|
+
*,
|
|
166
|
+
worker_options: dict[str, Any] | None = None,
|
|
167
|
+
origin: dict[str, Any] | None = None,
|
|
168
|
+
) -> type[PluginProcessor]:
|
|
169
|
+
"""Build the node class for a plugin from its (handshake) manifest."""
|
|
170
|
+
parsed = Manifest.from_dict(manifest)
|
|
171
|
+
if parsed.role != "processor":
|
|
172
|
+
raise PluginError(f"plugins must be processors, not {parsed.role}s")
|
|
173
|
+
if parsed.name.startswith(RESERVED_PREFIXES):
|
|
174
|
+
raise PluginError(f"plugin name {parsed.name!r} uses a reserved prefix")
|
|
175
|
+
attrs: dict[str, Any] = {
|
|
176
|
+
"manifest": parsed,
|
|
177
|
+
"launcher": launcher,
|
|
178
|
+
"worker_options": dict(worker_options or {}),
|
|
179
|
+
"origin": dict(origin or {}),
|
|
180
|
+
"__doc__": f"Plugin {parsed.name} {parsed.version} ({launcher.describe()}).",
|
|
181
|
+
}
|
|
182
|
+
name = "Plugin_" + parsed.name.replace(".", "_")
|
|
183
|
+
return type(name, (PluginProcessor,), attrs)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def probe_manifest(launcher: Launcher, **worker_options: Any) -> dict[str, Any]:
|
|
187
|
+
"""Start a plugin once to read its manifest."""
|
|
188
|
+
worker = PluginWorker(launcher, **worker_options)
|
|
189
|
+
try:
|
|
190
|
+
return worker.start()
|
|
191
|
+
finally:
|
|
192
|
+
worker.close()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
PluginFactory = Callable[[], type[PluginProcessor]]
|
meegxer/plugins/cli.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""``meegxer-plugin``: install, list, remove and check plugins."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from meegxer.core.notices import LOCAL_CODE_NOTICE
|
|
11
|
+
from meegxer.plugins import index
|
|
12
|
+
from meegxer.plugins.conformance import format_report, run_conformance
|
|
13
|
+
from meegxer.plugins.worker import ContainerLauncher, LocalLauncher
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _params(items: list[str]) -> dict[str, object]:
|
|
17
|
+
params: dict[str, object] = {}
|
|
18
|
+
for item in items:
|
|
19
|
+
name, _, value = item.partition("=")
|
|
20
|
+
try:
|
|
21
|
+
params[name] = json.loads(value)
|
|
22
|
+
except ValueError:
|
|
23
|
+
params[name] = value
|
|
24
|
+
return params
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _accepted(args: argparse.Namespace) -> bool:
|
|
28
|
+
"""Show the local-code notice; accepted with --accept-risk or a typed yes."""
|
|
29
|
+
print(LOCAL_CODE_NOTICE, file=sys.stderr)
|
|
30
|
+
if args.accept_risk:
|
|
31
|
+
return True
|
|
32
|
+
if not sys.stdin.isatty():
|
|
33
|
+
print("\nRead the notice, then run again with --accept-risk.", file=sys.stderr)
|
|
34
|
+
return False
|
|
35
|
+
answer = input("\nType yes to add this plugin: ")
|
|
36
|
+
return answer.strip().lower() == "yes"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main(argv: list[str] | None = None) -> int:
|
|
40
|
+
parser = argparse.ArgumentParser(prog="meegxer-plugin", description=__doc__)
|
|
41
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
42
|
+
|
|
43
|
+
check = sub.add_parser("check", help="run the conformance suite")
|
|
44
|
+
check.add_argument("image", nargs="?", help="container image")
|
|
45
|
+
check.add_argument("--engine", help="docker or podman")
|
|
46
|
+
check.add_argument(
|
|
47
|
+
"--script", type=Path, help="a custom script (process(raw, ...))"
|
|
48
|
+
)
|
|
49
|
+
check.add_argument("--param", action="append", default=[], help="name=value")
|
|
50
|
+
check.add_argument(
|
|
51
|
+
"--local",
|
|
52
|
+
nargs=argparse.REMAINDER,
|
|
53
|
+
help="run a local command instead (unsandboxed)",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
add = sub.add_parser(
|
|
57
|
+
"add", help="install a plugin: an image, a custom script or a local command"
|
|
58
|
+
)
|
|
59
|
+
add.add_argument("image", nargs="?", help="container image")
|
|
60
|
+
add.add_argument("--engine")
|
|
61
|
+
add.add_argument("--script", type=Path, help="a custom script (process(raw, ...))")
|
|
62
|
+
add.add_argument(
|
|
63
|
+
"--accept-risk",
|
|
64
|
+
action="store_true",
|
|
65
|
+
help="accept the local-code notice (local plugins run unsandboxed)",
|
|
66
|
+
)
|
|
67
|
+
add.add_argument(
|
|
68
|
+
"--local",
|
|
69
|
+
nargs=argparse.REMAINDER,
|
|
70
|
+
help="a local command speaking the plugin protocol (unsandboxed)",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
sub.add_parser("list", help="list installed plugins")
|
|
74
|
+
rm = sub.add_parser("remove", help="uninstall a plugin by name")
|
|
75
|
+
rm.add_argument("name")
|
|
76
|
+
args = parser.parse_args(argv)
|
|
77
|
+
|
|
78
|
+
if args.command == "check":
|
|
79
|
+
if args.script:
|
|
80
|
+
launcher: ContainerLauncher | LocalLauncher = LocalLauncher(
|
|
81
|
+
tuple(index.script_command(args.script)), allow=True
|
|
82
|
+
)
|
|
83
|
+
elif args.local:
|
|
84
|
+
launcher = LocalLauncher(tuple(args.local), allow=True)
|
|
85
|
+
elif args.image:
|
|
86
|
+
launcher = ContainerLauncher(args.image, args.engine)
|
|
87
|
+
else:
|
|
88
|
+
parser.error("give an image, --script FILE or --local COMMAND")
|
|
89
|
+
checks = run_conformance(launcher, _params(args.param))
|
|
90
|
+
print(format_report(checks))
|
|
91
|
+
return 0 if all(c.passed for c in checks) else 1
|
|
92
|
+
if args.command == "add" and (args.script or args.local):
|
|
93
|
+
if args.script and not args.script.is_file():
|
|
94
|
+
parser.error(f"no such script: {args.script}")
|
|
95
|
+
if not _accepted(args):
|
|
96
|
+
return 1
|
|
97
|
+
command = index.script_command(args.script) if args.script else args.local
|
|
98
|
+
entry = index.add_local(
|
|
99
|
+
command, accept_risk=True, script=args.script if args.script else None
|
|
100
|
+
)
|
|
101
|
+
print(
|
|
102
|
+
f"installed {entry['manifest']['name']} {entry['manifest']['version']} "
|
|
103
|
+
f"(local, unsandboxed) into {index.index_path()}"
|
|
104
|
+
)
|
|
105
|
+
return 0
|
|
106
|
+
if args.command == "add":
|
|
107
|
+
if not args.image:
|
|
108
|
+
parser.error("give an image, --script FILE or --local COMMAND")
|
|
109
|
+
entry = index.add_image(args.image, args.engine)
|
|
110
|
+
print(
|
|
111
|
+
f"installed {entry['manifest']['name']} {entry['manifest']['version']} "
|
|
112
|
+
f"({entry['image_id'][:19]}) into {index.index_path()}"
|
|
113
|
+
)
|
|
114
|
+
return 0
|
|
115
|
+
if args.command == "list":
|
|
116
|
+
for entry in index.load_index():
|
|
117
|
+
where = entry.get("image") or " ".join(entry.get("command", []))
|
|
118
|
+
flag = " local, unsandboxed" if entry.get("unsandboxed") else ""
|
|
119
|
+
name, version = entry["manifest"]["name"], entry["manifest"]["version"]
|
|
120
|
+
print(f"{name:40} {version:8} {where}{flag}")
|
|
121
|
+
return 0
|
|
122
|
+
if args.command == "remove":
|
|
123
|
+
if not index.remove(args.name):
|
|
124
|
+
print(f"no plugin named {args.name}", file=sys.stderr)
|
|
125
|
+
return 1
|
|
126
|
+
print(f"removed {args.name}")
|
|
127
|
+
return 0
|
|
128
|
+
return 2
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
if __name__ == "__main__":
|
|
132
|
+
sys.exit(main())
|