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.
Files changed (96) hide show
  1. plumcraft_engine/__init__.py +546 -0
  2. plumcraft_engine/arrays.py +249 -0
  3. plumcraft_engine/audio.py +72 -0
  4. plumcraft_engine/bind.py +147 -0
  5. plumcraft_engine/cli.py +399 -0
  6. plumcraft_engine/color.py +505 -0
  7. plumcraft_engine/conformance.py +357 -0
  8. plumcraft_engine/data.py +498 -0
  9. plumcraft_engine/declare.py +704 -0
  10. plumcraft_engine/exec.py +1011 -0
  11. plumcraft_engine/files.py +430 -0
  12. plumcraft_engine/host.py +402 -0
  13. plumcraft_engine/http.py +215 -0
  14. plumcraft_engine/image.py +62 -0
  15. plumcraft_engine/media.py +73 -0
  16. plumcraft_engine/memo.py +99 -0
  17. plumcraft_engine/new.py +460 -0
  18. plumcraft_engine/persist.py +151 -0
  19. plumcraft_engine/scratch.py +56 -0
  20. plumcraft_engine/seq.py +308 -0
  21. plumcraft_engine/serve.py +190 -0
  22. plumcraft_engine/slices.py +64 -0
  23. plumcraft_engine/svg.py +64 -0
  24. plumcraft_engine/templates/engine/README.md.tmpl +90 -0
  25. plumcraft_engine/templates/engine/conftest.py.tmpl +20 -0
  26. plumcraft_engine/templates/engine/engine.py.tmpl +16 -0
  27. plumcraft_engine/templates/engine/fragments/array.py.tmpl +14 -0
  28. plumcraft_engine/templates/engine/fragments/audio.py.tmpl +18 -0
  29. plumcraft_engine/templates/engine/fragments/audio_array.py.tmpl +13 -0
  30. plumcraft_engine/templates/engine/fragments/helpers_image.py.tmpl +15 -0
  31. plumcraft_engine/templates/engine/fragments/helpers_list.py.tmpl +41 -0
  32. plumcraft_engine/templates/engine/fragments/helpers_svg.py.tmpl +2 -0
  33. plumcraft_engine/templates/engine/fragments/image.py.tmpl +14 -0
  34. plumcraft_engine/templates/engine/fragments/image_array.py.tmpl +14 -0
  35. plumcraft_engine/templates/engine/fragments/json.py.tmpl +14 -0
  36. plumcraft_engine/templates/engine/fragments/json_text.py.tmpl +13 -0
  37. plumcraft_engine/templates/engine/fragments/scalars.py.tmpl +20 -0
  38. plumcraft_engine/templates/engine/fragments/slow_scalars.py.tmpl +13 -0
  39. plumcraft_engine/templates/engine/fragments/slow_text.py.tmpl +13 -0
  40. plumcraft_engine/templates/engine/fragments/svg.py.tmpl +17 -0
  41. plumcraft_engine/templates/engine/fragments/svg_text.py.tmpl +10 -0
  42. plumcraft_engine/templates/engine/fragments/table.py.tmpl +23 -0
  43. plumcraft_engine/templates/engine/fragments/table_array.py.tmpl +18 -0
  44. plumcraft_engine/templates/engine/fragments/table_text.py.tmpl +25 -0
  45. plumcraft_engine/templates/engine/fragments/test_nodes_array.py.tmpl +10 -0
  46. plumcraft_engine/templates/engine/fragments/test_nodes_audio.py.tmpl +7 -0
  47. plumcraft_engine/templates/engine/fragments/test_nodes_image.py.tmpl +6 -0
  48. plumcraft_engine/templates/engine/fragments/test_nodes_json.py.tmpl +6 -0
  49. plumcraft_engine/templates/engine/fragments/test_nodes_list.py.tmpl +8 -0
  50. plumcraft_engine/templates/engine/fragments/test_nodes_scalars.py.tmpl +14 -0
  51. plumcraft_engine/templates/engine/fragments/test_nodes_svg.py.tmpl +8 -0
  52. plumcraft_engine/templates/engine/fragments/test_nodes_table.py.tmpl +7 -0
  53. plumcraft_engine/templates/engine/fragments/test_nodes_text.py.tmpl +12 -0
  54. plumcraft_engine/templates/engine/fragments/test_pairs_audio_array.py.tmpl +6 -0
  55. plumcraft_engine/templates/engine/fragments/test_pairs_image_array.py.tmpl +5 -0
  56. plumcraft_engine/templates/engine/fragments/test_pairs_json_text.py.tmpl +5 -0
  57. plumcraft_engine/templates/engine/fragments/test_pairs_list.py.tmpl +5 -0
  58. plumcraft_engine/templates/engine/fragments/test_pairs_scalars.py.tmpl +5 -0
  59. plumcraft_engine/templates/engine/fragments/test_pairs_svg_text.py.tmpl +5 -0
  60. plumcraft_engine/templates/engine/fragments/test_pairs_table_array.py.tmpl +5 -0
  61. plumcraft_engine/templates/engine/fragments/test_pairs_table_text.py.tmpl +5 -0
  62. plumcraft_engine/templates/engine/fragments/test_pairs_text.py.tmpl +5 -0
  63. plumcraft_engine/templates/engine/fragments/text.py.tmpl +32 -0
  64. plumcraft_engine/templates/engine/gitignore.tmpl +13 -0
  65. plumcraft_engine/templates/engine/main.py.tmpl +15 -0
  66. plumcraft_engine/templates/engine/plumcraft-engine.toml.tmpl +13 -0
  67. plumcraft_engine/templates/engine/pycharm/idea.gitignore.tmpl +4 -0
  68. plumcraft_engine/templates/engine/pycharm/module.iml.tmpl +12 -0
  69. plumcraft_engine/templates/engine/pycharm/module.run.xml.tmpl +24 -0
  70. plumcraft_engine/templates/engine/pycharm/modules.xml.tmpl +8 -0
  71. plumcraft_engine/templates/engine/pycharm/pytest.run.xml.tmpl +19 -0
  72. plumcraft_engine/templates/engine/pycharm/shell.run.xml.tmpl +17 -0
  73. plumcraft_engine/templates/engine/pyproject.toml.tmpl +39 -0
  74. plumcraft_engine/templates/engine/scripts/_plumcraft.sh.tmpl +23 -0
  75. plumcraft_engine/templates/engine/scripts/check.tmpl +7 -0
  76. plumcraft_engine/templates/engine/scripts/cli-run.tmpl +9 -0
  77. plumcraft_engine/templates/engine/scripts/conform.tmpl +12 -0
  78. plumcraft_engine/templates/engine/scripts/editor.tmpl +9 -0
  79. plumcraft_engine/templates/engine/scripts/install.tmpl +8 -0
  80. plumcraft_engine/templates/engine/scripts/nodes.tmpl +5 -0
  81. plumcraft_engine/templates/engine/scripts/run.tmpl +13 -0
  82. plumcraft_engine/templates/engine/scripts/serve.tmpl +9 -0
  83. plumcraft_engine/templates/engine/scripts/uninstall.tmpl +10 -0
  84. plumcraft_engine/templates/engine/test_conformance.py.tmpl +9 -0
  85. plumcraft_engine/templates/engine/test_engine.py.tmpl +30 -0
  86. plumcraft_engine/templates/engine/test_nodes.py.tmpl +4 -0
  87. plumcraft_engine/templates/engine/test_roundtrips.py.tmpl +4 -0
  88. plumcraft_engine/vary.py +390 -0
  89. plumcraft_engine-0.1.0.dist-info/METADATA +161 -0
  90. plumcraft_engine-0.1.0.dist-info/RECORD +96 -0
  91. plumcraft_engine-0.1.0.dist-info/WHEEL +4 -0
  92. plumcraft_engine-0.1.0.dist-info/entry_points.txt +3 -0
  93. plumcraft_engine-0.1.0.dist-info/licenses/LICENSE-APACHE +202 -0
  94. plumcraft_engine-0.1.0.dist-info/licenses/LICENSE-MIT +21 -0
  95. plumcraft_text/__init__.py +131 -0
  96. 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
+ ]