plumcraft-engine 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plumcraft_engine/__init__.py +546 -0
- plumcraft_engine/arrays.py +249 -0
- plumcraft_engine/audio.py +72 -0
- plumcraft_engine/bind.py +147 -0
- plumcraft_engine/cli.py +399 -0
- plumcraft_engine/color.py +505 -0
- plumcraft_engine/conformance.py +357 -0
- plumcraft_engine/data.py +498 -0
- plumcraft_engine/declare.py +704 -0
- plumcraft_engine/exec.py +1011 -0
- plumcraft_engine/files.py +430 -0
- plumcraft_engine/host.py +402 -0
- plumcraft_engine/http.py +215 -0
- plumcraft_engine/image.py +62 -0
- plumcraft_engine/media.py +73 -0
- plumcraft_engine/memo.py +99 -0
- plumcraft_engine/new.py +460 -0
- plumcraft_engine/persist.py +151 -0
- plumcraft_engine/scratch.py +56 -0
- plumcraft_engine/seq.py +308 -0
- plumcraft_engine/serve.py +190 -0
- plumcraft_engine/slices.py +64 -0
- plumcraft_engine/svg.py +64 -0
- plumcraft_engine/templates/engine/README.md.tmpl +90 -0
- plumcraft_engine/templates/engine/conftest.py.tmpl +20 -0
- plumcraft_engine/templates/engine/engine.py.tmpl +16 -0
- plumcraft_engine/templates/engine/fragments/array.py.tmpl +14 -0
- plumcraft_engine/templates/engine/fragments/audio.py.tmpl +18 -0
- plumcraft_engine/templates/engine/fragments/audio_array.py.tmpl +13 -0
- plumcraft_engine/templates/engine/fragments/helpers_image.py.tmpl +15 -0
- plumcraft_engine/templates/engine/fragments/helpers_list.py.tmpl +41 -0
- plumcraft_engine/templates/engine/fragments/helpers_svg.py.tmpl +2 -0
- plumcraft_engine/templates/engine/fragments/image.py.tmpl +14 -0
- plumcraft_engine/templates/engine/fragments/image_array.py.tmpl +14 -0
- plumcraft_engine/templates/engine/fragments/json.py.tmpl +14 -0
- plumcraft_engine/templates/engine/fragments/json_text.py.tmpl +13 -0
- plumcraft_engine/templates/engine/fragments/scalars.py.tmpl +20 -0
- plumcraft_engine/templates/engine/fragments/slow_scalars.py.tmpl +13 -0
- plumcraft_engine/templates/engine/fragments/slow_text.py.tmpl +13 -0
- plumcraft_engine/templates/engine/fragments/svg.py.tmpl +17 -0
- plumcraft_engine/templates/engine/fragments/svg_text.py.tmpl +10 -0
- plumcraft_engine/templates/engine/fragments/table.py.tmpl +23 -0
- plumcraft_engine/templates/engine/fragments/table_array.py.tmpl +18 -0
- plumcraft_engine/templates/engine/fragments/table_text.py.tmpl +25 -0
- plumcraft_engine/templates/engine/fragments/test_nodes_array.py.tmpl +10 -0
- plumcraft_engine/templates/engine/fragments/test_nodes_audio.py.tmpl +7 -0
- plumcraft_engine/templates/engine/fragments/test_nodes_image.py.tmpl +6 -0
- plumcraft_engine/templates/engine/fragments/test_nodes_json.py.tmpl +6 -0
- plumcraft_engine/templates/engine/fragments/test_nodes_list.py.tmpl +8 -0
- plumcraft_engine/templates/engine/fragments/test_nodes_scalars.py.tmpl +14 -0
- plumcraft_engine/templates/engine/fragments/test_nodes_svg.py.tmpl +8 -0
- plumcraft_engine/templates/engine/fragments/test_nodes_table.py.tmpl +7 -0
- plumcraft_engine/templates/engine/fragments/test_nodes_text.py.tmpl +12 -0
- plumcraft_engine/templates/engine/fragments/test_pairs_audio_array.py.tmpl +6 -0
- plumcraft_engine/templates/engine/fragments/test_pairs_image_array.py.tmpl +5 -0
- plumcraft_engine/templates/engine/fragments/test_pairs_json_text.py.tmpl +5 -0
- plumcraft_engine/templates/engine/fragments/test_pairs_list.py.tmpl +5 -0
- plumcraft_engine/templates/engine/fragments/test_pairs_scalars.py.tmpl +5 -0
- plumcraft_engine/templates/engine/fragments/test_pairs_svg_text.py.tmpl +5 -0
- plumcraft_engine/templates/engine/fragments/test_pairs_table_array.py.tmpl +5 -0
- plumcraft_engine/templates/engine/fragments/test_pairs_table_text.py.tmpl +5 -0
- plumcraft_engine/templates/engine/fragments/test_pairs_text.py.tmpl +5 -0
- plumcraft_engine/templates/engine/fragments/text.py.tmpl +32 -0
- plumcraft_engine/templates/engine/gitignore.tmpl +13 -0
- plumcraft_engine/templates/engine/main.py.tmpl +15 -0
- plumcraft_engine/templates/engine/plumcraft-engine.toml.tmpl +13 -0
- plumcraft_engine/templates/engine/pycharm/idea.gitignore.tmpl +4 -0
- plumcraft_engine/templates/engine/pycharm/module.iml.tmpl +12 -0
- plumcraft_engine/templates/engine/pycharm/module.run.xml.tmpl +24 -0
- plumcraft_engine/templates/engine/pycharm/modules.xml.tmpl +8 -0
- plumcraft_engine/templates/engine/pycharm/pytest.run.xml.tmpl +19 -0
- plumcraft_engine/templates/engine/pycharm/shell.run.xml.tmpl +17 -0
- plumcraft_engine/templates/engine/pyproject.toml.tmpl +39 -0
- plumcraft_engine/templates/engine/scripts/_plumcraft.sh.tmpl +23 -0
- plumcraft_engine/templates/engine/scripts/check.tmpl +7 -0
- plumcraft_engine/templates/engine/scripts/cli-run.tmpl +9 -0
- plumcraft_engine/templates/engine/scripts/conform.tmpl +12 -0
- plumcraft_engine/templates/engine/scripts/editor.tmpl +9 -0
- plumcraft_engine/templates/engine/scripts/install.tmpl +8 -0
- plumcraft_engine/templates/engine/scripts/nodes.tmpl +5 -0
- plumcraft_engine/templates/engine/scripts/run.tmpl +13 -0
- plumcraft_engine/templates/engine/scripts/serve.tmpl +9 -0
- plumcraft_engine/templates/engine/scripts/uninstall.tmpl +10 -0
- plumcraft_engine/templates/engine/test_conformance.py.tmpl +9 -0
- plumcraft_engine/templates/engine/test_engine.py.tmpl +30 -0
- plumcraft_engine/templates/engine/test_nodes.py.tmpl +4 -0
- plumcraft_engine/templates/engine/test_roundtrips.py.tmpl +4 -0
- plumcraft_engine/vary.py +390 -0
- plumcraft_engine-0.1.0.dist-info/METADATA +161 -0
- plumcraft_engine-0.1.0.dist-info/RECORD +96 -0
- plumcraft_engine-0.1.0.dist-info/WHEEL +4 -0
- plumcraft_engine-0.1.0.dist-info/entry_points.txt +3 -0
- plumcraft_engine-0.1.0.dist-info/licenses/LICENSE-APACHE +202 -0
- plumcraft_engine-0.1.0.dist-info/licenses/LICENSE-MIT +21 -0
- plumcraft_text/__init__.py +131 -0
- plumcraft_text/__main__.py +15 -0
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
"""PlumCraft's engine SDK in Python: the same contract as the Rust `plumcraft-engine` crate.
|
|
2
|
+
|
|
3
|
+
Declare an engine as a class with `@node` methods (`plumcraft_engine.declare`, and
|
|
4
|
+
`python/README.md`), and serve it with `MyEngine.serve()`. Underneath is the low-level layer the
|
|
5
|
+
declarations compile to: `Node` subclasses with a `definition` dict (or `simple`), collected in an
|
|
6
|
+
`Engine(info)` and served with `plumcraft_engine.serve.main` (mode 2, spec 16.3; mode 3 with
|
|
7
|
+
`--listen`). The executor emits every status
|
|
8
|
+
record the interface requires (spec 9.4); the host answers everything off the run path from a
|
|
9
|
+
lock-protected run table (spec 5.5).
|
|
10
|
+
|
|
11
|
+
Messages are plain dicts in protocol 2's JSON shape. Readers on the UI side default every field
|
|
12
|
+
a message leaves out, so this SDK sends what it means and nothing else.
|
|
13
|
+
|
|
14
|
+
Standard library only. Stdout is never the data channel: diagnostics go to stderr.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import functools
|
|
20
|
+
import hashlib
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from typing import Any, ClassVar
|
|
28
|
+
|
|
29
|
+
PROTOCOL = 2
|
|
30
|
+
|
|
31
|
+
# Protocol error codes (spec 12.2), as JSON-RPC numbers.
|
|
32
|
+
PARSE = -32700
|
|
33
|
+
INVALID_REQUEST = -32600
|
|
34
|
+
UNKNOWN_OP = -32601
|
|
35
|
+
BAD_PARAMS = -32602
|
|
36
|
+
INTERNAL = -32603
|
|
37
|
+
VERSION = -32000
|
|
38
|
+
NOT_FOUND = -32003
|
|
39
|
+
UNAUTHORIZED = -32005
|
|
40
|
+
UNAVAILABLE = -32006
|
|
41
|
+
|
|
42
|
+
# Capabilities the engine offers whether or not the client listed them (spec 5.3).
|
|
43
|
+
_ENGINE_ONLY = {"run.plan", "run.peek", "run.watch", "run.multi", "graph.put", "data.cas"}
|
|
44
|
+
|
|
45
|
+
# Progress records are sent at most this often per node (spec 9.4).
|
|
46
|
+
PROGRESS_EVERY_S = 0.1
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def now_ts() -> float:
|
|
50
|
+
"""Seconds since the epoch, as status records carry it."""
|
|
51
|
+
return time.time()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ProtoError(Exception):
|
|
55
|
+
"""A protocol error: the reply to a request that could not be served (spec 12.2)."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, code: int, message: str):
|
|
58
|
+
super().__init__(message)
|
|
59
|
+
self.code = code
|
|
60
|
+
self.message = message
|
|
61
|
+
|
|
62
|
+
def to_json(self) -> dict:
|
|
63
|
+
return {"code": self.code, "message": self.message}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class NodeError(Exception):
|
|
67
|
+
"""A node's failure, carried as data in records and replies (spec 12.1)."""
|
|
68
|
+
|
|
69
|
+
OP = "op"
|
|
70
|
+
CONFIG = "config"
|
|
71
|
+
GRAPH = "graph"
|
|
72
|
+
SKIPPED = "skipped"
|
|
73
|
+
CANCELLED = "cancelled"
|
|
74
|
+
ENGINE = "engine"
|
|
75
|
+
MISSING_SOURCE = "missing_source"
|
|
76
|
+
|
|
77
|
+
def __init__(self, kind: str, message: str):
|
|
78
|
+
super().__init__(message)
|
|
79
|
+
self.kind = kind
|
|
80
|
+
self.message = message
|
|
81
|
+
|
|
82
|
+
def to_json(self) -> dict:
|
|
83
|
+
return {"kind": self.kind, "message": self.message}
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def cancelled(cls) -> NodeError:
|
|
87
|
+
return cls(cls.CANCELLED, "cancelled")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class CancelToken:
|
|
91
|
+
"""A run's cancel flag: set at once off the run path, checked by the executor and long nodes."""
|
|
92
|
+
|
|
93
|
+
def __init__(self) -> None:
|
|
94
|
+
self._flag = threading.Event()
|
|
95
|
+
|
|
96
|
+
def cancel(self) -> None:
|
|
97
|
+
self._flag.set()
|
|
98
|
+
|
|
99
|
+
def is_cancelled(self) -> bool:
|
|
100
|
+
return self._flag.is_set()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Cx:
|
|
104
|
+
"""What a running node can do besides compute."""
|
|
105
|
+
|
|
106
|
+
def __init__(self, node_id: str, cancel: CancelToken, record: Callable[[dict], None], setup: list[float] | None = None):
|
|
107
|
+
self.node_id = node_id
|
|
108
|
+
self._cancel = cancel
|
|
109
|
+
self._record = record
|
|
110
|
+
self._last_progress: float | None = None
|
|
111
|
+
self._setup = setup if setup is not None else [0.0]
|
|
112
|
+
|
|
113
|
+
def cancelled(self) -> bool:
|
|
114
|
+
return self._cancel.is_cancelled()
|
|
115
|
+
|
|
116
|
+
def setup_took(self, seconds: float) -> None:
|
|
117
|
+
"""Time this call spent getting ready rather than working (waiting for or starting a child
|
|
118
|
+
process). The memo leaves it out when it weighs whether a result is slow enough to persist
|
|
119
|
+
(spec 14.5), so a cold start doesn't make a trivial node look expensive."""
|
|
120
|
+
self._setup[0] += max(0.0, float(seconds))
|
|
121
|
+
|
|
122
|
+
def progress(self, fraction: float, stage: str | None = None) -> None:
|
|
123
|
+
"""Report progress (0..1) and an optional stage text, rate-limited to one record per 100 ms."""
|
|
124
|
+
now = time.monotonic()
|
|
125
|
+
if self._last_progress is not None and now - self._last_progress < PROGRESS_EVERY_S:
|
|
126
|
+
return
|
|
127
|
+
self._last_progress = now
|
|
128
|
+
rec = status(self.node_id, "progress")
|
|
129
|
+
rec["fraction"] = min(max(float(fraction), 0.0), 1.0)
|
|
130
|
+
if stage is not None:
|
|
131
|
+
rec["stage"] = stage
|
|
132
|
+
self._record(rec)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass
|
|
136
|
+
class List:
|
|
137
|
+
"""A list value and the sweeps it spans (spec 7.6 rule 2), as the Rust SDK's `List`: wired into
|
|
138
|
+
an input that takes one item, the node runs once per item.
|
|
139
|
+
|
|
140
|
+
`axes` is `[(name, length), …]`, row-major (the first varies slowest), its lengths
|
|
141
|
+
multiplying to `len(items)`. A node may name its own axes (outputs that share one zip
|
|
142
|
+
downstream); one it leaves empty gets `<node>.<port>` as it leaves the node. `labels`, when
|
|
143
|
+
given, says what made each item (a sheet label)."""
|
|
144
|
+
|
|
145
|
+
items: list[Any]
|
|
146
|
+
axes: list[tuple[str, int]] = field(default_factory=list)
|
|
147
|
+
labels: list[str] = field(default_factory=list)
|
|
148
|
+
|
|
149
|
+
def axes_or_plain(self) -> list[tuple[str, int]]:
|
|
150
|
+
"""Its axes, or one anonymous axis when it has none."""
|
|
151
|
+
return list(self.axes) if self.axes else [("", len(self.items))]
|
|
152
|
+
|
|
153
|
+
def label(self, i: int) -> str:
|
|
154
|
+
"""The label of item `i`, or `""`."""
|
|
155
|
+
return self.labels[i] if 0 <= i < len(self.labels) else ""
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass
|
|
159
|
+
class Blob:
|
|
160
|
+
"""Encoded bytes named by content (spec 4.4), as the Rust SDK's `Blob` with bytes in memory:
|
|
161
|
+
an image, an SVG, a PDF, any file. `handle` is `md5:raw:` of `data`; `meta` is a BlobMeta dict
|
|
162
|
+
(`media`, `size`, `form`)."""
|
|
163
|
+
|
|
164
|
+
handle: str
|
|
165
|
+
meta: dict
|
|
166
|
+
data: bytes
|
|
167
|
+
# A picture of it, (h, w, 4) uint8: the node's thumbnail, and its rendition when watched.
|
|
168
|
+
thumb: Any = None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# The wire type of a port that takes or makes any value (Zip, Unzip, Load and Write Files). An
|
|
172
|
+
# engine that declares it gets conversions both ways between it and every other type it declares
|
|
173
|
+
# (see `Engine.schema`).
|
|
174
|
+
ANY = "ANY"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class Node:
|
|
178
|
+
"""One node type. Subclasses set `definition` (a NodeDef dict) and implement `run`."""
|
|
179
|
+
|
|
180
|
+
definition: dict = {}
|
|
181
|
+
|
|
182
|
+
def run(self, inputs: dict[str, Any], cx: Cx) -> dict[str, Any]:
|
|
183
|
+
raise NotImplementedError
|
|
184
|
+
|
|
185
|
+
def volatile(self, inputs: dict[str, Any]) -> Any:
|
|
186
|
+
"""What the node's memo key must also cover that its inputs don't: the files a directory
|
|
187
|
+
holds, a file's size and time. Called before the key is made (so it should be cheap);
|
|
188
|
+
None adds nothing."""
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class _Simple(Node):
|
|
193
|
+
def __init__(self, definition: dict, f: Callable[[dict[str, Any]], dict[str, Any]]):
|
|
194
|
+
self.definition = definition
|
|
195
|
+
self._f = f
|
|
196
|
+
|
|
197
|
+
def run(self, inputs: dict[str, Any], cx: Cx) -> dict[str, Any]:
|
|
198
|
+
return self._f(inputs)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def simple(definition: dict, f: Callable[[dict[str, Any]], dict[str, Any]]) -> Node:
|
|
202
|
+
"""A node from a definition and a function of its inputs."""
|
|
203
|
+
return _Simple(definition, f)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def status(node_id: str, state: str) -> dict:
|
|
207
|
+
"""A StatusRecord body (spec 9.3); the executor stamps `seq`, `publisher` and `ts`."""
|
|
208
|
+
return {"node_id": node_id, "state": state}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _hash_tree(h: Any, root: str, files: list[str]) -> None:
|
|
212
|
+
for f in sorted(files):
|
|
213
|
+
h.update(os.path.relpath(f, root).encode("utf-8") + b"\0")
|
|
214
|
+
try:
|
|
215
|
+
with open(f, "rb") as fh:
|
|
216
|
+
h.update(fh.read())
|
|
217
|
+
except OSError:
|
|
218
|
+
pass
|
|
219
|
+
h.update(b"\0")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _py_files(path: str) -> list[str]:
|
|
223
|
+
if os.path.isfile(path):
|
|
224
|
+
return [path]
|
|
225
|
+
out = []
|
|
226
|
+
for d, dirs, names in os.walk(path):
|
|
227
|
+
dirs[:] = [x for x in dirs if x != "__pycache__" and not x.startswith(".")]
|
|
228
|
+
out += [os.path.join(d, n) for n in names if n.endswith(".py")]
|
|
229
|
+
return out
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _versions() -> bytes:
|
|
233
|
+
from importlib import metadata
|
|
234
|
+
|
|
235
|
+
def version(dist: str) -> str:
|
|
236
|
+
try:
|
|
237
|
+
return metadata.version(dist)
|
|
238
|
+
except metadata.PackageNotFoundError:
|
|
239
|
+
return "-"
|
|
240
|
+
|
|
241
|
+
return f"python {sys.version_info[0]}.{sys.version_info[1]}; numpy {version('numpy')}; pyuncommonkit {version('pyuncommonkit')}".encode()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@functools.cache
|
|
245
|
+
def sdk_digest() -> str:
|
|
246
|
+
"""The MD5 of this SDK's own `.py` files and the versions results depend on (Python's minor
|
|
247
|
+
version, numpy, pyuncommonkit), as the Rust SDK's `SDK_DIGEST`."""
|
|
248
|
+
h = hashlib.md5()
|
|
249
|
+
root = os.path.dirname(os.path.abspath(__file__))
|
|
250
|
+
_hash_tree(h, root, _py_files(root))
|
|
251
|
+
h.update(_versions())
|
|
252
|
+
return h.hexdigest()
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def code_digest(cls: type) -> str:
|
|
256
|
+
"""The MD5 of the code that defines `cls`: its package's `.py` files when its module is in a
|
|
257
|
+
package, else its module's file alone."""
|
|
258
|
+
mod = sys.modules.get(cls.__module__)
|
|
259
|
+
path = getattr(mod, "__file__", None)
|
|
260
|
+
if not path:
|
|
261
|
+
return "-"
|
|
262
|
+
path = os.path.abspath(path)
|
|
263
|
+
pkg = getattr(mod, "__package__", None)
|
|
264
|
+
root = os.path.dirname(path) if pkg else path
|
|
265
|
+
h = hashlib.md5()
|
|
266
|
+
_hash_tree(h, os.path.dirname(path), _py_files(root))
|
|
267
|
+
return h.hexdigest()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# How slow a node must be (ms of its own work) before its result is persisted (spec 14.5), when
|
|
271
|
+
# `PLUMCRAFT_MEMO_PERSIST_MS` (a number of ms, or `off`) doesn't say. As the Rust SDK's.
|
|
272
|
+
PERSIST_MS = 1000.0
|
|
273
|
+
PERSIST_ENV = "PLUMCRAFT_MEMO_PERSIST_MS"
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def persist_ms_from_env() -> float | None:
|
|
277
|
+
v = os.environ.get(PERSIST_ENV)
|
|
278
|
+
if v is None:
|
|
279
|
+
return PERSIST_MS
|
|
280
|
+
if v.strip().lower() == "off":
|
|
281
|
+
return None
|
|
282
|
+
try:
|
|
283
|
+
ms = float(v)
|
|
284
|
+
except ValueError:
|
|
285
|
+
return PERSIST_MS
|
|
286
|
+
return ms if ms >= 0.0 and ms != float("inf") else PERSIST_MS
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def dependency_digest(dists: tuple[str, ...]) -> str:
|
|
290
|
+
"""The MD5 of these distributions' installed versions (`-` for one that isn't installed)."""
|
|
291
|
+
from importlib import metadata
|
|
292
|
+
|
|
293
|
+
def version(dist: str) -> str:
|
|
294
|
+
try:
|
|
295
|
+
return metadata.version(dist)
|
|
296
|
+
except metadata.PackageNotFoundError:
|
|
297
|
+
return "-"
|
|
298
|
+
|
|
299
|
+
return hashlib.md5("; ".join(f"{d} {version(d)}" for d in sorted(dists)).encode()).hexdigest()
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def salt(prefix: str, code: str, dependencies: tuple[str, ...] = ()) -> str:
|
|
303
|
+
"""A memo salt (spec 14.2) that changes whenever code that can change a result does, as the
|
|
304
|
+
Rust SDK's `salt`: persisted records outlive the process. `dependencies` names the packages
|
|
305
|
+
an engine's results depend on, so upgrading one never serves a result the old version made."""
|
|
306
|
+
deps = f"+deps:{dependency_digest(dependencies)}" if dependencies else ""
|
|
307
|
+
return f"{prefix}+code:{code}+sdk:{sdk_digest()}{deps}"
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class Engine:
|
|
311
|
+
"""An engine: its identity, wire types and nodes. Not tied to a binding.
|
|
312
|
+
|
|
313
|
+
Declaratively, subclass it with class attributes and `@node` methods:
|
|
314
|
+
|
|
315
|
+
class TextEngine(Engine):
|
|
316
|
+
id = "text" # node ids default to "<id>.<method>"
|
|
317
|
+
name = "Text"
|
|
318
|
+
version = "0.1.0"
|
|
319
|
+
salt = "py:text+0.1.0" # default "py:<id>+<version>"; the code digest is added
|
|
320
|
+
category = "Text" # default palette category for its nodes
|
|
321
|
+
family = "text" # default family for its nodes
|
|
322
|
+
wires = (Text, String) # optional: in catalogue order; default as first used
|
|
323
|
+
|
|
324
|
+
and call the class with no arguments to build one. At the low level, pass `info` and add
|
|
325
|
+
nodes with `with_node`.
|
|
326
|
+
"""
|
|
327
|
+
|
|
328
|
+
id: str = ""
|
|
329
|
+
name: str = ""
|
|
330
|
+
version: str = "0.0.0"
|
|
331
|
+
salt: str | None = None
|
|
332
|
+
category: str | tuple[str, ...] | None = None
|
|
333
|
+
family: str | None = None
|
|
334
|
+
icon: str | None = None
|
|
335
|
+
wires: ClassVar[tuple[Any, ...] | None] = None
|
|
336
|
+
# The distributions results depend on (`("opencv-python-headless",)`): their versions join the
|
|
337
|
+
# memo salt, so an upgrade never serves a result the old version made.
|
|
338
|
+
dependencies: ClassVar[tuple[str, ...]] = ()
|
|
339
|
+
# Offer the Vary nodes (`with_vary`) too.
|
|
340
|
+
vary: ClassVar[bool] = False
|
|
341
|
+
# Offer every building block (`with_building_blocks`: Vary, sequences, files) too.
|
|
342
|
+
building_blocks: ClassVar[bool] = False
|
|
343
|
+
|
|
344
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
345
|
+
super().__init_subclass__(**kwargs)
|
|
346
|
+
from .declare import compile_engine
|
|
347
|
+
|
|
348
|
+
compile_engine(cls, _RESERVED)
|
|
349
|
+
|
|
350
|
+
@classmethod
|
|
351
|
+
def serve(cls, argv: list[str] | None = None) -> int:
|
|
352
|
+
"""Run this engine as a process: mode 2 (connect back to the UI's socket, from
|
|
353
|
+
`--connect unix:<path>` or the environment) or mode 3 (`--listen host:port`)."""
|
|
354
|
+
from .serve import main
|
|
355
|
+
|
|
356
|
+
return main(cls, argv)
|
|
357
|
+
|
|
358
|
+
def __init__(self, info: dict | None = None):
|
|
359
|
+
if info is None:
|
|
360
|
+
cls = type(self)
|
|
361
|
+
prefix = cls.salt or f"py:{cls.id}+{cls.version}"
|
|
362
|
+
info = {"id": cls.id, "name": cls.name or cls.id, "version": cls.version, "salt": salt(prefix, code_digest(cls), cls.dependencies)}
|
|
363
|
+
self.info = info
|
|
364
|
+
self.wire_types: list[dict] = []
|
|
365
|
+
self.nodes: dict[str, Node] = {}
|
|
366
|
+
self.caps = ["codec.json", "display.stream", "events.poll", "events.push", "events.resume", "run.stats", "status.progress"]
|
|
367
|
+
self.limits = {"max_inline": 64 << 10}
|
|
368
|
+
# Blobs go to the shared commonkit store unless `with_store` says otherwise.
|
|
369
|
+
import os
|
|
370
|
+
|
|
371
|
+
self.store_root: str | None = os.environ.get("COMMONKIT_STORE_ROOT") or _default_root()
|
|
372
|
+
self.persist_ms: float | None = persist_ms_from_env()
|
|
373
|
+
from .declare import bind
|
|
374
|
+
|
|
375
|
+
bind(self)
|
|
376
|
+
if type(self).building_blocks:
|
|
377
|
+
self.with_building_blocks()
|
|
378
|
+
elif type(self).vary:
|
|
379
|
+
self.with_vary()
|
|
380
|
+
|
|
381
|
+
def with_vary(self) -> Engine:
|
|
382
|
+
"""Offer the Vary nodes (`plumcraft_engine.vary`: Vary Float, Int, String, Bool, Choices,
|
|
383
|
+
Range, Range 2D and 3D) and the scalar wire types they make, where the engine lacks them."""
|
|
384
|
+
from . import vary
|
|
385
|
+
|
|
386
|
+
for w in vary.wire_types():
|
|
387
|
+
if self.wire_type(w["id"]) is None:
|
|
388
|
+
self.wire_types.append(w)
|
|
389
|
+
for n in vary.nodes():
|
|
390
|
+
self.with_node(n)
|
|
391
|
+
return self
|
|
392
|
+
|
|
393
|
+
def with_building_blocks(self) -> Engine:
|
|
394
|
+
"""Offer every building block: the Vary nodes (`with_vary`), the sequence nodes
|
|
395
|
+
(`plumcraft_engine.seq`: Zip, Outer Join, Unzip) and the file nodes
|
|
396
|
+
(`plumcraft_engine.files`: Select, Find, Load and Write Files), with the `ANY` wire type
|
|
397
|
+
they use."""
|
|
398
|
+
from . import files, seq
|
|
399
|
+
|
|
400
|
+
if self.wire_type(ANY) is None:
|
|
401
|
+
self.wire_types.append({"id": ANY, "color": "#9ca3af", "media": "bytes"})
|
|
402
|
+
self.with_vary()
|
|
403
|
+
for n in [*seq.nodes(), *files.nodes()]:
|
|
404
|
+
self.with_node(n)
|
|
405
|
+
return self
|
|
406
|
+
|
|
407
|
+
def with_store(self, root: str | None) -> Engine:
|
|
408
|
+
"""Publish blobs under `root`; None publishes nothing (every value must then fit inline)."""
|
|
409
|
+
self.store_root = root
|
|
410
|
+
return self
|
|
411
|
+
|
|
412
|
+
def with_persist_ms(self, ms: float | None) -> Engine:
|
|
413
|
+
"""Persist results whose own work took at least `ms` (spec 14.5); None persists nothing
|
|
414
|
+
unless a node is marked `cache: always`. Persisting needs a store."""
|
|
415
|
+
self.persist_ms = ms
|
|
416
|
+
return self
|
|
417
|
+
|
|
418
|
+
def with_wire_type(self, w: dict) -> Engine:
|
|
419
|
+
self.wire_types.append(w)
|
|
420
|
+
return self
|
|
421
|
+
|
|
422
|
+
def picture_thumb(self, blob: Blob) -> Any:
|
|
423
|
+
"""A thumbnail, (h, w, 4) uint8, for a picture file's value that has none (Load Files'): this
|
|
424
|
+
SDK can't decode pictures, so an engine that can overrides this. None shows no picture."""
|
|
425
|
+
return None
|
|
426
|
+
|
|
427
|
+
def with_node(self, n: Node) -> Engine:
|
|
428
|
+
self.nodes[n.definition["id"]] = n
|
|
429
|
+
return self
|
|
430
|
+
|
|
431
|
+
def wire_type(self, type_id: str) -> dict | None:
|
|
432
|
+
return next((w for w in self.wire_types if w["id"] == type_id), None)
|
|
433
|
+
|
|
434
|
+
def schema(self) -> dict:
|
|
435
|
+
out = {
|
|
436
|
+
"v": PROTOCOL,
|
|
437
|
+
"engine": self.info,
|
|
438
|
+
"wire_types": self.wire_types,
|
|
439
|
+
"nodes": [self.nodes[k].definition for k in sorted(self.nodes)],
|
|
440
|
+
}
|
|
441
|
+
# With ANY declared: the base conversions, plus every type to and from ANY.
|
|
442
|
+
if self.wire_type(ANY) is not None:
|
|
443
|
+
compat = [{"from": f, "to": t} for f, t in (("INT", "FLOAT"), ("STRING", "ENUM"), ("ENUM", "STRING"))]
|
|
444
|
+
for w in self.wire_types:
|
|
445
|
+
if w["id"] != ANY:
|
|
446
|
+
compat += [{"from": w["id"], "to": ANY}, {"from": ANY, "to": w["id"]}]
|
|
447
|
+
out["compat"] = compat
|
|
448
|
+
return out
|
|
449
|
+
|
|
450
|
+
def hello(self, req: dict, session: str) -> dict:
|
|
451
|
+
"""Answer `hello`: choose the protocol and the session's capabilities."""
|
|
452
|
+
if req.get("op") != "hello":
|
|
453
|
+
raise ProtoError(INVALID_REQUEST, "expected op hello")
|
|
454
|
+
rng = req.get("protocol") if isinstance(req.get("protocol"), dict) else {}
|
|
455
|
+
lo = max(_int(rng.get("min"), PROTOCOL), PROTOCOL)
|
|
456
|
+
hi = min(_int(rng.get("max"), PROTOCOL), PROTOCOL)
|
|
457
|
+
if hi < lo:
|
|
458
|
+
raise ProtoError(VERSION, f"engine speaks protocol {PROTOCOL}–{PROTOCOL}, this app {rng.get('min')}–{rng.get('max')}")
|
|
459
|
+
client = req.get("caps") if isinstance(req.get("caps"), list) else []
|
|
460
|
+
caps = sorted(c for c in self.caps if c in client or c in _ENGINE_ONLY or _is_pack_op(c))
|
|
461
|
+
reply = {"v": PROTOCOL, "protocol": hi, "session": session, "engine": self.info, "caps": caps}
|
|
462
|
+
if self.store_root:
|
|
463
|
+
reply["data"] = {"stores": [self.store_root]}
|
|
464
|
+
return reply
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
# Names a node method can't take: they are the Engine's own.
|
|
468
|
+
_RESERVED = {k for k in vars(Engine) if not k.startswith("__")} | {"info", "wire_types", "wires", "nodes", "caps", "limits", "store_root", "persist_ms"}
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _default_root() -> str:
|
|
472
|
+
from commonkit.shared import setting
|
|
473
|
+
|
|
474
|
+
return setting("ROOT")
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _int(v: Any, default: int) -> int:
|
|
478
|
+
return v if isinstance(v, int) and not isinstance(v, bool) else default
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _is_pack_op(cap: str) -> bool:
|
|
482
|
+
parts = cap.split(".")
|
|
483
|
+
return len(parts) == 3 and parts[0] == "x" and all(parts[1:])
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
from .arrays import ArrayValue # noqa: E402
|
|
487
|
+
from .audio import AudioValue # noqa: E402
|
|
488
|
+
from .declare import ( # noqa: E402 (the declarative layer builds on the names above)
|
|
489
|
+
Array,
|
|
490
|
+
Audio,
|
|
491
|
+
Bool,
|
|
492
|
+
ConfigError,
|
|
493
|
+
DeclarationError,
|
|
494
|
+
File,
|
|
495
|
+
Float,
|
|
496
|
+
Image,
|
|
497
|
+
Int,
|
|
498
|
+
Json,
|
|
499
|
+
OpError,
|
|
500
|
+
Port,
|
|
501
|
+
Range,
|
|
502
|
+
String,
|
|
503
|
+
Svg,
|
|
504
|
+
Table,
|
|
505
|
+
Text,
|
|
506
|
+
WireType,
|
|
507
|
+
node,
|
|
508
|
+
)
|
|
509
|
+
from .image import image # noqa: E402
|
|
510
|
+
from .svg import svg # noqa: E402
|
|
511
|
+
|
|
512
|
+
__all__ = [
|
|
513
|
+
"ANY",
|
|
514
|
+
"Array",
|
|
515
|
+
"ArrayValue",
|
|
516
|
+
"Audio",
|
|
517
|
+
"AudioValue",
|
|
518
|
+
"Blob",
|
|
519
|
+
"Bool",
|
|
520
|
+
"CancelToken",
|
|
521
|
+
"ConfigError",
|
|
522
|
+
"Cx",
|
|
523
|
+
"DeclarationError",
|
|
524
|
+
"Engine",
|
|
525
|
+
"File",
|
|
526
|
+
"Float",
|
|
527
|
+
"Image",
|
|
528
|
+
"Int",
|
|
529
|
+
"Json",
|
|
530
|
+
"List",
|
|
531
|
+
"Node",
|
|
532
|
+
"NodeError",
|
|
533
|
+
"OpError",
|
|
534
|
+
"Port",
|
|
535
|
+
"ProtoError",
|
|
536
|
+
"Range",
|
|
537
|
+
"String",
|
|
538
|
+
"Svg",
|
|
539
|
+
"Table",
|
|
540
|
+
"Text",
|
|
541
|
+
"WireType",
|
|
542
|
+
"image",
|
|
543
|
+
"node",
|
|
544
|
+
"simple",
|
|
545
|
+
"svg",
|
|
546
|
+
]
|