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.
- flexdeck-0.1.1.dist-info/METADATA +82 -0
- flexdeck-0.1.1.dist-info/RECORD +12 -0
- flexdeck-0.1.1.dist-info/WHEEL +5 -0
- flexdeck-0.1.1.dist-info/entry_points.txt +2 -0
- flexdeck-0.1.1.dist-info/licenses/LICENSE +21 -0
- flexdeck-0.1.1.dist-info/top_level.txt +1 -0
- flexdeck_plugin/__init__.py +599 -0
- flexdeck_plugin/__main__.py +65 -0
- flexdeck_plugin/conformance.py +73 -0
- flexdeck_plugin/pack.py +109 -0
- flexdeck_plugin/template.py +134 -0
- flexdeck_plugin/testing.py +91 -0
|
@@ -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,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)
|
flexdeck_plugin/pack.py
ADDED
|
@@ -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
|