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 | |
| 3 | A tree is a TOML file. It names what it answers to (``triggers``), what it |
| 4 | needs (``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 | |
| 28 | Trees do not loop. A path through a tree is finite by construction, which |
| 29 | is 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 | |
| 36 | from __future__ import annotations |
| 37 | |
| 38 | import re |
| 39 | import tomllib |
| 40 | import urllib.parse |
| 41 | from dataclasses import dataclass, field |
| 42 | from pathlib import Path |
| 43 | from typing import Any |
| 44 | |
| 45 | from . import adapters, conditions, extract as extractmod |
| 46 | from .intent import KINDS as SLOT_KINDS, PRODUCES, Language, Trigger, TriggerError |
| 47 | from .template import PLACEHOLDER, placeholders |
| 48 | |
| 49 | NAME = re.compile(r"^[a-z0-9][a-z0-9\-]*$") |
| 50 | KINDS = {"observe", "act", "decide", "set", "each", "delegate", "ask", "done", "fail"} |
| 51 | TERMINAL = {"done", "fail"} |
| 52 | INPUT_TYPES = {"str", "int", "float", "bool", "list", "dict"} |
| 53 | HTTP_METHODS = {"GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"} |
| 54 | READ_METHODS = {"GET", "HEAD", "OPTIONS"} |
| 55 | |
| 56 | |
| 57 | class TreeError(ValueError): |
| 58 | pass |
| 59 | |
| 60 | |
| 61 | @dataclass |
| 62 | class 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 |
| 72 | class 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 |
| 122 | class 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 |
| 152 | class 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 | |
| 275 | def 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 | |
| 285 | def 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 | |
| 293 | def 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 | |
| 390 | def _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 | |
| 397 | def _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 | |
| 414 | def _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 | |
| 426 | def 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"}} |
| 603 | WATCH_KINDS = ("every", "file", "queue", "serial") |
| 604 | WATCH_ROOTS = ("event", "env", "now") |
| 605 | |
| 606 | |
| 607 | def _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 | |
| 619 | def _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 | |
| 688 | def _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 | |
| 693 | def _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 | |
| 722 | def _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 | |
| 743 | def _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 | |
| 757 | def _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 | |
| 771 | def _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 | |
| 798 | def _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 | |
| 831 | def _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 |