flexdeck 0.1.1__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,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: flexdeck
3
+ Version: 0.1.1
4
+ Summary: Write FlexDeck plugins in Python: declare actions, values, events and feeds; test, pack and verify them against FlexDeck itself.
5
+ License: MIT
6
+ Keywords: flexdeck,plugin,sdk,deck,tablet
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Dynamic: license-file
11
+
12
+ <p align="center">
13
+ <a href="https://flex-deck.com"><img alt="flexdeck" src="https://raw.githubusercontent.com/santoshpanna/FlexDeck/main/.github/assets/logo.svg" width="72"></a>
14
+ </p>
15
+
16
+ <h1 align="center">flexdeck (Python)</h1>
17
+
18
+ <p align="center">
19
+ Write <a href="https://flex-deck.com">FlexDeck</a> plugins in Python.<br>
20
+ Standard library only. A packed plugin carries the SDK with it, so it runs on any Python 3.10+ with nothing else installed.
21
+ </p>
22
+
23
+ <p align="center">
24
+ <img alt="Python 3.10+" src="https://img.shields.io/badge/python-3.10%2B-ecebe6?style=flat-square&labelColor=111113&logo=python&logoColor=white">
25
+ <img alt="No dependencies" src="https://img.shields.io/badge/dependencies-none-ecebe6?style=flat-square&labelColor=111113">
26
+ <a href="https://github.com/santoshpanna/FlexDeck/blob/main/docs/plugins.md"><img alt="Plugin guide" src="https://img.shields.io/badge/docs-plugin%20guide-ecebe6?style=flat-square&labelColor=111113"></a>
27
+ </p>
28
+
29
+ <br>
30
+
31
+ ## Start in a minute
32
+
33
+ ```sh
34
+ pip install flexdeck
35
+ flexdeck-plugin new lights "Desk lights"
36
+ cd lights
37
+ python -m unittest # its tests, without FlexDeck
38
+ flexdeck-plugin verify # ask FlexDeck itself whether it will work
39
+ flexdeck-plugin pack # lights-0.1.0.fdpkg, ready to install
40
+ ```
41
+
42
+ ## A whole plugin
43
+
44
+ ```python
45
+ from flexdeck_plugin import Plugin, arg
46
+
47
+ plugin = Plugin(id="lights", name="Desk lights", version="0.1.0", permissions=["network"])
48
+ plugin.variable("lights.on", "bool", "Whether the lights are on")
49
+
50
+ @plugin.action("toggle", "Turn the lights on or off", {"room": arg("text", "desk", "Which room")})
51
+ def toggle(args, ctx):
52
+ ctx.vars.set({"lights.on": not ctx.vars.get("lights.on")})
53
+
54
+ if __name__ == "__main__":
55
+ plugin.serve()
56
+ ```
57
+
58
+ ## Verify before you publish
59
+
60
+ `verify` packs the plugin and runs it through the FlexDeck on this computer (`flexdeck verify-plugin`), exactly as an install would. It checks:
61
+
62
+ - [x] its catalogue and gallery keys
63
+ - [x] every action's example arguments
64
+ - [x] its values, feeds and settings
65
+ - [x] that it keeps to its own namespace and its declared events
66
+ - [x] that it writes nothing but the protocol to stdout
67
+ - [x] that it stops cleanly
68
+
69
+ > [!WARNING]
70
+ > `--run-actions` also runs every action with its examples, and those actions do real things.
71
+
72
+ If FlexDeck is installed somewhere unusual, set `FLEXDECK_BIN` to its path.
73
+
74
+ ## See also
75
+
76
+ - [Plugin guide](https://github.com/santoshpanna/FlexDeck/blob/main/docs/plugins.md): the full reference
77
+ - [Official plugins](https://github.com/Questory-Labs/FlexDeck-Plugins)
78
+ - [`@flexdeck/plugin-sdk`](https://www.npmjs.com/package/@flexdeck/plugin-sdk) for TypeScript · [`flexdeck-plugin-sdk`](https://crates.io/crates/flexdeck-plugin-sdk) for Rust
79
+
80
+ <br>
81
+
82
+ <p align="center"><sub>MIT licensed, so your plugins can use any license.</sub></p>
@@ -0,0 +1,12 @@
1
+ flexdeck-0.1.1.dist-info/licenses/LICENSE,sha256=mfGNNUvFyHn1fAQgOINcVwbaxWamdjjK2pNutD1o1K0,1070
2
+ flexdeck_plugin/__init__.py,sha256=IcbLk4f5N5dKvnRfLxY-R3yWbdl73WlToHRt-qL7SqM,26727
3
+ flexdeck_plugin/__main__.py,sha256=PRxnxz2ArBk5tFuGDZOD9Ohgata5F63-x80L5EIy5OU,2789
4
+ flexdeck_plugin/conformance.py,sha256=8JnKMvPILmK403nSDGNfJhfrVlJ4BQ3ETnqQ6ZWSYqw,3214
5
+ flexdeck_plugin/pack.py,sha256=djmrRAK2l-8uB2thgZ_kOqvHg8zPfOwn1W5OTxPbgFI,4416
6
+ flexdeck_plugin/template.py,sha256=quE2F6q8kjzpfXypEPuD6GIB74UdDMQu-jSv9IOtJmw,4957
7
+ flexdeck_plugin/testing.py,sha256=fE6OufoZRGR2zZHjrXO_-k0UuCQwyphSQetdeN8t1fQ,4164
8
+ flexdeck-0.1.1.dist-info/METADATA,sha256=Y3FqGHruYAn6E2oa5O6d4muyGOKluyC3mZxAkFOf7dw,3188
9
+ flexdeck-0.1.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
10
+ flexdeck-0.1.1.dist-info/entry_points.txt,sha256=5U2jfe3pGZU7BeIVD3BkklG31XKZOCMoGVcqxaS4_Cc,66
11
+ flexdeck-0.1.1.dist-info/top_level.txt,sha256=oG_BLYGZA0Qn7BKcwc97jcNzlwC4RkVx1P93IV5fgLw,16
12
+ flexdeck-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ flexdeck-plugin = flexdeck_plugin.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Santosh Panna
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ flexdeck_plugin
@@ -0,0 +1,599 @@
1
+ """Write FlexDeck plugins in Python.
2
+
3
+ A plugin is one :class:`Plugin` with actions, values, events and feeds
4
+ declared on it; the SDK speaks the protocol (JSON-RPC over stdin/stdout, the
5
+ same contract as the TypeScript SDK and ``crates/flexdeck-plugin-abi``)::
6
+
7
+ from flexdeck_plugin import Plugin, arg
8
+
9
+ plugin = Plugin(id="lights", name="Desk lights", version="0.1.0", permissions=["network"])
10
+ plugin.variable("lights.on", "bool", "Whether the desk lights are on")
11
+
12
+ @plugin.action("toggle", "Turn the desk lights on or off")
13
+ def toggle(args, ctx):
14
+ ctx.vars.set({"lights.on": not ctx.vars.get("lights.on")})
15
+
16
+ if __name__ == "__main__":
17
+ plugin.serve()
18
+
19
+ Standard library only, so a packed plugin carries the SDK with it and runs
20
+ on any Python 3.10+ with nothing installed.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import base64
26
+ import hashlib
27
+ import io
28
+ import json
29
+ import math
30
+ import os
31
+ import re
32
+ import sys
33
+ import threading
34
+ from concurrent.futures import ThreadPoolExecutor
35
+ from dataclasses import dataclass, field
36
+ from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Union
37
+
38
+ __all__ = [
39
+ "ABI_VERSION",
40
+ "Argument",
41
+ "Context",
42
+ "Plugin",
43
+ "PluginError",
44
+ "arg",
45
+ "is_valid_id",
46
+ ]
47
+
48
+ #: The contract version this SDK speaks. A Python plugin needs ABI 4.
49
+ ABI_VERSION = 4
50
+ SUPPORTED_ABIS = (4,)
51
+ KINDS = ("text", "number", "bool", "choice")
52
+ PERMISSIONS = ("network", "notifications", "processes", "filesystem")
53
+ MAX_SERIES_POINTS = 600
54
+ MAX_IMAGE_BYTES = 256 * 1024
55
+ IMAGE_MIMES = ("image/png", "image/jpeg", "image/webp")
56
+
57
+ Value = Union[str, float, int, bool]
58
+
59
+ _ID = re.compile(r"^[a-z][a-z0-9_-]{0,63}$")
60
+
61
+
62
+ def is_valid_id(value: str) -> bool:
63
+ """Ids become variable namespaces, folder names and URL segments."""
64
+ return bool(_ID.match(value))
65
+
66
+
67
+ class PluginError(Exception):
68
+ """A plugin defined in a way FlexDeck would refuse, said when it is defined."""
69
+
70
+
71
+ def _require(condition: Any, message: str) -> None:
72
+ if not condition:
73
+ raise PluginError(f"flexdeck plugin: {message}")
74
+
75
+
76
+ @dataclass
77
+ class Argument:
78
+ """One argument of an action or feed. ``example`` must work: the editor
79
+ fills it in, and verification runs every action with it."""
80
+
81
+ kind: str
82
+ example: str
83
+ description: str = ""
84
+ choices: List[str] = field(default_factory=list)
85
+ optional: bool = False
86
+ #: Suggestions for the editor, asked for while someone fills the argument in:
87
+ #: ``suggest(args, ctx) -> [value | (value, label)]``.
88
+ suggest: Optional[Callable[[Dict[str, str], "Context"], Iterable[Any]]] = None
89
+
90
+ def dto(self, name: str) -> Dict[str, Any]:
91
+ return {
92
+ "name": name,
93
+ "description": self.description,
94
+ "example": self.example,
95
+ "kind": self.kind,
96
+ "choices": list(self.choices),
97
+ "optional": self.optional,
98
+ }
99
+
100
+
101
+ def arg(kind: str, example: str, description: str = "", *, choices: Iterable[str] = (), optional: bool = False, suggest=None) -> Argument:
102
+ """An argument: ``arg("text", "desk", "Which room")``."""
103
+ return Argument(kind=kind, example=str(example), description=description, choices=list(choices), optional=optional, suggest=suggest)
104
+
105
+
106
+ @dataclass
107
+ class _Action:
108
+ description: str
109
+ run: Callable[[Dict[str, str], "Context"], Any]
110
+ arguments: Dict[str, Argument]
111
+ check: Optional[Callable[[Dict[str, str], "Context"], Optional[str]]] = None
112
+
113
+
114
+ @dataclass
115
+ class _Feed:
116
+ description: str
117
+ fields: Dict[str, Dict[str, str]]
118
+ arguments: Dict[str, Argument]
119
+ watch: Callable[[str, Dict[str, str], "Context"], Any]
120
+ unwatch: Optional[Callable[[str, "Context"], Any]] = None
121
+
122
+
123
+ def _wrap(kind: str, value: Value) -> Dict[str, Any]:
124
+ if kind == "bool":
125
+ return {"kind": kind, "value": bool(value)}
126
+ if kind == "number":
127
+ return {"kind": kind, "value": float(value)}
128
+ return {"kind": kind, "value": str(value)}
129
+
130
+
131
+ class _Vars:
132
+ def __init__(self, plugin: "Plugin") -> None:
133
+ self._plugin = plugin
134
+
135
+ def set(self, values: Mapping[str, Value]) -> None:
136
+ """Publish values for variables this plugin owns."""
137
+ wire = self._plugin._publish(values)
138
+ if wire:
139
+ self._plugin._notify("vars/set", {"values": wire})
140
+
141
+ def declare(self, variables: Mapping[str, Mapping[str, Any]], replace: bool = False) -> None:
142
+ """Declare variables at run time, one per scene, say:
143
+ ``{"obs.scene.live": {"kind": "bool", "description": "…"}}``."""
144
+ self._plugin._declare(variables, replace)
145
+
146
+ def get(self, name: str) -> Optional[Value]:
147
+ """The last value this plugin published."""
148
+ return self._plugin._published.get(name)
149
+
150
+
151
+ class _Log:
152
+ def __init__(self, plugin: "Plugin") -> None:
153
+ self._plugin = plugin
154
+
155
+ def info(self, message: str) -> None:
156
+ self._plugin._notify("log", {"level": "info", "message": str(message)})
157
+
158
+ def warn(self, message: str) -> None:
159
+ self._plugin._notify("log", {"level": "warn", "message": str(message)})
160
+
161
+ def error(self, message: str) -> None:
162
+ self._plugin._notify("log", {"level": "error", "message": str(message)})
163
+
164
+
165
+ class _Feed_:
166
+ def __init__(self, plugin: "Plugin", instance: str) -> None:
167
+ self._plugin = plugin
168
+ self._instance = instance
169
+
170
+ def set(self, values: Mapping[str, Value]) -> None:
171
+ """Publish one key's values: ``{"temp": 21}`` becomes ``<id>.@<instance>.temp``."""
172
+ plugin = self._plugin
173
+ feed = plugin._watching.get(self._instance)
174
+ _require(feed is not None, f"nothing is watching a feed as '{self._instance}'")
175
+ fields = plugin._feeds[feed].fields
176
+ fresh: Dict[str, Dict[str, str]] = {}
177
+ full: Dict[str, Value] = {}
178
+ for name, value in values.items():
179
+ spec = fields.get(name)
180
+ if spec is None:
181
+ plugin.ctx.log.error(f"'{name}' is not a field of the {feed} feed")
182
+ continue
183
+ full_name = f"{plugin.id}.@{self._instance}.{name}"
184
+ if full_name not in plugin._declared:
185
+ fresh[full_name] = spec
186
+ full[full_name] = value
187
+ if fresh:
188
+ plugin._declare(fresh, False)
189
+ plugin.ctx.vars.set(full)
190
+
191
+
192
+ class Context:
193
+ """What an action, a sample and a feed get: settings, values, events, logs."""
194
+
195
+ def __init__(self, plugin: "Plugin") -> None:
196
+ self._plugin = plugin
197
+ self.id = plugin.id
198
+ self.vars = _Vars(plugin)
199
+ self.log = _Log(plugin)
200
+
201
+ @property
202
+ def settings(self) -> Dict[str, str]:
203
+ """This plugin's settings, defaults filled in. Never another plugin's."""
204
+ return dict(self._plugin._settings)
205
+
206
+ @property
207
+ def data_dir(self) -> Optional[str]:
208
+ """A folder of this plugin's own, kept across updates."""
209
+ return self._plugin._data_dir
210
+
211
+ def emit(self, event: str, payload: Optional[Mapping[str, str]] = None) -> None:
212
+ """Announce an event this plugin declares."""
213
+ if event not in self._plugin._events:
214
+ self.log.error(f"'{event}' is not a declared event")
215
+ return
216
+ self._plugin._notify("event/emit", {"event": event, "payload": {k: str(v) for k, v in (payload or {}).items()}})
217
+
218
+ def notify(self, title: str, body: str = "") -> None:
219
+ """Show a notification. Needs the ``notifications`` permission."""
220
+ self._plugin._notify("notify", {"title": title, "body": body})
221
+
222
+ def store_settings(self, values: Mapping[str, str]) -> None:
223
+ """Keep values for settings this plugin declares (a refreshed token)."""
224
+ for key in values:
225
+ _require(key in self._plugin._settings_spec, f"setting '{key}' is not declared, so it cannot be stored")
226
+ self._plugin._settings.update(values)
227
+ self._plugin._notify("settings/store", {"values": dict(values)})
228
+
229
+ def open_url(self, url: str) -> None:
230
+ """Open an http(s) address in the person's browser. Needs ``network``."""
231
+ _require(re.match(r"^https?://\S+$", url, re.I), f"'{url}' is not an http(s) address")
232
+ self._plugin._notify("host/open_url", {"url": url})
233
+
234
+ def put_image(self, data: bytes, mime: str) -> str:
235
+ """Hand over a picture (≤256 KiB); returns ``img:<sha256>`` for a value to hold."""
236
+ _require(mime in IMAGE_MIMES, f"'{mime}' is not an image type the agent shows")
237
+ _require(len(data) <= MAX_IMAGE_BYTES, f"the image is {len(data)} bytes; the limit is {MAX_IMAGE_BYTES}")
238
+ sha = hashlib.sha256(data).hexdigest()
239
+ self._plugin._notify("images/put", {"sha256": sha, "mime": mime, "data": base64.b64encode(data).decode("ascii")})
240
+ return f"img:{sha}"
241
+
242
+ def set_series(self, name: str, points: Iterable[float]) -> None:
243
+ """A value's recent history, oldest first, for a graph. At most 600 points."""
244
+ _require(name.startswith(f"{self.id}."), f"'{name}' must start with '{self.id}.'")
245
+ finite = [float(p) for p in points if isinstance(p, (int, float)) and math.isfinite(p)][-MAX_SERIES_POINTS:]
246
+ self._plugin._notify("series/set", {"name": name, "points": finite})
247
+
248
+ def feed(self, instance: str) -> _Feed_:
249
+ """Publish values for one key's run of a feed."""
250
+ return _Feed_(self._plugin, instance)
251
+
252
+
253
+ class Plugin:
254
+ """A FlexDeck plugin. Declare on it, then :meth:`serve`."""
255
+
256
+ def __init__(
257
+ self,
258
+ id: str,
259
+ name: str,
260
+ version: str,
261
+ description: str = "",
262
+ author: str = "",
263
+ permissions: Iterable[str] = (),
264
+ ) -> None:
265
+ _require(is_valid_id(id), f"'{id}' is not a usable id (lowercase letters, digits, - and _)")
266
+ _require(re.match(r"^\d[0-9A-Za-z.+-]*$", version) and ".." not in version, f"'{version}' is not a version")
267
+ for permission in permissions:
268
+ _require(permission in PERMISSIONS, f"'{permission}' is not a permission: {', '.join(PERMISSIONS)}")
269
+ self.id = id
270
+ self.name = name
271
+ self.version = version
272
+ self.description = description
273
+ self.author = author
274
+ self.permissions = list(permissions)
275
+ self._actions: Dict[str, _Action] = {}
276
+ self._settings_spec: Dict[str, Dict[str, Any]] = {}
277
+ self._events: Dict[str, Dict[str, Any]] = {}
278
+ self._feeds: Dict[str, _Feed] = {}
279
+ self._presets: List[Dict[str, Any]] = []
280
+ self._declared: Dict[str, Dict[str, Any]] = {}
281
+ self._published: Dict[str, Value] = {}
282
+ self._watching: Dict[str, str] = {}
283
+ self._settings: Dict[str, str] = {}
284
+ self._data_dir: Optional[str] = None
285
+ self._sample: Optional[Callable[[List[str], Context], Optional[Mapping[str, Value]]]] = None
286
+ self._start: Optional[Callable[[Context], Any]] = None
287
+ self._stop: Optional[Callable[[Context], Any]] = None
288
+ self._settings_changed: Optional[Callable[[Context], Any]] = None
289
+ self._lock = threading.RLock()
290
+ self._out_lock = threading.Lock()
291
+ #: What the plugin sent the host unprompted. For tests.
292
+ self.sent: List[Dict[str, Any]] = []
293
+ self._write: Callable[[Dict[str, Any]], None] = self.sent.append
294
+ self.ctx = Context(self)
295
+
296
+ # ---- Declaring ---------------------------------------------------------------
297
+
298
+ def variable(self, name: str, kind: str, description: str = "", *, unit: Optional[str] = None, choices: Iterable[str] = (), read_only: bool = True) -> None:
299
+ """A live value a key can show and an automation react to, under ``<id>.``."""
300
+ prefix = f"{self.id}."
301
+ _require(name.startswith(prefix) and len(name) > len(prefix), f"variable '{name}' must start with '{prefix}'")
302
+ _require(kind in KINDS, f"variable '{name}': '{kind}' is not a kind: {', '.join(KINDS)}")
303
+ self._declared[name] = {"kind": kind, "description": description, "unit": unit, "choices": list(choices), "readOnly": read_only}
304
+
305
+ def setting(self, key: str, kind: str = "text", description: str = "", *, default: Optional[str] = None, choices: Iterable[str] = (), secret: bool = False) -> None:
306
+ """Configuration filled in once. A ``secret`` is never shown again."""
307
+ _require(kind in KINDS, f"setting '{key}': '{kind}' is not a kind")
308
+ self._settings_spec[key] = {"kind": kind, "description": description, "default": default, "choices": list(choices), "secret": secret}
309
+
310
+ def event(self, name: str, description: str = "", fields: Iterable[str] = ()) -> None:
311
+ """Something that happens, which an automation can be started by."""
312
+ self._events[name] = {"description": description, "fields": list(fields)}
313
+
314
+ def action(self, name: str, description: str, arguments: Optional[Mapping[str, Argument]] = None, *, check=None):
315
+ """Decorate a function ``run(args, ctx)`` as an action. It may return
316
+ ``{"state": "<look>"}`` for the key to show."""
317
+ arguments = dict(arguments or {})
318
+ for arg_name, argument in arguments.items():
319
+ _require(argument.kind in KINDS, f"{name}.{arg_name}: '{argument.kind}' is not a kind")
320
+ if argument.kind == "choice":
321
+ _require(argument.example in argument.choices, f"{name}.{arg_name}: the example must be one of its choices")
322
+
323
+ def register(run):
324
+ self._actions[name] = _Action(description=description, run=run, arguments=arguments, check=check)
325
+ return run
326
+
327
+ return register
328
+
329
+ def feed(self, name: str, description: str = "", *, fields: Mapping[str, Union[str, Mapping[str, str]]], arguments: Optional[Mapping[str, Argument]] = None, unwatch=None):
330
+ """Decorate ``watch(instance, args, ctx)``: values for one key, with its
331
+ own arguments (a city), published with ``ctx.feed(instance).set``."""
332
+ specs = {f: ({"kind": s, "description": ""} if isinstance(s, str) else {"kind": s["kind"], "description": s.get("description", "")}) for f, s in fields.items()}
333
+
334
+ def register(watch):
335
+ self._feeds[name] = _Feed(description=description, fields=specs, arguments=dict(arguments or {}), watch=watch, unwatch=unwatch)
336
+ return watch
337
+
338
+ return register
339
+
340
+ def preset(self, id: str, name: str, widget: Mapping[str, Any], *, category: str = "", description: str = "", w: int = 1, h: int = 1) -> None:
341
+ """A ready-made key for the gallery: a widget without id or place."""
342
+ self._presets.append({"id": id, "name": name, "category": category, "description": description, "w": w, "h": h, "widget": dict(widget)})
343
+
344
+ def sample(self, function):
345
+ """Decorate ``sample(wanted, ctx) -> {name: value}``: asked only for what a key shows."""
346
+ self._sample = function
347
+ return function
348
+
349
+ def on_start(self, function):
350
+ self._start = function
351
+ return function
352
+
353
+ def on_stop(self, function):
354
+ self._stop = function
355
+ return function
356
+
357
+ def on_settings_changed(self, function):
358
+ self._settings_changed = function
359
+ return function
360
+
361
+ # ---- What the host is told -------------------------------------------------
362
+
363
+ def manifest(self) -> Dict[str, Any]:
364
+ """What ``initialize`` answers with."""
365
+ manifest: Dict[str, Any] = {
366
+ "abi": ABI_VERSION,
367
+ "id": self.id,
368
+ "name": self.name,
369
+ "version": self.version,
370
+ "description": self.description,
371
+ "author": self.author,
372
+ "actions": [
373
+ {"action": n, "description": a.description, "arguments": [arg.dto(k) for k, arg in a.arguments.items()]}
374
+ for n, a in self._actions.items()
375
+ ],
376
+ "settings": [
377
+ {"key": k, "description": s["description"], "kind": s["kind"], "choices": s["choices"], "defaultValue": s["default"], "secret": s["secret"]}
378
+ for k, s in self._settings_spec.items()
379
+ ],
380
+ "events": [{"name": n, "description": e["description"], "fields": e["fields"]} for n, e in self._events.items()],
381
+ "permissions": list(self.permissions),
382
+ }
383
+ uses = ["series"]
384
+ if self._presets:
385
+ uses.append("presets")
386
+ manifest["presets"] = list(self._presets)
387
+ if self._feeds:
388
+ uses.append("feeds")
389
+ manifest["feeds"] = [
390
+ {
391
+ "name": n,
392
+ "description": f.description,
393
+ "arguments": [arg.dto(k) for k, arg in f.arguments.items()],
394
+ "fields": [{"name": k, "kind": s["kind"], "description": s["description"]} for k, s in f.fields.items()],
395
+ }
396
+ for n, f in self._feeds.items()
397
+ ]
398
+ manifest["uses"] = uses
399
+ return manifest
400
+
401
+ def variables(self) -> List[Dict[str, Any]]:
402
+ with self._lock:
403
+ return [{"name": n, "description": s.get("description", ""), "kind": s["kind"], "unit": s.get("unit"), "choices": s.get("choices", []), "readOnly": s.get("readOnly", True)} for n, s in self._declared.items()]
404
+
405
+ def _notify(self, method: str, params: Dict[str, Any]) -> None:
406
+ self._write({"jsonrpc": "2.0", "method": method, "params": params})
407
+
408
+ def _publish(self, values: Mapping[str, Value]) -> Dict[str, Any]:
409
+ wire: Dict[str, Any] = {}
410
+ with self._lock:
411
+ for name, value in values.items():
412
+ spec = self._declared.get(name)
413
+ if spec is None:
414
+ self.ctx.log.error(f"'{name}' was never declared, so it was not published")
415
+ continue
416
+ if self._published.get(name, object()) == value and type(self._published.get(name)) is type(value):
417
+ continue
418
+ self._published[name] = value
419
+ wire[name] = _wrap(spec["kind"], value)
420
+ return wire
421
+
422
+ def _declare(self, variables: Mapping[str, Mapping[str, Any]], replace: bool) -> None:
423
+ prefix = f"{self.id}."
424
+ for name, spec in variables.items():
425
+ _require(name.startswith(prefix), f"variable '{name}' must start with '{prefix}'")
426
+ _require(spec.get("kind") in KINDS, f"variable '{name}': '{spec.get('kind')}' is not a kind")
427
+ with self._lock:
428
+ if replace:
429
+ for name in list(self._declared):
430
+ if name not in variables:
431
+ self._declared.pop(name, None)
432
+ self._published.pop(name, None)
433
+ for name, spec in variables.items():
434
+ self._declared[name] = {"kind": spec["kind"], "description": spec.get("description", ""), "unit": spec.get("unit"), "choices": list(spec.get("choices", [])), "readOnly": spec.get("readOnly", True)}
435
+ self._notify("vars/declare", {"variables": self.variables(), "replace": replace})
436
+
437
+ def configure(self, settings: Mapping[str, str], data_dir: Optional[str] = None) -> None:
438
+ """Settings as ``initialize`` gives them, defaults filled in. For tests."""
439
+ defaults = {k: s["default"] for k, s in self._settings_spec.items() if s["default"] is not None}
440
+ self._settings = {**defaults, **dict(settings)}
441
+ self._data_dir = data_dir
442
+
443
+ # ---- Answering the host ----------------------------------------------------
444
+
445
+ def handle(self, method: str, params: Optional[Mapping[str, Any]] = None) -> Any:
446
+ """Answer one request as the host sends it. Raises for an error answer."""
447
+ body = dict(params or {})
448
+ ctx = self.ctx
449
+ if method == "initialize":
450
+ asked = body.get("abi")
451
+ if asked not in SUPPORTED_ABIS:
452
+ raise PluginError(f"this plugin speaks ABI {' or '.join(map(str, SUPPORTED_ABIS))}, the agent speaks {asked}")
453
+ self.configure(body.get("settings") or {}, body.get("dataDir"))
454
+ return {"manifest": self.manifest(), "variables": self.variables()}
455
+ if method == "variables":
456
+ return self.variables()
457
+ if method == "check":
458
+ action = self._actions.get(body.get("action", ""))
459
+ if action is None:
460
+ return {"ok": False, "error": f"no action called '{body.get('action')}'"}
461
+ problem = action.check(dict(body.get("args") or {}), ctx) if action.check else None
462
+ return {"ok": False, "error": str(problem)} if problem else {"ok": True}
463
+ if method == "invoke":
464
+ action = self._actions.get(body.get("action", ""))
465
+ if action is None:
466
+ return {"ok": False, "error": f"no action called '{body.get('action')}'"}
467
+ try:
468
+ result = action.run(dict(body.get("args") or {}), ctx)
469
+ except Exception as error: # an action's failure is an answer, not a crash
470
+ return {"ok": False, "error": str(error) or type(error).__name__}
471
+ answer: Dict[str, Any] = {"ok": True}
472
+ if isinstance(result, Mapping) and isinstance(result.get("state"), str):
473
+ answer["state"] = result["state"]
474
+ return answer
475
+ if method == "sample":
476
+ with self._lock:
477
+ wanted = [n for n in body.get("wanted") or [] if n in self._declared]
478
+ if not wanted:
479
+ return {"values": {}}
480
+ returned = self._sample(wanted, ctx) if self._sample else None
481
+ self._publish(returned or {})
482
+ values = {}
483
+ with self._lock:
484
+ for name in wanted:
485
+ if name in self._published:
486
+ values[name] = _wrap(self._declared[name]["kind"], self._published[name])
487
+ return {"values": values}
488
+ if method == "choices":
489
+ action = self._actions.get(body.get("action", ""))
490
+ answer = {"arguments": {}}
491
+ if action is None:
492
+ return {**answer, "error": f"no action called '{body.get('action')}'"}
493
+ args = dict(body.get("args") or {})
494
+ for name, argument in action.arguments.items():
495
+ if not argument.suggest:
496
+ continue
497
+ try:
498
+ suggestions = argument.suggest(args, ctx) or []
499
+ except Exception as error:
500
+ answer.setdefault("error", str(error))
501
+ continue
502
+ answer["arguments"][name] = [
503
+ {"value": str(s[0]), "label": str(s[1])} if isinstance(s, (tuple, list)) else {"value": str(s)} for s in suggestions
504
+ ]
505
+ return answer
506
+ if method == "feed/watch":
507
+ feed = self._feeds.get(body.get("feed", ""))
508
+ if feed is None:
509
+ raise PluginError(f"no feed called '{body.get('feed')}'")
510
+ instance = str(body.get("instance", ""))
511
+ self._watching[instance] = body["feed"]
512
+ given = dict(body.get("args") or {})
513
+ filled = {n: (given[n] if str(given.get(n, "")).strip() else a.example) for n, a in feed.arguments.items()}
514
+ feed.watch(instance, filled, ctx)
515
+ return {}
516
+ if method == "feed/unwatch":
517
+ instance = str(body.get("instance", ""))
518
+ feed = self._watching.pop(instance, None)
519
+ prefix = f"{self.id}.@{instance}."
520
+ with self._lock:
521
+ for name in [n for n in self._declared if n.startswith(prefix)]:
522
+ self._declared.pop(name, None)
523
+ self._published.pop(name, None)
524
+ if feed and self._feeds[feed].unwatch:
525
+ self._feeds[feed].unwatch(instance, ctx)
526
+ return {}
527
+ if method == "settings/changed":
528
+ self.configure(body.get("settings") or {}, self._data_dir)
529
+ if self._settings_changed:
530
+ self._settings_changed(ctx)
531
+ return {}
532
+ if method == "shutdown":
533
+ if self._stop:
534
+ self._stop(ctx)
535
+ return {}
536
+ raise PluginError(f"no method '{method}'")
537
+
538
+ def serve(self) -> None:
539
+ """Speak the protocol on this process's stdin and stdout until the agent goes."""
540
+ # The protocol is UTF-8 lines ending in a bare newline, whatever the
541
+ # console's code page; stdout is only the protocol, so a print by habit
542
+ # goes to the log (stderr) instead.
543
+ out = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", newline="\n")
544
+ incoming = io.TextIOWrapper(sys.stdin.buffer, encoding="utf-8")
545
+ sys.stdout = sys.stderr
546
+
547
+ def write(message: Dict[str, Any]) -> None:
548
+ line = json.dumps(message, separators=(",", ":"), ensure_ascii=False)
549
+ with self._out_lock:
550
+ out.write(line + "\n")
551
+ out.flush()
552
+
553
+ self._write = write
554
+ pool = ThreadPoolExecutor(max_workers=4, thread_name_prefix="flexdeck")
555
+ started = threading.Event()
556
+
557
+ def answer(message: Dict[str, Any]) -> None:
558
+ method = message["method"]
559
+ try:
560
+ result = self.handle(method, message.get("params"))
561
+ write({"jsonrpc": "2.0", "id": message["id"], "result": result if result is not None else {}})
562
+ except Exception as error:
563
+ write({"jsonrpc": "2.0", "id": message["id"], "error": {"code": -32000, "message": str(error)}})
564
+ if method == "initialize" and not started.is_set():
565
+ started.set()
566
+ if self._start:
567
+ # After the answer, so a slow connect never counts against the deadline.
568
+ def start() -> None:
569
+ try:
570
+ self._start(self.ctx)
571
+ except Exception as error:
572
+ self.ctx.log.error(f"start failed: {error}")
573
+
574
+ pool.submit(start)
575
+ if method == "shutdown":
576
+ os._exit(0)
577
+
578
+ for line in incoming:
579
+ line = line.strip()
580
+ if not line:
581
+ continue
582
+ try:
583
+ message = json.loads(line)
584
+ except ValueError:
585
+ self.ctx.log.error(f"the agent sent something that is not JSON: {line[:200]}")
586
+ continue
587
+ if "id" not in message or not message.get("method"):
588
+ continue
589
+ if message["method"] in ("initialize", "shutdown"):
590
+ answer(message)
591
+ else:
592
+ pool.submit(answer, message)
593
+ # The agent closed stdin: it is gone, so follow it.
594
+ if self._stop:
595
+ try:
596
+ self._stop(self.ctx)
597
+ except Exception:
598
+ pass
599
+ os._exit(0)
@@ -0,0 +1,65 @@
1
+ """python -m flexdeck_plugin <new|check|pack|verify> [dir] (also: flexdeck-plugin)"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from . import PluginError, is_valid_id
9
+ from .conformance import conform, describe, find_flexdeck, passed
10
+ from .pack import check, pack
11
+ from .template import scaffold
12
+
13
+ USAGE = """usage: flexdeck-plugin <command> [dir]
14
+
15
+ new <id> ["<Name>"] a new plugin in ./<id>, ready to test and pack
16
+ check [dir] plugin.json and the code agree
17
+ pack [dir] write <id>-<version>.fdpkg, ready to install
18
+ verify [dir] [--run-actions]
19
+ pack, then ask FlexDeck itself whether it will work:
20
+ run as the app runs it, checked at every step"""
21
+
22
+
23
+ def main(argv=None) -> int:
24
+ # A console on an old code page shows what it can rather than crashing.
25
+ for stream in (sys.stdout, sys.stderr):
26
+ if hasattr(stream, "reconfigure"):
27
+ stream.reconfigure(errors="replace")
28
+ args = list(sys.argv[1:] if argv is None else argv)
29
+ command = args[0] if args else "help"
30
+ rest = [a for a in args[1:] if not a.startswith("--")]
31
+ directory = Path(rest[0]) if rest else Path(".")
32
+ try:
33
+ if command == "new":
34
+ if not rest or not is_valid_id(rest[0]):
35
+ print('usage: flexdeck-plugin new <id> ["<Name>"] (id: lowercase letters, digits, - and _)', file=sys.stderr)
36
+ return 1
37
+ target = scaffold(Path(rest[0]), rest[0], rest[1] if len(rest) > 1 else rest[0])
38
+ print(f"created {target}\nnext:\n cd {rest[0]}\n python -m unittest\n python -m flexdeck_plugin verify")
39
+ return 0
40
+ if command == "check":
41
+ plugin = check(directory)
42
+ print(f"{plugin.id} {plugin.version}: plugin.json and the code agree")
43
+ return 0
44
+ if command == "pack":
45
+ packed = pack(directory)
46
+ print(f"packed {packed.file}\n {packed.size} bytes\n sha256 {packed.sha256}")
47
+ return 0
48
+ if command == "verify":
49
+ flexdeck = find_flexdeck(directory)
50
+ if not flexdeck:
51
+ print("verify asks FlexDeck itself, and none was found. Install the FlexDeck app, or set FLEXDECK_BIN to its executable.", file=sys.stderr)
52
+ return 1
53
+ packed = pack(directory)
54
+ report = conform(packed.file, flexdeck, run_actions="--run-actions" in args)
55
+ print(describe(report))
56
+ return 0 if passed(report) else 1
57
+ except (PluginError, RuntimeError) as error:
58
+ print(f"flexdeck-plugin: {error}", file=sys.stderr)
59
+ return 1
60
+ print(USAGE)
61
+ return 0 if command in ("help", "--help", "-h") else 1
62
+
63
+
64
+ if __name__ == "__main__":
65
+ sys.exit(main())
@@ -0,0 +1,73 @@
1
+ """Will this plugin work in FlexDeck? Asked of FlexDeck itself.
2
+
3
+ ``flexdeck-plugin verify`` packs the plugin and hands it to the FlexDeck on
4
+ this computer (``flexdeck verify-plugin``), which runs it exactly as it would
5
+ after an install (the same host, deadlines and rules) and checks it at every
6
+ step. Nothing here re-implements those rules, so they cannot drift from the
7
+ app's; the TypeScript SDK asks the same way.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ import tempfile
18
+ from pathlib import Path
19
+ from typing import Mapping, Optional
20
+
21
+ EXE = "flexdeck.exe" if os.name == "nt" else "flexdeck"
22
+
23
+
24
+ def find_flexdeck(start: Optional[Path] = None, env: Optional[Mapping[str, str]] = None) -> Optional[str]:
25
+ """FLEXDECK_BIN; a build in a FlexDeck checkout above ``start``; the PATH; where the app installs."""
26
+ env = os.environ if env is None else env
27
+ if env.get("FLEXDECK_BIN"):
28
+ return env["FLEXDECK_BIN"] if Path(env["FLEXDECK_BIN"]).is_file() else None
29
+ here = Path(start or Path.cwd()).resolve()
30
+ for directory in [here, *here.parents]:
31
+ for build in ("debug", "release"):
32
+ candidate = directory / "target" / build / EXE
33
+ if candidate.is_file():
34
+ return str(candidate)
35
+ on_path = shutil.which("flexdeck", path=env.get("PATH"))
36
+ if on_path:
37
+ return on_path
38
+ if os.name == "nt":
39
+ installed = [Path(env[k]) / "flexdeck" / EXE for k in ("LOCALAPPDATA", "ProgramFiles") if env.get(k)]
40
+ elif sys.platform == "darwin":
41
+ installed = [Path("/Applications/flexdeck.app/Contents/MacOS/flexdeck")]
42
+ else:
43
+ installed = [Path("/usr/bin/flexdeck"), Path("/usr/local/bin/flexdeck")]
44
+ return next((str(p) for p in installed if p.is_file()), None)
45
+
46
+
47
+ def conform(package: Path, flexdeck: str, run_actions: bool = False) -> dict:
48
+ """Ask ``flexdeck`` whether the package (a .fdpkg or a package folder) will work."""
49
+ with tempfile.TemporaryDirectory(prefix="flexdeck-verify-") as scratch:
50
+ out = Path(scratch) / "report.json"
51
+ args = [flexdeck, "verify-plugin", str(Path(package).resolve()), "--out", str(out)]
52
+ if run_actions:
53
+ args.append("--run-actions")
54
+ run = subprocess.run(args, capture_output=True, text=True, timeout=120)
55
+ if not out.is_file():
56
+ raise RuntimeError(f"{flexdeck} could not verify it: {(run.stderr or 'no report').strip()}")
57
+ return json.loads(out.read_text(encoding="utf-8"))
58
+
59
+
60
+ def passed(report: dict) -> bool:
61
+ return all(check["ok"] for check in report["checks"])
62
+
63
+
64
+ def describe(report: dict) -> str:
65
+ lines = [f"{report['plugin']} ({report['runtime']})"]
66
+ for check in report["checks"]:
67
+ lines.append((" ok " if check["ok"] else " FAIL") + f" {check['name']}" + (f" — {check['detail']}" if check["detail"] else ""))
68
+ failed = sum(1 for check in report["checks"] if not check["ok"])
69
+ if failed == 0:
70
+ lines += ["", "It will work in FlexDeck."]
71
+ else:
72
+ lines += ["", f"{failed} check{'' if failed == 1 else 's'} failed: fix {'it' if failed == 1 else 'them'} before anyone installs it."]
73
+ return "\n".join(lines)
@@ -0,0 +1,109 @@
1
+ """Package a plugin: ``<id>-<version>.fdpkg``, ready to install.
2
+
3
+ The package carries the plugin's own Python files and this SDK beside them, so
4
+ it runs on any Python 3.10+ with nothing installed. ``plugin.json`` and the
5
+ code must agree on who the plugin is (the agent refuses a plugin whose answer
6
+ differs from its package), so that is checked first.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import importlib.util
13
+ import json
14
+ import os
15
+ import sys
16
+ import zipfile
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+ from typing import List, Optional
20
+
21
+ from . import ABI_VERSION, Plugin, PluginError, is_valid_id
22
+
23
+ SDK_DIR = Path(__file__).resolve().parent
24
+ #: Never packed: tests, caches, packages, environments, and a copy of the SDK
25
+ #: (the one running this is packed instead).
26
+ SKIP_DIRS = {"__pycache__", "tests", "test", ".venv", "venv", "dist", "build", ".git", ".pytest_cache", "flexdeck_plugin"}
27
+ PACKED = (".py", ".json", ".txt", ".md", ".png", ".svg")
28
+
29
+
30
+ @dataclass
31
+ class Packed:
32
+ file: Path
33
+ size: int
34
+ sha256: str
35
+
36
+
37
+ def read_package(directory: Path) -> dict:
38
+ path = directory / "plugin.json"
39
+ if not path.is_file():
40
+ raise PluginError(f"no plugin.json in {directory}")
41
+ package = json.loads(path.read_text(encoding="utf-8"))
42
+ problems = []
43
+ if package.get("runtime") != "python":
44
+ problems.append('runtime must be "python"')
45
+ if package.get("abi") != ABI_VERSION:
46
+ problems.append(f"abi must be {ABI_VERSION}")
47
+ if not is_valid_id(str(package.get("id", ""))):
48
+ problems.append("id: lowercase letters, digits, - and _, starting with a letter")
49
+ entry = package.get("entry")
50
+ if not entry or not (directory / entry).is_file():
51
+ problems.append(f"entry: '{entry}' is not a file in the plugin")
52
+ if problems:
53
+ raise PluginError("plugin.json: " + "; ".join(problems))
54
+ return package
55
+
56
+
57
+ def load_plugin(directory: Path, entry: str) -> Plugin:
58
+ """Import the entry file (without serving it) and find its Plugin."""
59
+ sys.path.insert(0, str(directory))
60
+ try:
61
+ spec = importlib.util.spec_from_file_location("_flexdeck_entry", directory / entry)
62
+ module = importlib.util.module_from_spec(spec)
63
+ assert spec.loader is not None
64
+ spec.loader.exec_module(module)
65
+ finally:
66
+ sys.path.remove(str(directory))
67
+ found = [value for value in vars(module).values() if isinstance(value, Plugin)]
68
+ if len(found) != 1:
69
+ raise PluginError(f"{entry} should define exactly one Plugin; it defines {len(found)}")
70
+ return found[0]
71
+
72
+
73
+ def check(directory: Path) -> Plugin:
74
+ """plugin.json and the code agree, as the agent will require."""
75
+ directory = Path(directory).resolve()
76
+ package = read_package(directory)
77
+ plugin = load_plugin(directory, package["entry"])
78
+ for key, value in (("id", plugin.id), ("name", plugin.name), ("version", plugin.version)):
79
+ if package.get(key) != value:
80
+ raise PluginError(f"plugin.json says {key} '{package.get(key)}', the code says '{value}'")
81
+ extra = sorted(set(plugin.permissions) - set(package.get("permissions", [])))
82
+ if extra:
83
+ raise PluginError(f"the code asks for {', '.join(extra)}, which plugin.json does not declare")
84
+ return plugin
85
+
86
+
87
+ def _files(directory: Path) -> List[Path]:
88
+ files = []
89
+ for root, dirs, names in os.walk(directory):
90
+ dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS and not d.startswith("."))
91
+ for name in sorted(names):
92
+ if name.endswith(PACKED):
93
+ files.append(Path(root) / name)
94
+ return files
95
+
96
+
97
+ def pack(directory: Path, out: Optional[Path] = None) -> Packed:
98
+ directory = Path(directory).resolve()
99
+ check(directory)
100
+ package = read_package(directory)
101
+ target = Path(out) if out else directory / f"{package['id']}-{package['version']}.fdpkg"
102
+ with zipfile.ZipFile(target, "w", zipfile.ZIP_DEFLATED) as archive:
103
+ for path in _files(directory):
104
+ archive.write(path, path.relative_to(directory).as_posix())
105
+ for path in sorted(SDK_DIR.rglob("*")):
106
+ if path.is_file() and "__pycache__" not in path.parts and path.suffix in (".py", ".json"):
107
+ archive.write(path, "flexdeck_plugin/" + path.relative_to(SDK_DIR).as_posix())
108
+ data = target.read_bytes()
109
+ return Packed(file=target, size=len(data), sha256=hashlib.sha256(data).hexdigest())
@@ -0,0 +1,134 @@
1
+ """The plugin ``flexdeck-plugin new`` writes: every part used once, tested, packable."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ PLUGIN = '''"""NAME: a FlexDeck plugin.
9
+
10
+ python -m unittest run its tests, without FlexDeck
11
+ python -m flexdeck_plugin verify ask FlexDeck itself whether it will work
12
+ python -m flexdeck_plugin pack write ID-0.1.0.fdpkg, ready to install
13
+
14
+ Everything a plugin can declare is used once below. Delete what you do not need.
15
+ """
16
+
17
+ from flexdeck_plugin import Plugin, arg
18
+
19
+ # The id is the namespace for everything this plugin owns: its values are
20
+ # `ID.<name>`, and a key's action says plugin "ID". It must match plugin.json
21
+ # and can never change once people use it.
22
+ plugin = Plugin(
23
+ id="ID",
24
+ name="NAME",
25
+ version="0.1.0",
26
+ description="A starting point for a FlexDeck plugin.",
27
+ author="you",
28
+ # Shown before anyone installs it. Ask only for what you use.
29
+ permissions=["notifications"],
30
+ )
31
+
32
+ # Configuration filled in once. A secret is never shown again once saved.
33
+ plugin.setting("greeting", "text", "The word to greet with", default="Hello")
34
+
35
+ # Live values a key can show and an automation can react to.
36
+ plugin.variable("ID.greetings", "number", "How many greetings so far")
37
+
38
+ # Things that happen, which an automation can be started by.
39
+ plugin.event("greeted", "Someone was greeted", fields=["name"])
40
+
41
+
42
+ # Every argument has an example that works: the editor fills it in, and the
43
+ # tests and `verify` run every action with it.
44
+ @plugin.action("greet", "Greet someone", {"name": arg("text", "world", "Who to greet")})
45
+ def greet(args, ctx):
46
+ count = (ctx.vars.get("ID.greetings") or 0) + 1
47
+ ctx.vars.set({"ID.greetings": count})
48
+ ctx.emit("greeted", {"name": args["name"]})
49
+ ctx.notify(f"{ctx.settings['greeting']}, {args['name']}!")
50
+
51
+
52
+ # Asked only for values something is showing.
53
+ @plugin.sample
54
+ def sample(wanted, ctx):
55
+ return {"ID.greetings": ctx.vars.get("ID.greetings") or 0}
56
+
57
+
58
+ # A feed: values each key gets for itself, with its own arguments. A key
59
+ # reads them as {{this.word}}.
60
+ @plugin.feed("shout", "A word, louder", fields={"word": "text"}, arguments={"word": arg("text", "hello", "The word")})
61
+ def shout(instance, args, ctx):
62
+ ctx.feed(instance).set({"word": args["word"].upper() + "!"})
63
+
64
+
65
+ # A ready-made key for the gallery.
66
+ plugin.preset(
67
+ "shout",
68
+ "Shout",
69
+ {"sources": [{"source": "ID/shout"}], "face": {"layout": "FACE_LAYOUT_VALUE", "elements": [{"slot": "SLOT_VALUE", "template": "{{this.word}}"}]}},
70
+ category="Plugins",
71
+ description="A word, louder.",
72
+ )
73
+
74
+ if __name__ == "__main__":
75
+ plugin.serve()
76
+ '''
77
+
78
+ TEST = '''import unittest
79
+
80
+ from flexdeck_plugin.testing import Harness, check_definition
81
+
82
+ from plugin import plugin
83
+
84
+
85
+ class NAMEClassTest(unittest.TestCase):
86
+ def test_flexdeck_would_accept_the_definition(self):
87
+ self.assertEqual(check_definition(plugin), [])
88
+
89
+ def test_greeting_counts_and_announces(self):
90
+ host = Harness(plugin, settings={"greeting": "Hi"})
91
+ self.assertTrue(host.invoke("greet", {"name": "ada"})["ok"])
92
+ self.assertEqual(host.value("ID.greetings"), 1)
93
+ self.assertEqual(host.sent("event/emit"), [{"event": "greeted", "payload": {"name": "ada"}}])
94
+ self.assertEqual(host.sent("notify")[0]["title"], "Hi, ada!")
95
+
96
+ def test_a_feed_publishes_under_its_key(self):
97
+ host = Harness(plugin)
98
+ host.watch("shout", "w1", {"word": "hey"})
99
+ self.assertEqual(host.value("ID.@w1.word"), "HEY!")
100
+
101
+
102
+ if __name__ == "__main__":
103
+ unittest.main()
104
+ '''
105
+
106
+
107
+ def scaffold(target: Path, id: str, name: str) -> Path:
108
+ """Write a new plugin into ``target`` (which must not exist)."""
109
+ target = Path(target)
110
+ if target.exists():
111
+ raise FileExistsError(f"{target} already exists")
112
+ (target / "tests").mkdir(parents=True)
113
+ class_name = "".join(part.capitalize() for part in id.replace("-", "_").split("_")) or "Plugin"
114
+ fill = lambda text: text.replace("NAMEClass", class_name).replace("ID", id).replace("NAME", name) # noqa: E731
115
+ (target / "plugin.py").write_text(fill(PLUGIN), encoding="utf-8")
116
+ (target / "tests" / "__init__.py").write_text("", encoding="utf-8")
117
+ (target / "tests" / "test_plugin.py").write_text(fill(TEST), encoding="utf-8")
118
+ package = {
119
+ "abi": 4,
120
+ "id": id,
121
+ "name": name,
122
+ "version": "0.1.0",
123
+ "description": "A starting point for a FlexDeck plugin.",
124
+ "author": "you",
125
+ "runtime": "python",
126
+ "entry": "plugin.py",
127
+ "permissions": ["notifications"],
128
+ }
129
+ (target / "plugin.json").write_text(json.dumps(package, indent=2) + "\n", encoding="utf-8")
130
+ (target / "README.md").write_text(
131
+ f"# {name}\n\nA FlexDeck plugin.\n\n python -m unittest\n python -m flexdeck_plugin verify\n python -m flexdeck_plugin pack\n",
132
+ encoding="utf-8",
133
+ )
134
+ return target
@@ -0,0 +1,91 @@
1
+ """Test a plugin without the agent: the host's side of the conversation, in process.
2
+
3
+ from flexdeck_plugin.testing import Harness, check_definition
4
+ from plugin import plugin
5
+
6
+ def test_every_action_accepts_its_examples():
7
+ assert check_definition(plugin) == []
8
+
9
+ def test_toggle():
10
+ host = Harness(plugin)
11
+ assert host.invoke("toggle")["ok"]
12
+ assert host.value("lights.on") is True
13
+
14
+ For the whole picture (run as FlexDeck runs it), ``flexdeck-plugin verify``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Any, Dict, List, Mapping, Optional
20
+
21
+ from . import ABI_VERSION, KINDS, Plugin
22
+
23
+
24
+ class Harness:
25
+ """Plays the agent: ``initialize`` first, then whatever a test asks."""
26
+
27
+ def __init__(self, plugin: Plugin, settings: Optional[Mapping[str, str]] = None, data_dir: Optional[str] = None) -> None:
28
+ self.plugin = plugin
29
+ plugin.sent.clear()
30
+ self.introduction = plugin.handle("initialize", {"abi": ABI_VERSION, "settings": dict(settings or {}), "dataDir": data_dir})
31
+
32
+ def invoke(self, action: str, args: Optional[Mapping[str, str]] = None) -> Dict[str, Any]:
33
+ return self.plugin.handle("invoke", {"action": action, "args": dict(args or {})})
34
+
35
+ def check(self, action: str, args: Optional[Mapping[str, str]] = None) -> Dict[str, Any]:
36
+ return self.plugin.handle("check", {"action": action, "args": dict(args or {})})
37
+
38
+ def sample(self, wanted: List[str]) -> Dict[str, Any]:
39
+ return self.plugin.handle("sample", {"wanted": list(wanted)})["values"]
40
+
41
+ def watch(self, feed: str, instance: str = "test", args: Optional[Mapping[str, str]] = None) -> None:
42
+ self.plugin.handle("feed/watch", {"instance": instance, "feed": feed, "args": dict(args or {})})
43
+
44
+ def unwatch(self, instance: str = "test") -> None:
45
+ self.plugin.handle("feed/unwatch", {"instance": instance})
46
+
47
+ def value(self, name: str) -> Any:
48
+ """The last value published for ``name``, as the host would hold it."""
49
+ found = None
50
+ for message in self.plugin.sent:
51
+ if message["method"] == "vars/set" and name in message["params"]["values"]:
52
+ found = message["params"]["values"][name]["value"]
53
+ return found
54
+
55
+ def sent(self, method: str) -> List[Dict[str, Any]]:
56
+ return [m["params"] for m in self.plugin.sent if m["method"] == method]
57
+
58
+
59
+ def check_definition(plugin: Plugin) -> List[str]:
60
+ """Problems FlexDeck would find in the definition: arguments without a
61
+ working example, values outside the namespace, actions that refuse their
62
+ own examples. Empty: none."""
63
+ problems: List[str] = []
64
+ manifest = plugin.manifest()
65
+ owners = [(a["action"], a["arguments"]) for a in manifest["actions"]] + [(f["name"], f["arguments"]) for f in manifest.get("feeds", [])]
66
+ for owner, arguments in owners:
67
+ for argument in arguments:
68
+ where = f"{owner}.{argument['name']}"
69
+ example = argument["example"]
70
+ if argument["kind"] not in KINDS:
71
+ problems.append(f"{where}: '{argument['kind']}' is not a kind")
72
+ elif not example:
73
+ if not argument["optional"]:
74
+ problems.append(f"{where} has no example")
75
+ elif argument["kind"] == "number":
76
+ try:
77
+ float(example)
78
+ except ValueError:
79
+ problems.append(f"{where}: '{example}' is not a number")
80
+ elif argument["kind"] == "bool" and example not in ("true", "false"):
81
+ problems.append(f"{where}: '{example}' is not true or false")
82
+ for variable in plugin.variables():
83
+ if not variable["name"].startswith(f"{plugin.id}."):
84
+ problems.append(f"{variable['name']} is not under {plugin.id}.")
85
+ host = Harness(plugin)
86
+ for action in manifest["actions"]:
87
+ args = {a["name"]: a["example"] for a in action["arguments"] if a["example"]}
88
+ answer = host.check(action["action"], args)
89
+ if not answer["ok"]:
90
+ problems.append(f"{action['action']} refuses its own examples: {answer.get('error')}")
91
+ return problems