pyrtan / sourcev0.19.0 · main

pyrtan / src / pyrtan / tree.py

39,921 bytes · last changed 2026-10-27 in Chat: strangers and bots, Omegle-style, on a hub and across a follow; release 0.10.0

1"""The tree: Pyrtan's unit of automation.
2
3A tree is a TOML file. It names what it answers to (``triggers``), what it
4needs (``inputs``), which hosts it may touch (``hosts``), and a set of
5``nodes`` beginning at ``start``. Node kinds:
6
7``observe``
8 Read something: an HTTP request, or an extraction from state already
9 held. Safe to retry.
10``act``
11 Change something: an HTTP request with side effects, or an event emitted
12 to the hub. Never retried automatically.
13``decide``
14 Look at one value and pick a branch by plain conditions. The heart of the
15 format; every decision a run makes is one of these.
16``set``
17 Compute state from templates.
18``each``
19 For every item of a list (up to ``limit``), run another tree or a local
20 sub-flow, collecting the outputs.
21``delegate``
22 Hand an intent or a named tree to the swarm and keep its output.
23``ask``
24 Pause and request an input from whoever is running the tree.
25``done`` / ``fail``
26 Terminal: what to say and what to output, or why it did not work.
27
28Trees do not loop. A path through a tree is finite by construction, which
29is what makes the trace a complete account of the run; repetition is
30``each`` with a limit.
31
32:func:`check` validates a tree and returns every problem found, at the level
33``error`` (will not run), ``warn`` (will run, may surprise) or ``info``.
34"""
35
36from __future__ import annotations
37
38import re
39import tomllib
40import urllib.parse
41from dataclasses import dataclass, field
42from pathlib import Path
43from typing import Any
44
45from . import adapters, conditions, extract as extractmod
46from .intent import KINDS as SLOT_KINDS, PRODUCES, Language, Trigger, TriggerError
47from .template import PLACEHOLDER, placeholders
48
49NAME = re.compile(r"^[a-z0-9][a-z0-9\-]*$")
50KINDS = {"observe", "act", "decide", "set", "each", "delegate", "ask", "done", "fail"}
51TERMINAL = {"done", "fail"}
52INPUT_TYPES = {"str", "int", "float", "bool", "list", "dict"}
53HTTP_METHODS = {"GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"}
54READ_METHODS = {"GET", "HEAD", "OPTIONS"}
55
56
57class TreeError(ValueError):
58 pass
59
60
61@dataclass
62class Problem:
63 level: str # error | warn | info
64 where: str
65 message: str
66
67 def __str__(self) -> str:
68 return f"{self.level}: {self.where}: {self.message}"
69
70
71@dataclass
72class InputSpec:
73 name: str
74 type: str = "str"
75 default: Any = None
76 required: bool = True
77 description: str = ""
78 min: float | None = None
79 max: float | None = None
80 one_of: list[Any] | None = None
81 pattern: str | None = None
82
83 def coerce(self, value: Any) -> Any:
84 try:
85 if self.type == "int":
86 if isinstance(value, bool):
87 raise ValueError
88 value = int(value) if not isinstance(value, str) else int(value.strip())
89 elif self.type == "float":
90 value = float(value)
91 elif self.type == "bool":
92 if isinstance(value, str):
93 low = value.strip().lower()
94 if low in ("true", "yes", "1", "on"):
95 value = True
96 elif low in ("false", "no", "0", "off"):
97 value = False
98 else:
99 raise ValueError
100 else:
101 value = bool(value)
102 elif self.type == "str":
103 value = str(value)
104 elif self.type == "list" and not isinstance(value, list):
105 raise ValueError
106 elif self.type == "dict" and not isinstance(value, dict):
107 raise ValueError
108 except (TypeError, ValueError):
109 raise TreeError(f"input {self.name!r} must be {self.type}, got {value!r}") from None
110 if self.min is not None and isinstance(value, (int, float)) and value < self.min:
111 raise TreeError(f"input {self.name!r} must be at least {self.min}, got {value}")
112 if self.max is not None and isinstance(value, (int, float)) and value > self.max:
113 raise TreeError(f"input {self.name!r} must be at most {self.max}, got {value}")
114 if self.one_of is not None and value not in self.one_of:
115 raise TreeError(f"input {self.name!r} must be one of {self.one_of}, got {value!r}")
116 if self.pattern is not None and isinstance(value, str) and not re.fullmatch(self.pattern, value):
117 raise TreeError(f"input {self.name!r} must match {self.pattern!r}, got {value!r}")
118 return value
119
120
121@dataclass
122class Node:
123 id: str
124 kind: str
125 spec: dict[str, Any]
126
127 def get(self, key: str, default: Any = None) -> Any:
128 return self.spec.get(key, default)
129
130 @property
131 def next(self) -> str | None:
132 return self.spec.get("next")
133
134 def targets(self) -> list[str]:
135 """Every node id this node can hand off to."""
136 out: list[str] = []
137 if self.kind == "decide":
138 for br in self.spec.get("branches", []):
139 if isinstance(br, dict):
140 if "then" in br:
141 out.append(br["then"])
142 if "else" in br:
143 out.append(br["else"])
144 if self.kind == "each" and "start" in self.spec:
145 out.append(self.spec["start"])
146 if self.next:
147 out.append(self.next)
148 return out
149
150
151@dataclass
152class Tree:
153 name: str
154 description: str
155 version: int
156 hosts: list[str]
157 triggers: list[Trigger]
158 inputs: dict[str, InputSpec]
159 start: str
160 nodes: dict[str, Node]
161 tags: list[str] = field(default_factory=list)
162 source: str | None = None
163 raw: dict[str, Any] = field(default_factory=dict, repr=False)
164 license: str = ""
165 language: Language = field(default_factory=Language)
166 namespace: str = "" # who published it: the catalog id is namespace/name
167 watches: list[dict[str, Any]] = field(default_factory=list) # when this tree runs on its own: a clock, a file, a topic, a serial line
168 paths: list[str] = field(default_factory=list) # what ``file`` nodes may reach
169 commands: list[str] = field(default_factory=list) # what ``shell`` nodes may run
170 devices: list[str] = field(default_factory=list) # what ``serial`` nodes may open
171 topics: list[str] = field(default_factory=list) # what ``queue`` nodes may subscribe to or publish on
172
173 def node(self, node_id: str) -> Node:
174 try:
175 return self.nodes[node_id]
176 except KeyError:
177 raise TreeError(f"{self.name}: no node {node_id!r}") from None
178
179 def host_allowed(self, url: str) -> bool:
180 host = (urllib.parse.urlsplit(url).hostname or "").lower()
181 if not host:
182 return False
183 for allowed in self.hosts:
184 allowed = allowed.lower()
185 if allowed == "*":
186 return True
187 if allowed.startswith("*."):
188 suffix = allowed[1:]
189 if host.endswith(suffix) or host == allowed[2:]:
190 return True
191 elif host == allowed:
192 return True
193 return False
194
195 @property
196 def id(self) -> str:
197 """``namespace/name`` in a catalog; just the name without one."""
198 return f"{self.namespace}/{self.name}" if self.namespace else self.name
199
200 def allowed(self, source: str, spec: dict[str, Any]) -> tuple[str, str] | None:
201 """Why a rendered request to ``source`` is outside this tree's
202 allow-list, as ``(reason, text)``, or None when it is inside."""
203 if source in ("http", "browser"):
204 url = str(spec.get("url", ""))
205 return None if self.host_allowed(url) else ("host-not-allowed", f"{url} is outside this tree's hosts {self.hosts}.")
206 if source == "file":
207 path = adapters.absolute_path(spec.get("path", ""))
208 return None if adapters.path_allowed(self.paths, path) else ("path-not-allowed", f"{path} is outside this tree's paths {self.paths}.")
209 if source == "shell":
210 cmd = str(spec.get("command", ""))
211 return None if adapters.command_allowed(self.commands, cmd) else ("command-not-allowed", f"{cmd!r} is not in this tree's commands {self.commands}.")
212 if source == "serial":
213 dev = str(spec.get("device", ""))
214 return None if adapters.device_allowed(self.devices, dev) else ("device-not-allowed", f"{dev} is outside this tree's devices {self.devices}.")
215 if source == "queue":
216 url = str(spec.get("url", ""))
217 try:
218 _, host, port = adapters.broker(url)
219 except ValueError as e:
220 return ("host-not-allowed", str(e))
221 if not self.host_allowed(f"mqtt://{host}:{port}/"):
222 return ("host-not-allowed", f"{host} is outside this tree's hosts {self.hosts}.")
223 topic = str(spec.get("topic", ""))
224 return None if adapters.topic_allowed(self.topics, topic) else ("topic-not-allowed", f"{topic!r} is outside this tree's topics {self.topics}.")
225 return None
226
227 def watch_kinds(self) -> list[str]:
228 return [next(k for k in WATCH_KINDS if k in w) for w in self.watches]
229
230 def sources(self) -> list[str]:
231 """The forms of I/O this tree's nodes use."""
232 return sorted({s for n in self.nodes.values() if n.kind in ("observe", "act") for s in adapters.SOURCES if n.get(s) is not None})
233
234 def bind_inputs(self, given: dict[str, Any]) -> dict[str, Any]:
235 """Apply defaults, coerce types, and refuse unknown or missing inputs."""
236 unknown = set(given) - set(self.inputs)
237 if unknown:
238 raise TreeError(f"{self.name}: unknown inputs {sorted(unknown)}")
239 bound: dict[str, Any] = {}
240 for name, spec in self.inputs.items():
241 if name in given and given[name] is not None:
242 bound[name] = spec.coerce(given[name])
243 elif spec.default is not None:
244 bound[name] = spec.default
245 elif spec.required:
246 raise TreeError(f"{self.name}: input {name!r} is required")
247 else:
248 bound[name] = None
249 return bound
250
251 def capability(self) -> dict[str, Any]:
252 """What this tree advertises to the swarm."""
253 return {
254 "name": self.name,
255 "version": self.version,
256 **({"namespace": self.namespace} if self.namespace else {}),
257 "description": self.description,
258 "tags": self.tags,
259 "hosts": self.hosts,
260 **{k: getattr(self, k) for k in ("paths", "commands", "devices", "topics") if getattr(self, k)},
261 "sources": self.sources(),
262 **({"watches": [{"kind": k, "what": _watch_what(w)} for k, w in zip(self.watch_kinds(), self.watches)]} if self.watches else {}),
263 "license": self.license,
264 "language": self.language.to_dict(),
265 "triggers": [t.pattern for t in self.triggers],
266 # What a trigger fixes ("top story" means count = 1): a hub routes by these too.
267 **({"trigger_inputs": {t.pattern: t.fixed_inputs for t in self.triggers if t.fixed_inputs}} if any(t.fixed_inputs for t in self.triggers) else {}),
268 "inputs": {
269 n: {k: v for k, v in {"type": s.type, "default": s.default, "required": s.required and s.default is None, "description": s.description}.items() if v not in (None, "", False)}
270 for n, s in self.inputs.items()
271 },
272 }
273
274
275def load_tree(path: str | Path) -> Tree:
276 path = Path(path)
277 try:
278 text = path.read_text(encoding="utf-8")
279 except OSError as e:
280 raise TreeError(f"cannot read {path}: {e}") from e
281 tree = load_tree_text(text, source=str(path))
282 return tree
283
284
285def load_tree_text(text: str, source: str | None = None) -> Tree:
286 try:
287 raw = tomllib.loads(text)
288 except tomllib.TOMLDecodeError as e:
289 raise TreeError(f"{source or '<text>'}: not valid TOML: {e}") from e
290 return build(raw, source=source)
291
292
293def build(raw: dict[str, Any], source: str | None = None) -> Tree:
294 where = source or raw.get("name", "<tree>")
295 name = raw.get("name")
296 if not isinstance(name, str) or not NAME.match(name):
297 raise TreeError(f"{where}: 'name' must be lowercase letters, digits and dashes, got {name!r}")
298 description = str(raw.get("description", "")).strip()
299 version = raw.get("version", 1)
300 if not isinstance(version, int) or version < 1:
301 raise TreeError(f"{name}: 'version' must be a positive integer")
302 hosts = raw.get("hosts", [])
303 if not isinstance(hosts, list) or not all(isinstance(h, str) and h for h in hosts):
304 raise TreeError(f"{name}: 'hosts' must be a list of host names")
305 lists: dict[str, list[str]] = {}
306 for key, noun in (("paths", "paths or globs"), ("commands", "program names"), ("devices", "device paths or globs"), ("topics", "topic filters")):
307 value = raw.get(key, [])
308 if not isinstance(value, list) or not all(isinstance(v, str) and v for v in value):
309 raise TreeError(f"{name}: '{key}' must be a list of {noun}")
310 lists[key] = value
311 tags = raw.get("tags", [])
312 if not isinstance(tags, list) or not all(isinstance(t, str) for t in tags):
313 raise TreeError(f"{name}: 'tags' must be a list of strings")
314 license_ = raw.get("license", "")
315 if not isinstance(license_, str):
316 raise TreeError(f"{name}: 'license' must be a string such as \"MIT\" (an SPDX identifier)")
317 namespace = raw.get("namespace", "")
318 if not isinstance(namespace, str) or (namespace and not NAME.match(namespace)):
319 raise TreeError(f"{name}: 'namespace' must be lowercase letters, digits and dashes (who publishes the tree), got {namespace!r}")
320 watches = raw.get("watches", [])
321 if not isinstance(watches, list) or not all(isinstance(w, dict) for w in watches):
322 raise TreeError(f"{name}: 'watches' is a list of tables ([[watches]])")
323
324 try:
325 language = Language.from_raw(raw.get("language"), "language")
326 except TriggerError as e:
327 raise TreeError(f"{name}: {e}") from e
328
329 triggers: list[Trigger] = []
330 for i, t in enumerate(raw.get("triggers", [])):
331 if isinstance(t, str):
332 t = {"pattern": t}
333 if not isinstance(t, dict) or "pattern" not in t:
334 raise TreeError(f"{name}: triggers[{i}] needs a 'pattern'")
335 try:
336 triggers.append(Trigger(t["pattern"], dict(t.get("inputs", {})), language))
337 except TriggerError as e:
338 raise TreeError(f"{name}: triggers[{i}]: {e}") from e
339
340 inputs: dict[str, InputSpec] = {}
341 raw_inputs = raw.get("inputs", {})
342 if not isinstance(raw_inputs, dict):
343 raise TreeError(f"{name}: 'inputs' must be a table")
344 for iname, spec in raw_inputs.items():
345 if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", iname):
346 raise TreeError(f"{name}: bad input name {iname!r}")
347 if not isinstance(spec, dict):
348 spec = {"default": spec}
349 itype = spec.get("type", _infer_type(spec.get("default")))
350 if itype not in INPUT_TYPES:
351 raise TreeError(f"{name}: input {iname!r} has unknown type {itype!r}")
352 inputs[iname] = InputSpec(
353 name=iname,
354 type=itype,
355 default=spec.get("default"),
356 required=bool(spec.get("required", spec.get("default") is None)),
357 description=str(spec.get("description", "")),
358 min=spec.get("min"),
359 max=spec.get("max"),
360 one_of=spec.get("one_of"),
361 pattern=spec.get("pattern"),
362 )
363
364 raw_nodes = raw.get("nodes")
365 if not isinstance(raw_nodes, dict) or not raw_nodes:
366 raise TreeError(f"{name}: 'nodes' must be a non-empty table")
367 nodes: dict[str, Node] = {}
368 for nid, spec in raw_nodes.items():
369 if not NAME.match(nid):
370 raise TreeError(f"{name}: node id {nid!r} must be lowercase letters, digits and dashes")
371 if not isinstance(spec, dict):
372 raise TreeError(f"{name}: node {nid!r} must be a table")
373 kind = spec.get("kind")
374 if kind not in KINDS:
375 raise TreeError(f"{name}: node {nid!r} has unknown kind {kind!r}; one of {sorted(KINDS)}")
376 nodes[nid] = Node(nid, kind, spec)
377
378 start = raw.get("start")
379 if not isinstance(start, str):
380 hint = " (top-level keys such as 'start' must come before the first [table] in TOML)" if _buried(raw, "start") else ""
381 raise TreeError(f"{name}: 'start' must name a node{hint}")
382
383 tree = Tree(name, description, version, hosts, triggers, inputs, start, nodes, tags, source, raw, license_.strip(), language, namespace, watches, **lists)
384 errors = [p for p in check(tree) if p.level == "error"]
385 if errors:
386 raise TreeError(f"{name}: " + "; ".join(f"{p.where}: {p.message}" for p in errors))
387 return tree
388
389
390def _buried(raw: Any, key: str, depth: int = 0) -> bool:
391 """True when ``key`` appears inside some table rather than at top level."""
392 if not isinstance(raw, dict) or depth > 3:
393 return False
394 return any((key in v) if isinstance(v, dict) else False for v in raw.values()) or any(_buried(v, key, depth + 1) for v in raw.values())
395
396
397def _infer_type(value: Any) -> str:
398 if isinstance(value, bool):
399 return "bool"
400 if isinstance(value, int):
401 return "int"
402 if isinstance(value, float):
403 return "float"
404 if isinstance(value, list):
405 return "list"
406 if isinstance(value, dict):
407 return "dict"
408 return "str"
409
410
411# ---------------------------------------------------------------- checking
412
413
414def _slot_fits(kind: str | None, input_type: str) -> bool:
415 """Whether what a slot kind reads can be bound to an input of this type."""
416 produced = PRODUCES[kind]
417 if produced == "int":
418 return input_type in ("int", "float", "str")
419 if produced == "float":
420 return input_type in ("float", "str")
421 if produced == "number":
422 return input_type in ("int", "float", "str")
423 return input_type == "str"
424
425
426def check(tree: Tree) -> list[Problem]:
427 """Validate structure, references, templates, conditions, selectors,
428 hosts and reachability. Never raises; returns what it found."""
429 problems: list[Problem] = []
430 err = lambda where, msg: problems.append(Problem("error", where, msg))
431 warn = lambda where, msg: problems.append(Problem("warn", where, msg))
432 info = lambda where, msg: problems.append(Problem("info", where, msg))
433
434 if not tree.description:
435 warn("description", "a tree should say what it does; the swarm shows this to people")
436 if tree.start not in tree.nodes:
437 err("start", f"start node {tree.start!r} does not exist")
438
439 for i, t in enumerate(tree.triggers):
440 for slot, kind in t.slots.items():
441 if slot not in tree.inputs:
442 err(f"triggers[{i}]", f"slot {{{slot}}} is not a declared input")
443 continue
444 want = tree.inputs[slot].type
445 if not _slot_fits(kind, want):
446 err(f"triggers[{i}]", f"slot {{{slot}:{kind}}} produces a {PRODUCES[kind]}, but input {slot!r} is a {want}")
447 for fixed in t.fixed_inputs:
448 if fixed not in tree.inputs:
449 err(f"triggers[{i}]", f"fixed input {fixed!r} is not a declared input")
450
451 produced: set[str] = {"last"}
452 for node in tree.nodes.values():
453 for key in ("into",):
454 v = node.get(key)
455 if isinstance(v, str):
456 produced.add(v.split(".")[0].split("[")[0])
457 for ex in node.get("extract", []) or []:
458 if isinstance(ex, dict) and isinstance(ex.get("into"), str):
459 produced.add(ex["into"].split(".")[0])
460 if node.kind == "set" and isinstance(node.get("set"), dict):
461 produced.update(k.split(".")[0] for k in node.get("set"))
462
463 used: set[str] = set()
464 for node in tree.nodes.values():
465 where = f"nodes.{node.id}"
466 for target in node.targets():
467 if target not in tree.nodes:
468 err(where, f"refers to node {target!r}, which does not exist")
469 if node.kind in TERMINAL and node.next:
470 err(where, f"a {node.kind} node has no 'next'")
471 if node.kind not in TERMINAL and node.kind != "decide" and not node.next:
472 err(where, f"a {node.kind} node needs 'next'")
473
474 if node.kind in ("observe", "act"):
475 present = [s for s in adapters.SOURCES if node.get(s) is not None]
476 frm = node.get("from")
477 emit = node.get("emit")
478 ways = len(present) + sum(1 for x in (frm, emit) if x is not None)
479 if ways != 1:
480 err(where, f"an {node.kind} node needs exactly one of {', '.join(repr(s) for s in adapters.SOURCES)}, 'from' or 'emit'")
481 for s in present:
482 used.add(s)
483 _SOURCE_CHECKS[s](tree, node, node.get(s), err, warn)
484 if emit is not None:
485 if node.kind != "act":
486 err(where, "'emit' is an act, not an observation")
487 if not isinstance(emit, dict) or not isinstance(emit.get("event"), str):
488 err(where, "'emit' needs a string 'event'")
489 if frm is not None and not isinstance(frm, str):
490 err(where, "'from' must be a state path")
491 if node.get("retries") is not None and node.kind == "act":
492 err(where, "an act is never retried; drop 'retries' or make it an observe")
493 for j, ex in enumerate(node.get("extract", []) or []):
494 try:
495 extractmod.validate(ex)
496 except extractmod.ExtractError as e:
497 err(f"{where}.extract[{j}]", str(e))
498
499 elif node.kind == "decide":
500 on = node.get("on")
501 if not isinstance(on, str) or not on.strip():
502 err(where, "a decide node needs 'on': the state path it looks at")
503 branches = node.get("branches")
504 if not isinstance(branches, list) or not branches:
505 err(where, "a decide node needs a non-empty 'branches' list")
506 else:
507 has_else = False
508 for j, br in enumerate(branches):
509 bw = f"{where}.branches[{j}]"
510 if not isinstance(br, dict):
511 err(bw, "a branch is a table")
512 continue
513 if "else" in br:
514 if j != len(branches) - 1:
515 err(bw, "'else' must be the last branch")
516 if set(br) - {"else", "note"}:
517 err(bw, "an else branch has only 'else'")
518 has_else = True
519 else:
520 if "when" not in br or "then" not in br:
521 err(bw, "a branch needs 'when' and 'then'")
522 continue
523 try:
524 conditions.validate(br["when"])
525 except conditions.ConditionError as e:
526 err(bw, str(e))
527 if not has_else:
528 info(where, "no 'else': an unforeseen value is recorded as a gap and fails the run")
529 if node.next:
530 err(where, "a decide node routes through its branches, not 'next'")
531
532 elif node.kind == "set":
533 if not isinstance(node.get("set"), dict) or not node.get("set"):
534 err(where, "a set node needs a non-empty 'set' table")
535
536 elif node.kind == "each":
537 if not isinstance(node.get("over"), str):
538 err(where, "an each node needs 'over': the list to walk")
539 if ("tree" in node.spec) == ("start" in node.spec):
540 err(where, "an each node needs exactly one of 'tree' (delegate per item) or 'start' (local sub-flow)")
541 if "start" in node.spec and node.get("start") == node.id:
542 err(where, "a sub-flow cannot start at the each node itself")
543 if node.get("limit") is None:
544 warn(where, "no 'limit': the whole list is walked, however long")
545 if not isinstance(node.get("into"), str):
546 err(where, "an each node needs 'into' to collect results")
547
548 elif node.kind == "delegate":
549 if ("intent" in node.spec) == ("tree" in node.spec):
550 err(where, "a delegate node needs exactly one of 'intent' or 'tree'")
551 if not isinstance(node.get("into"), str):
552 err(where, "a delegate node needs 'into'")
553
554 elif node.kind == "ask":
555 if not isinstance(node.get("prompt"), str):
556 err(where, "an ask node needs a 'prompt'")
557 into = node.get("into")
558 if not isinstance(into, str):
559 err(where, "an ask node needs 'into'")
560 elif into.startswith("inputs.") and into[len("inputs."):] not in tree.inputs:
561 err(where, f"asks for {into}, which is not a declared input")
562
563 elif node.kind == "done":
564 if node.get("say") is None and node.get("output") is None:
565 warn(where, "a done node with neither 'say' nor 'output' tells nobody anything")
566 elif node.kind == "fail":
567 if not isinstance(node.get("reason"), str):
568 err(where, "a fail node needs a 'reason' code")
569
570 for path in placeholders(node.spec):
571 head, _, rest = path.partition(".")
572 if head == "inputs":
573 key = rest.split(".")[0].split("[")[0]
574 if key not in tree.inputs:
575 err(where, f"template refers to inputs.{key}, which is not declared")
576 elif head == "state":
577 key = rest.split(".")[0].split("[")[0]
578 if key and key not in produced:
579 warn(where, f"template refers to state.{key}, which no node produces")
580 elif head not in ("item", "index", "tree", "now", "env"):
581 err(where, f"template root {head!r} is not one of inputs, state, item, index, tree, now, env")
582
583 for i, w in enumerate(tree.watches):
584 used.update(_check_watch(tree, i, w, err, warn))
585
586 for list_name, sources in _NEEDS.items():
587 have = getattr(tree, list_name)
588 using = sorted(used & sources)
589 if using and not have:
590 if using == ["http"]:
591 err("hosts", "a tree that speaks HTTP must list the hosts it may reach")
592 else:
593 err(list_name, f"a tree with {', '.join(using)} nodes must list the {list_name} it may reach")
594 if have and not using:
595 info(list_name, f"{list_name} are listed but no node uses them")
596
597 if tree.start in tree.nodes:
598 problems.extend(_check_graph(tree))
599 return problems
600
601
602_NEEDS = {"hosts": {"http", "browser", "queue"}, "paths": {"file"}, "commands": {"shell"}, "devices": {"serial"}, "topics": {"queue"}}
603WATCH_KINDS = ("every", "file", "queue", "serial")
604WATCH_ROOTS = ("event", "env", "now")
605
606
607def _watch_what(w: dict[str, Any]) -> str:
608 if "every" in w:
609 return str(w["every"])
610 if "file" in w:
611 return str(w["file"])
612 if "queue" in w and isinstance(w["queue"], dict):
613 return f"{w['queue'].get('topic')} at {w['queue'].get('url')}"
614 if "serial" in w and isinstance(w["serial"], dict):
615 return str(w["serial"].get("device"))
616 return "?"
617
618
619def _check_watch(tree: Tree, i: int, w: dict[str, Any], err, warn) -> set[str]:
620 """A watch is static: no template reaches a path, a topic or a device,
621 so every one is checked here against the tree's lists. Returns the
622 sources it uses, for the allow-list bookkeeping."""
623 from .nlp import ParseError, parse_duration
624
625 where = f"watches[{i}]"
626 kinds = [k for k in WATCH_KINDS if k in w]
627 if len(kinds) != 1:
628 err(where, f"a watch is exactly one of {', '.join(WATCH_KINDS)}, with optional 'inputs' and 'name'")
629 return set()
630 kind = kinds[0]
631 used: set[str] = set()
632 if kind == "every":
633 value = w["every"]
634 try:
635 seconds = int(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else parse_duration(str(value))
636 except (ParseError, ValueError):
637 seconds = 0
638 if seconds < 1:
639 err(where, f"'every' is a duration such as \"5m\" or \"1 hour\", got {value!r}")
640 elif kind == "file":
641 path = w["file"]
642 if not isinstance(path, str) or not path or PLACEHOLDER.search(path):
643 err(where, "'file' is a literal path; a watch cannot be decided at run time")
644 elif tree.paths and not adapters.path_allowed(tree.paths, path):
645 err(where, f"{path!r} is not under this tree's paths")
646 used.add("file")
647 elif kind == "queue":
648 spec = w["queue"]
649 if not isinstance(spec, dict) or not isinstance(spec.get("url"), str) or not isinstance(spec.get("topic"), str) or PLACEHOLDER.search(spec["url"] + spec["topic"]):
650 err(where, "'queue' is a table with literal 'url' and 'topic'")
651 else:
652 try:
653 _, host, port = adapters.broker(spec["url"])
654 except ValueError as e:
655 err(where, str(e))
656 else:
657 if tree.hosts and not tree.host_allowed(f"mqtt://{host}:{port}/"):
658 err(where, f"host {host!r} is not in this tree's hosts")
659 if tree.topics and not adapters.topic_allowed(tree.topics, spec["topic"]):
660 err(where, f"{spec['topic']!r} is not under this tree's topics")
661 used.add("queue")
662 elif kind == "serial":
663 spec = w["serial"]
664 if not isinstance(spec, dict) or not isinstance(spec.get("device"), str) or PLACEHOLDER.search(spec["device"]):
665 err(where, "'serial' is a table with a literal 'device' and optionally 'baud', 'read_until'")
666 elif tree.devices and not adapters.device_allowed(tree.devices, spec["device"]):
667 err(where, f"{spec['device']!r} is not in this tree's devices")
668 used.add("serial")
669 inputs = w.get("inputs", {})
670 if not isinstance(inputs, dict):
671 err(where, "'inputs' is a table of input = value (templates may read event, env and now)")
672 inputs = {}
673 for k, v in inputs.items():
674 if k not in tree.inputs:
675 err(where, f"input {k!r} is not declared")
676 for expr in placeholders(v):
677 head = expr.split("|")[0].strip().split(".")[0].split("[")[0]
678 if head not in WATCH_ROOTS:
679 err(where, f"template root {head!r} is not one of {', '.join(WATCH_ROOTS)}")
680 for k, spec in tree.inputs.items():
681 if spec.required and spec.default is None and k not in inputs:
682 err(where, f"input {k!r} is required and the watch does not give it")
683 if w.get("name") is not None and not isinstance(w["name"], str):
684 err(where, "'name' is a string")
685 return used
686
687
688def _literal(value: Any) -> str | None:
689 """The value when it is a string with no placeholder, else None."""
690 return value if isinstance(value, str) and not PLACEHOLDER.search(value) else None
691
692
693def _check_browser(tree: Tree, node: Node, spec: Any, err, warn) -> None:
694 where = f"nodes.{node.id}.browser"
695 if not isinstance(spec, dict) or not isinstance(spec.get("url"), str) or not spec["url"]:
696 err(where, "'browser' is a table with 'url' and optionally 'wait_for', 'wait_until', 'actions'")
697 return
698 head = PLACEHOLDER.split(spec["url"], 1)[0]
699 parsed = urllib.parse.urlsplit(head)
700 if parsed.scheme and parsed.hostname:
701 if parsed.scheme not in ("http", "https"):
702 err(where, f"scheme {parsed.scheme!r} is not http or https")
703 if not tree.host_allowed(head):
704 err(where, f"host {parsed.hostname!r} is not in this tree's hosts")
705 else:
706 warn(where, "the host is decided at run time; the runtime refuses hosts outside the list")
707 actions = spec.get("actions", [])
708 if not isinstance(actions, list):
709 err(where, "'actions' is a list of tables")
710 return
711 for i, a in enumerate(actions):
712 keys = set(a) & set(adapters.BROWSER_ACTIONS) if isinstance(a, dict) else set()
713 if len(keys) != 1:
714 err(f"{where}.actions[{i}]", f"an action is a table with exactly one of {', '.join(adapters.BROWSER_ACTIONS)}")
715 elif keys & {"fill", "press", "select"}:
716 k = keys.pop()
717 need = "value" if k != "press" else "key"
718 if not isinstance(a[k], dict) or "selector" not in a[k] or need not in a[k]:
719 err(f"{where}.actions[{i}]", f"'{k}' is a table with 'selector' and '{need}'")
720
721
722def _check_file(tree: Tree, node: Node, spec: Any, err, warn) -> None:
723 where = f"nodes.{node.id}.file"
724 if not isinstance(spec, dict) or not isinstance(spec.get("path"), str) or not spec["path"]:
725 err(where, "'file' is a table with 'path' and optionally 'tail' (to read), 'write' or 'append' (to change)")
726 return
727 writes = "write" in spec or "append" in spec
728 if writes and node.kind == "observe":
729 err(where, "a write changes things; make this an act node")
730 if not writes and node.kind == "act":
731 warn(where, "an act that only reads; an observe node would retry it safely")
732 if "write" in spec and "append" in spec:
733 err(where, "'write' or 'append', not both")
734 if spec.get("tail") is not None and not isinstance(spec["tail"], int) and not (isinstance(spec["tail"], str) and PLACEHOLDER.search(spec["tail"])):
735 err(where, "'tail' is a number of lines")
736 path = _literal(spec["path"])
737 if path is None:
738 warn(where, "the path is decided at run time; the runtime refuses paths outside the list")
739 elif tree.paths and not adapters.path_allowed(tree.paths, path):
740 err(where, f"{path!r} is not under this tree's paths")
741
742
743def _check_shell(tree: Tree, node: Node, spec: Any, err, warn) -> None:
744 where = f"nodes.{node.id}.shell"
745 if not isinstance(spec, dict) or not isinstance(spec.get("command"), str) or not spec["command"]:
746 err(where, "'shell' is a table with 'command' (a program name) and optionally 'args' (a list), 'stdin', 'env', 'cwd'")
747 return
748 if spec.get("args") is not None and not isinstance(spec["args"], list):
749 err(where, "'args' is a list; no shell reads the line, so there is nothing to quote")
750 cmd = _literal(spec["command"])
751 if cmd is None:
752 warn(where, "the command is decided at run time; the runtime refuses commands outside the list")
753 elif tree.commands and not adapters.command_allowed(tree.commands, cmd):
754 err(where, f"{cmd!r} is not in this tree's commands")
755
756
757def _check_serial(tree: Tree, node: Node, spec: Any, err, warn) -> None:
758 where = f"nodes.{node.id}.serial"
759 if not isinstance(spec, dict) or not isinstance(spec.get("device"), str) or not spec["device"]:
760 err(where, "'serial' is a table with 'device' and optionally 'baud', 'write', 'read_until' or 'read'")
761 return
762 if spec.get("baud") is not None and not isinstance(spec["baud"], int):
763 err(where, "'baud' is a number")
764 dev = _literal(spec["device"])
765 if dev is None:
766 warn(where, "the device is decided at run time; the runtime refuses devices outside the list")
767 elif tree.devices and not adapters.device_allowed(tree.devices, dev):
768 err(where, f"{dev!r} is not in this tree's devices")
769
770
771def _check_queue(tree: Tree, node: Node, spec: Any, err, warn) -> None:
772 where = f"nodes.{node.id}.queue"
773 if not isinstance(spec, dict) or not isinstance(spec.get("url"), str) or not isinstance(spec.get("topic"), str):
774 err(where, "'queue' is a table with 'url' (mqtt://host[:port]), 'topic' and, to send, 'publish'")
775 return
776 if "publish" in spec and node.kind == "observe":
777 err(where, "a publish changes things; make this an act node")
778 if "publish" not in spec and node.kind == "act":
779 warn(where, "an act that only waits for a message; an observe node would retry it safely")
780 url = _literal(spec["url"])
781 if url is None:
782 warn(where, "the broker is decided at run time; the runtime refuses hosts outside the list")
783 else:
784 try:
785 _, host, port = adapters.broker(url)
786 except ValueError as e:
787 err(where, str(e))
788 else:
789 if tree.hosts and not tree.host_allowed(f"mqtt://{host}:{port}/"):
790 err(where, f"host {host!r} is not in this tree's hosts")
791 topic = _literal(spec["topic"])
792 if topic is None:
793 warn(where, "the topic is decided at run time; the runtime refuses topics outside the list")
794 elif tree.topics and not adapters.topic_allowed(tree.topics, topic):
795 err(where, f"{topic!r} is not under this tree's topics")
796
797
798def _check_http(tree: Tree, node: Node, http: Any, err, warn) -> None:
799 where = f"nodes.{node.id}.http"
800 if not isinstance(http, dict):
801 err(where, "'http' is a table with 'url' and optionally 'method', 'headers', 'body'")
802 return
803 url = http.get("url")
804 if not isinstance(url, str) or not url:
805 err(where, "needs a 'url'")
806 return
807 method = str(http.get("method", "GET")).upper()
808 if method not in HTTP_METHODS:
809 err(where, f"unknown method {method!r}")
810 if node.kind == "observe" and method not in READ_METHODS:
811 err(where, f"{method} changes things; make this an act node")
812 if node.kind == "act" and method in READ_METHODS and http.get("body") is None:
813 warn(where, f"{method} with no body is a read; an observe node would retry it safely")
814 head = PLACEHOLDER.split(url, 1)[0]
815 parsed = urllib.parse.urlsplit(head)
816 if parsed.scheme and parsed.hostname:
817 if parsed.scheme not in ("http", "https"):
818 err(where, f"scheme {parsed.scheme!r} is not http or https")
819 if not tree.host_allowed(head):
820 err(where, f"host {parsed.hostname!r} is not in this tree's hosts")
821 else:
822 warn(where, "the host is decided at run time; the runtime refuses hosts outside the list")
823 headers = http.get("headers", {})
824 if headers is not None and not isinstance(headers, dict):
825 err(where, "'headers' is a table")
826
827
828_SOURCE_CHECKS = {"http": _check_http, "browser": _check_browser, "file": _check_file, "shell": _check_shell, "serial": _check_serial, "queue": _check_queue}
829
830
831def _check_graph(tree: Tree) -> list[Problem]:
832 problems: list[Problem] = []
833 # Reachability from start.
834 seen: set[str] = set()
835 stack = [tree.start]
836 while stack:
837 nid = stack.pop()
838 if nid in seen or nid not in tree.nodes:
839 continue
840 seen.add(nid)
841 stack.extend(tree.nodes[nid].targets())
842 for nid in tree.nodes:
843 if nid not in seen:
844 problems.append(Problem("warn", f"nodes.{nid}", "not reachable from start"))
845 # Cycles: a tree does not loop.
846 WHITE, GREY, BLACK = 0, 1, 2
847 colour = {nid: WHITE for nid in tree.nodes}
848
849 def visit(nid: str, path: list[str]) -> None:
850 colour[nid] = GREY
851 for t in tree.nodes[nid].targets():
852 if t not in tree.nodes:
853 continue
854 if colour[t] == GREY:
855 cycle = path[path.index(t):] + [t]
856 problems.append(Problem("error", f"nodes.{nid}", "trees do not loop: " + " -> ".join(cycle) + " (use each with a limit)"))
857 elif colour[t] == WHITE:
858 visit(t, path + [t])
859 colour[nid] = BLACK
860
861 for nid in tree.nodes:
862 if colour[nid] == WHITE:
863 visit(nid, [nid])
864 return problems