openpuppet-language 2.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 (40) hide show
  1. openpuppet_language-2.0.data/data/share/puppet/conformance/README.md +274 -0
  2. openpuppet_language-2.0.data/data/share/puppet/conformance/cases/binding.json +238 -0
  3. openpuppet_language-2.0.data/data/share/puppet/conformance/cases/capabilities.json +155 -0
  4. openpuppet_language-2.0.data/data/share/puppet/conformance/cases/grammar.json +248 -0
  5. openpuppet_language-2.0.data/data/share/puppet/conformance/cases/interaction.json +220 -0
  6. openpuppet_language-2.0.data/data/share/puppet/conformance/cases/listen.json +133 -0
  7. openpuppet_language-2.0.data/data/share/puppet/conformance/cases/platform.json +274 -0
  8. openpuppet_language-2.0.data/data/share/puppet/conformance/cases/probes.json +117 -0
  9. openpuppet_language-2.0.data/data/share/puppet/conformance/cases/render.json +180 -0
  10. openpuppet_language-2.0.data/data/share/puppet/conformance/cases/tabs.json +39 -0
  11. openpuppet_language-2.0.data/data/share/puppet/conformance/cases/templates.json +143 -0
  12. openpuppet_language-2.0.data/data/share/puppet/conformance/fixtures/capabilities_bad_result.py +11 -0
  13. openpuppet_language-2.0.data/data/share/puppet/conformance/fixtures/capabilities_basic.py +24 -0
  14. openpuppet_language-2.0.data/data/share/puppet/conformance/fixtures/capabilities_deps_mismatch.py +11 -0
  15. openpuppet_language-2.0.data/data/share/puppet/conformance/fixtures/capabilities_no_doc.py +10 -0
  16. openpuppet_language-2.0.data/data/share/puppet/conformance/runner.py +721 -0
  17. openpuppet_language-2.0.data/data/share/puppet/spec/01-grammar.md +283 -0
  18. openpuppet_language-2.0.data/data/share/puppet/spec/02-ir.md +77 -0
  19. openpuppet_language-2.0.data/data/share/puppet/spec/03-semantics.md +167 -0
  20. openpuppet_language-2.0.data/data/share/puppet/spec/04-vocabulary.md +227 -0
  21. openpuppet_language-2.0.data/data/share/puppet/spec/05-render-contract.md +126 -0
  22. openpuppet_language-2.0.data/data/share/puppet/spec/06-diagnostics.md +151 -0
  23. openpuppet_language-2.0.data/data/share/puppet/spec/README.md +73 -0
  24. openpuppet_language-2.0.dist-info/METADATA +50 -0
  25. openpuppet_language-2.0.dist-info/RECORD +40 -0
  26. openpuppet_language-2.0.dist-info/WHEEL +5 -0
  27. openpuppet_language-2.0.dist-info/entry_points.txt +2 -0
  28. openpuppet_language-2.0.dist-info/top_level.txt +1 -0
  29. puppet/__init__.py +19 -0
  30. puppet/adapter.py +112 -0
  31. puppet/capabilities.py +172 -0
  32. puppet/cli.py +132 -0
  33. puppet/diag.py +42 -0
  34. puppet/engine.py +1038 -0
  35. puppet/eval.py +243 -0
  36. puppet/ir.py +538 -0
  37. puppet/lang.py +755 -0
  38. puppet/raster_adapter.py +348 -0
  39. puppet/tk_adapter.py +374 -0
  40. puppet/vocab.py +147 -0
puppet/engine.py ADDED
@@ -0,0 +1,1038 @@
1
+ """执行语义(`spec/03-semantics.md`)。
2
+
3
+ 引擎只写状态;程序 IR 只由命令批次改变。任何"降级 / 丢弃 / 失败 / 上限"都产生诊断。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import inspect
10
+ import json
11
+ import os
12
+ import tempfile
13
+ import threading
14
+ import time
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ from .capabilities import load_capability_module, type_matches
18
+ from .diag import Diagnostic, ERROR, INFO, WARNING
19
+ from .eval import CollectionRef, EvalError, evaluate
20
+ from .ir import (ROOT, Program, ancestors_of, apply_stmt, in_template, new_program,
21
+ validate)
22
+ from .lang import (ActionStmt, Add, CallStmt, Del, Expr, Lit, Move, On, Probe,
23
+ Ref, SetStmt, Stmt, is_static, parse_program)
24
+ from . import vocab
25
+
26
+ STATE_FLAGS = vocab.STATE_FLAGS
27
+ FLAG_DEFAULTS = {"visible": True, "disabled": False, "hover": False,
28
+ "focus": False, "pressed": False, "error": False}
29
+ SERIALIZABLE = (str, int, float, bool, list, dict)
30
+
31
+
32
+ class Store:
33
+ """状态文件:原子写。"""
34
+
35
+ def __init__(self, path: str):
36
+ self.path = path
37
+ self.lock = threading.Lock()
38
+
39
+ def write(self, payload: dict) -> None:
40
+ with self.lock:
41
+ tmp = self.path + ".tmp"
42
+ with open(tmp, "w", encoding="utf-8") as fh:
43
+ json.dump(payload, fh, ensure_ascii=False)
44
+ os.replace(tmp, self.path)
45
+
46
+ def read(self) -> dict:
47
+ with self.lock:
48
+ if not os.path.exists(self.path):
49
+ return {}
50
+ try:
51
+ with open(self.path, "r", encoding="utf-8") as fh:
52
+ return json.load(fh)
53
+ except Exception: # noqa: BLE001 - 损坏状态按缺失处理(并会被记录)
54
+ return {}
55
+
56
+
57
+ class _Ctx:
58
+ """求值上下文:读引用、读局部绑定。
59
+
60
+ 绑定是**可叠加的**,不是替换关系:处理器可以有 `as t`,其动作里又能
61
+ `update_where … as r`;两个绑定在同一个表达式里必须同时可见。
62
+ """
63
+
64
+ def __init__(self, engine: "Engine", bindings: Optional[Dict[str, dict]] = None):
65
+ self.engine = engine
66
+ self.bindings: Dict[str, dict] = dict(bindings or {})
67
+ self.cache: Dict[tuple, Any] = {}
68
+ self.visiting: set = set()
69
+
70
+ def child(self, name: str, row) -> "_Ctx":
71
+ """派生一个作用域:在既有绑定之上再加一层。"""
72
+ inner = _Ctx(self.engine, self.bindings)
73
+ if name:
74
+ inner.bindings[name] = row
75
+ return inner
76
+
77
+ def read_ref(self, addr: str, field: Optional[str]):
78
+ program = self.engine.program
79
+ if addr in program.data:
80
+ if field is None:
81
+ return CollectionRef(addr)
82
+ if field == "status":
83
+ rec = self.engine.slots.get(addr)
84
+ return rec["status"] if rec else "idle"
85
+ if field == "value":
86
+ rec = self.engine.slots.get(addr)
87
+ return rec.get("value") if rec else None
88
+ items = self.engine.items.get(addr) or []
89
+ if len(items) == 1 and field in items[0]:
90
+ return items[0][field]
91
+ raise EvalError("BIND_EVAL", "数据源 #%s 取不到字段 %s" % (addr, field))
92
+ node = program.nodes.get(addr)
93
+ if node is None:
94
+ raise EvalError("BIND_EVAL", "未知地址 #%s" % addr)
95
+ if field is None:
96
+ raise EvalError("BIND_EVAL", "节点 #%s 不能作为值使用" % addr)
97
+ if field in STATE_FLAGS:
98
+ if field in node.attrs and not self.engine.is_flag_static(addr, field):
99
+ return self.value_of(addr, field)
100
+ return self.engine.flag(addr, field)
101
+ if field not in node.attrs:
102
+ default = vocab.default_value(node.type, field)
103
+ if default is not vocab._MISSING:
104
+ return default
105
+ return self.value_of(addr, field)
106
+
107
+ def source_items(self, source: str) -> List[dict]:
108
+ """数据源的行集合(供 count / len / first 等使用)。"""
109
+ if source not in self.engine.program.data:
110
+ raise EvalError("BIND_EVAL", "未知数据源 #%s" % source)
111
+ return list(self.engine.items.get(source, []))
112
+
113
+ def read_local(self, name: str, field: Optional[str]):
114
+ row = self.bindings.get(name)
115
+ if row is None:
116
+ raise EvalError("BIND_EVAL", "当前上下文没有绑定 %s" % name)
117
+ if field is None:
118
+ return dict(row)
119
+ if field not in row:
120
+ raise EvalError("BIND_EVAL", "绑定的记录没有字段 %s" % field)
121
+ return row[field]
122
+
123
+ def value_of(self, nid: str, attr: str):
124
+ key = (nid, attr)
125
+ if key in self.cache:
126
+ return self.cache[key]
127
+ if key in self.visiting:
128
+ raise EvalError("BIND_CYCLE", "绑定成环:#%s.%s" % (nid, attr))
129
+ node = self.engine.program.nodes.get(nid)
130
+ expr = node.attrs.get(attr) if node else None
131
+ if expr is None:
132
+ raise EvalError("BIND_EVAL", "节点 #%s 没有属性 %s" % (nid, attr))
133
+ self.visiting.add(key)
134
+ try:
135
+ value = evaluate(expr, self)
136
+ finally:
137
+ self.visiting.discard(key)
138
+ self.cache[key] = value
139
+ return value
140
+
141
+
142
+ class Engine:
143
+ CASCADE_LIMIT = 32
144
+
145
+ def __init__(self, workdir: Optional[str] = None, rendering: Optional[dict] = None):
146
+ self.workdir = workdir or tempfile.mkdtemp(prefix="puppet-state-")
147
+ self.store = Store(os.path.join(self.workdir, "state.json"))
148
+ self.lock = threading.RLock()
149
+ self.program = new_program()
150
+ self.items: Dict[str, List[dict]] = {}
151
+ self.slots: Dict[str, dict] = {}
152
+ self.flags: Dict[str, Dict[str, bool]] = {}
153
+ self.capabilities: Dict[str, dict] = {}
154
+ self.limits: Dict[str, Any] = {"callTimeoutMs": 5000}
155
+ self.pending: List[Diagnostic] = []
156
+ self.events: List[dict] = []
157
+ self.probes: List[dict] = []
158
+ self.subscriptions: set = set()
159
+ self.pending_limit = 256
160
+ self.dropped = 0
161
+ self.revision = 0
162
+ self._baseline: set = set()
163
+ self._seq = 0
164
+ # 几何测试替身(conformance 用):地址 → 矩形。为空表示"本渲染器无几何"。
165
+ self.render_geometry: Dict[str, dict] = {}
166
+ # 能力声明(渲染契约第 1 节):渲染器支持哪些词汇。None = 全部标准。
167
+ self.rendering = self._merge_rendering(rendering)
168
+
169
+ # ------------------------------------------------------------ 能力声明
170
+
171
+ @staticmethod
172
+ def _merge_rendering(rendering) -> Dict[str, Any]:
173
+ """能力声明:词汇能力为列表(`None` = 支持全部标准词汇)。
174
+
175
+ 测试替身可经 `load.rendering` 覆盖——实现必须**按声明行事**:
176
+ 未声明支持的词汇被使用时必须产生可见降级(声明即 oracle 的实现侧)。
177
+ """
178
+ base: Dict[str, Any] = {"controls": None, "attributes": None,
179
+ "animations": None, "icons": None}
180
+ if isinstance(rendering, dict):
181
+ for key in base:
182
+ if key in rendering:
183
+ base[key] = rendering[key]
184
+ return base
185
+
186
+ def _rendering_degradations(self) -> List[Diagnostic]:
187
+ """按能力声明扫描程序词汇:未声明支持的必须可见降级,禁止静默。"""
188
+ declared = self.rendering
189
+ controls = declared.get("controls")
190
+ attributes = declared.get("attributes")
191
+ animations = declared.get("animations")
192
+ icons = declared.get("icons")
193
+ out: List[Diagnostic] = []
194
+ for node in self.program.nodes.values():
195
+ if controls is not None and node.type not in controls:
196
+ out.append(Diagnostic(
197
+ "DEGRADED_FEATURE", INFO,
198
+ "渲染器未声明支持控件 %s,已降级呈现" % node.type,
199
+ feature="control:" + node.type))
200
+ for name, expr in node.attrs.items():
201
+ if (attributes is not None and name in vocab.ALL_ATTRS
202
+ and name not in attributes):
203
+ out.append(Diagnostic(
204
+ "DEGRADED_FEATURE", INFO,
205
+ "渲染器未声明支持属性 %s,已降级" % name,
206
+ feature="attr:" + name))
207
+ if (icons is not None and name == "icon"
208
+ and isinstance(expr, Lit) and isinstance(expr.value, str)
209
+ and expr.value not in icons):
210
+ out.append(Diagnostic(
211
+ "DEGRADED_FEATURE", INFO,
212
+ "图标 %s 不在能力声明内,已降级" % expr.value,
213
+ feature="icon:" + expr.value))
214
+ if animations is not None and name == "animate":
215
+ for item in self._static_names(expr):
216
+ if item not in animations:
217
+ out.append(Diagnostic(
218
+ "DEGRADED_FEATURE", INFO,
219
+ "属性 %s 不在可动画声明内,改为立即生效" % item,
220
+ feature="animation:" + item))
221
+ return out
222
+
223
+ @staticmethod
224
+ def _static_names(expr) -> List[str]:
225
+ """`animate` 的静态值:字符串或字符串列表;动态表达式不猜。"""
226
+ if not isinstance(expr, Lit):
227
+ return []
228
+ value = expr.value
229
+ if isinstance(value, str):
230
+ return [value]
231
+ if isinstance(value, list):
232
+ return [v for v in value if isinstance(v, str)]
233
+ return []
234
+
235
+ # ------------------------------------------------------------ 装载与批次
236
+
237
+ def load(self, lines, capabilities=None, limits=None, seed_state=None,
238
+ render_geometry=None, rendering=None,
239
+ capability_modules=None) -> List[Diagnostic]:
240
+ with self.lock:
241
+ self.program = new_program()
242
+ self.items = {}
243
+ self.slots = {}
244
+ self.flags = {}
245
+ self.pending = []
246
+ # 装载 = 新的生命周期:观察缓冲必须清空,否则上一个程序的诊断/事件/
247
+ # 探针结果会漏进下一个程序(跨用例污染,且表现为"莫名"的失败)。
248
+ self.events = []
249
+ self.probes = []
250
+ self.subscriptions = set()
251
+ self.render_geometry = dict(render_geometry or {})
252
+ self.rendering = self._merge_rendering(rendering)
253
+ self.capabilities = {c["name"]: c for c in (capabilities or [])}
254
+ self.limits = {"callTimeoutMs": 5000}
255
+ self.limits.update(limits or {})
256
+ self.revision = 0
257
+ self._baseline = set()
258
+ self.dropped = 0
259
+ self.pending_limit = int(self.limits.get("eventQueue", 256))
260
+ if seed_state:
261
+ self.store.write(seed_state)
262
+ saved = self.store.read()
263
+ stored_items = (saved.get("items") or {}) if isinstance(saved, dict) else {}
264
+
265
+ diags: List[Diagnostic] = []
266
+ # 真实能力模块:与替身同一份契约形状;同名以真实模块为准
267
+ for path in capability_modules or []:
268
+ contracts, cap_diags = load_capability_module(path)
269
+ diags += cap_diags
270
+ self.capabilities.update(contracts)
271
+ stmts, pdiags = parse_program(list(lines))
272
+ diags += pdiags
273
+ # 动作与调用需要数据源已存在,故延后执行
274
+ deferred: List[Stmt] = []
275
+ changed: List[str] = []
276
+ for stmt in stmts:
277
+ if isinstance(stmt, (ActionStmt, CallStmt)):
278
+ deferred.append(stmt)
279
+ continue
280
+ if isinstance(stmt, SetStmt):
281
+ self._split_flag_set(stmt, diags)
282
+ changed += apply_stmt(self.program, stmt, diags)
283
+ self._refresh_subscriptions()
284
+ diags += validate(self.program)
285
+ diags += self._rendering_degradations()
286
+ if not seed_state and not stored_items and any(
287
+ src.persist for src in self.program.data.values()):
288
+ diags.append(Diagnostic("STATE_REBUILT", INFO,
289
+ "没有可用的状态文件,持久数据从声明初值重建"))
290
+ self._seed_items(stored_items, diags)
291
+ ctx = _Ctx(self)
292
+ for stmt in deferred:
293
+ if isinstance(stmt, CallStmt):
294
+ self._exec_call(stmt.func, stmt.params, stmt.into, diags, ctx)
295
+ else:
296
+ changed += self._exec_action(stmt.action, diags, ctx, stmt.line)
297
+ # 写在源文件里的探针在装载完成后执行(此时程序已完整)
298
+ for probe in self.program.probes:
299
+ self._run_probe(probe.verb, probe.target)
300
+ diags += self._cascade(changed)
301
+ diags += self._propagate()
302
+ self._persist()
303
+ self._baseline = {d.key() for d in diags}
304
+ return diags
305
+
306
+ def apply_batch(self, lines) -> List[Diagnostic]:
307
+ with self.lock:
308
+ diags: List[Diagnostic] = []
309
+ stmts, pdiags = parse_program(list(lines))
310
+ diags += pdiags
311
+ changed: List[str] = []
312
+ ctx = _Ctx(self)
313
+ for stmt in stmts:
314
+ if isinstance(stmt, CallStmt):
315
+ self._exec_call(stmt.func, stmt.params, stmt.into, diags, ctx)
316
+ continue
317
+ if isinstance(stmt, ActionStmt):
318
+ changed += self._exec_action(stmt.action, diags, ctx, stmt.line)
319
+ continue
320
+ if isinstance(stmt, Probe):
321
+ self._run_probe(stmt.verb, stmt.target)
322
+ continue
323
+ if isinstance(stmt, SetStmt):
324
+ self._split_flag_set(stmt, diags, ctx)
325
+ changed += apply_stmt(self.program, stmt, diags)
326
+ self._refresh_subscriptions()
327
+ diags += validate(self.program)
328
+ diags += self._rendering_degradations()
329
+ diags += self._cascade(changed)
330
+ diags += self._propagate()
331
+ self.revision += 1
332
+ self._persist()
333
+ return self._new_only(diags)
334
+
335
+ def fire(self, target: str, event: str, row: Optional[int] = None,
336
+ value=None) -> List[Diagnostic]:
337
+ target = target.lstrip("#") # 接受 "#id" 与 "id" 两种写法
338
+ with self.lock:
339
+ diags: List[Diagnostic] = []
340
+ if event in vocab.INTERACTION_EVENTS and self.flag(target, "disabled"):
341
+ return diags # 引擎层拦截(禁用节点不响应)
342
+ payload: dict = {"value": value} if event == "change" else {}
343
+ self._publish(target, event, row, value) # 观察独立于反应
344
+ row_name, row_value = self._row_context(target, row, diags)
345
+ changed = self._dispatch(target, event, payload, diags,
346
+ row_name, row_value)
347
+ diags += self._cascade(changed)
348
+ diags += self._propagate()
349
+ self.revision += 1
350
+ self._persist()
351
+ return self._new_only(diags)
352
+
353
+ def restart(self) -> List[Diagnostic]:
354
+ with self.lock:
355
+ diags: List[Diagnostic] = []
356
+ # 重启是一个新的生命周期:诊断重新报一次,不沿用上一轮的"已报过"基线
357
+ self._baseline = set()
358
+ saved = self.store.read()
359
+ stored = (saved.get("items") or {}) if isinstance(saved, dict) else {}
360
+ if not stored and any(src.persist for src in self.program.data.values()):
361
+ diags.append(Diagnostic("STATE_REBUILT", INFO,
362
+ "没有可用的状态文件,持久数据从声明初值重建"))
363
+ self.slots = {}
364
+ self.flags = {}
365
+ self._seed_items(stored, diags)
366
+ diags += self._propagate()
367
+ self.revision = int((saved or {}).get("revision", self.revision)) + 1
368
+ self._persist()
369
+ self._baseline = {d.key() for d in diags}
370
+ return diags
371
+
372
+ # ------------------------------------------------------------ 状态辅助
373
+
374
+ def flag(self, nid: str, name: str) -> bool:
375
+ return bool(self.flags.get(nid, {}).get(name, FLAG_DEFAULTS.get(name, False)))
376
+
377
+ def set_flag(self, nid: str, name: str, value: bool) -> None:
378
+ self.flags.setdefault(nid, {})[name] = bool(value)
379
+
380
+ def is_flag_static(self, nid: str, name: str) -> bool:
381
+ node = self.program.nodes.get(nid)
382
+ if node is None:
383
+ return True
384
+ return is_static(node.attrs.get(name)) if name in node.attrs else True
385
+
386
+ def _split_flag_set(self, stmt: SetStmt, diags: List[Diagnostic],
387
+ ctx: Optional[_Ctx] = None) -> None:
388
+ """顶层 `set` 作用于状态标志时写运行期状态,不写程序。"""
389
+ node = self.program.nodes.get(stmt.target)
390
+ if node is None or not stmt.attrs:
391
+ return
392
+ ctx = ctx if ctx is not None else _Ctx(self)
393
+ for key in [k for k in stmt.attrs if k in STATE_FLAGS]:
394
+ expr = stmt.attrs.pop(key)
395
+ try:
396
+ self.set_flag(stmt.target, key, bool(evaluate(expr, ctx)))
397
+ except EvalError as ex:
398
+ diags.append(Diagnostic(ex.code, ERROR, ex.message, line=stmt.line))
399
+
400
+ def _seed_flags(self) -> None:
401
+ for node in self.program.nodes.values():
402
+ for name in STATE_FLAGS:
403
+ expr = node.attrs.get(name)
404
+ if isinstance(expr, Lit):
405
+ self.set_flag(node.id, name, bool(expr.value))
406
+
407
+ def _seed_items(self, stored_items: Optional[dict] = None,
408
+ diags: Optional[List[Diagnostic]] = None) -> None:
409
+ stored_items = stored_items or {}
410
+ for src in self.program.data.values():
411
+ key = "#" + src.id
412
+ rows = stored_items.get(key)
413
+ if src.persist and isinstance(rows, list):
414
+ self.items[src.id] = rows
415
+ if diags is not None:
416
+ diags.extend(self._drift_diags(src, rows))
417
+ else:
418
+ self.items[src.id] = self._initial_items(src)
419
+ self._seed_flags()
420
+
421
+ def _drift_diags(self, src, rows: list) -> List[Diagnostic]:
422
+ """结构漂移:不迁移、不删除,但必须说出来。"""
423
+ extra = set()
424
+ for row in rows:
425
+ if isinstance(row, dict):
426
+ extra |= {k for k in row if k not in src.schema}
427
+ if not extra:
428
+ return []
429
+ return [Diagnostic("PERSIST_SCHEMA_DRIFT", WARNING,
430
+ "持久状态里出现程序未声明的字段:%s(不迁移、不删除)"
431
+ % ", ".join(sorted(extra)))]
432
+
433
+ def _initial_items(self, src) -> List[dict]:
434
+ if src.initial is None:
435
+ return []
436
+ try:
437
+ value = evaluate(src.initial, _Ctx(self))
438
+ except EvalError:
439
+ return []
440
+ if not isinstance(value, list):
441
+ return []
442
+ return [self._coerce_item(src.id, item) for item in value if isinstance(item, dict)]
443
+
444
+ def _coerce_item(self, source: str, item: dict) -> dict:
445
+ src = self.program.data.get(source)
446
+ if src is None:
447
+ return dict(item)
448
+ out = {}
449
+ for name, (_typ, default) in src.schema.items():
450
+ if name in item:
451
+ out[name] = item[name]
452
+ elif default is not None:
453
+ try:
454
+ out[name] = evaluate(default, _Ctx(self))
455
+ except EvalError:
456
+ out[name] = None
457
+ else:
458
+ out[name] = None
459
+ for key, value in item.items():
460
+ out.setdefault(key, value)
461
+ return out
462
+
463
+ def _persist(self) -> None:
464
+ payload = {
465
+ "revision": self.revision,
466
+ "items": {"#" + src.id: self.items.get(src.id, [])
467
+ for src in self.program.data.values() if src.persist},
468
+ }
469
+ self.store.write(payload)
470
+
471
+ def _new_only(self, diags: List[Diagnostic]) -> List[Diagnostic]:
472
+ out = []
473
+ for diag in diags:
474
+ key = diag.key()
475
+ if key in self._baseline:
476
+ continue
477
+ self._baseline.add(key)
478
+ out.append(diag)
479
+ return out
480
+
481
+ def _refresh_subscriptions(self) -> None:
482
+ self.subscriptions = {(l.target, l.event) for l in self.program.listens}
483
+
484
+ def _publish(self, target: str, event: str, row: Optional[int] = None,
485
+ value=None) -> None:
486
+ """把订阅的事件推进观察流。只有被 `listen` 订阅的才推送。"""
487
+ if (target, event) not in self.subscriptions:
488
+ return
489
+ item = {"target": "#" + target, "event": event}
490
+ if row is not None:
491
+ item["row"] = row
492
+ if value is not None:
493
+ item["value"] = value
494
+ self.events.append(item)
495
+ if len(self.events) > self.pending_limit:
496
+ self.dropped += len(self.events) - self.pending_limit
497
+ self.events = self.events[-self.pending_limit:]
498
+
499
+ def queue(self, diags: List[Diagnostic]) -> None:
500
+ """观察流**不得静默丢弃**:溢出时丢最早的,并留下可见标记。"""
501
+ self.pending.extend(diags)
502
+ if len(self.pending) > self.pending_limit:
503
+ self.dropped += len(self.pending) - self.pending_limit
504
+ self.pending = self.pending[-self.pending_limit:]
505
+
506
+ # ------------------------------------------------------------ 传播
507
+
508
+ def _propagate(self) -> List[Diagnostic]:
509
+ diags: List[Diagnostic] = []
510
+ ctx = _Ctx(self)
511
+ for node in self.program.nodes.values():
512
+ if in_template(self.program, node.id):
513
+ continue
514
+ for name in list(node.attrs):
515
+ if name in vocab.REF_ATTRS:
516
+ continue # 引用类属性是"指向",不是值
517
+ try:
518
+ ctx.value_of(node.id, name)
519
+ except EvalError as ex:
520
+ diags.append(Diagnostic(ex.code, ERROR, ex.message, line=node.line))
521
+ self.set_flag(node.id, "error", True)
522
+ return diags
523
+
524
+ def _refresh(self) -> None:
525
+ self.queue(self._propagate())
526
+ with self.lock:
527
+ self._persist()
528
+
529
+ # ------------------------------------------------------------ 处理器
530
+
531
+ def _cascade(self, changed: List[str]) -> List[Diagnostic]:
532
+ diags: List[Diagnostic] = []
533
+ queue = [c for c in changed if c in self.program.data]
534
+ rounds = 0
535
+ while queue:
536
+ rounds += 1
537
+ if rounds > self.CASCADE_LIMIT:
538
+ diags.append(Diagnostic(
539
+ "CONVERGENCE_LIMIT", ERROR,
540
+ "处理器级联达到上限(%d 轮),已停止传播" % self.CASCADE_LIMIT))
541
+ break
542
+ source = queue.pop(0)
543
+ queue += self._dispatch(source, "change", {"source": "#" + source}, diags)
544
+ return diags
545
+
546
+ def _owning_template(self, target: str):
547
+ for nid in [target] + ancestors_of(self.program, target):
548
+ node = self.program.nodes.get(nid)
549
+ if node is not None and node.type == "template":
550
+ return node
551
+ return None
552
+
553
+ def _row_context(self, target: str, row: Optional[int], diags: List[Diagnostic]):
554
+ """行内事件的上下文:该事件发生在模板的第几行。
555
+
556
+ 行内事件的绑定名只能由"第几行"确定,因此派发者**必须**给出行序号;
557
+ 无法确定时**必须**报错,不得静默按"无绑定"派发。
558
+ """
559
+ template = self._owning_template(target)
560
+ if template is None:
561
+ if row is not None:
562
+ diags.append(Diagnostic("ROW_CONTEXT", ERROR,
563
+ "#%s 不在任何模板内,不应携带行上下文" % target))
564
+ return "", None
565
+ if row is None:
566
+ diags.append(Diagnostic("ROW_CONTEXT", ERROR,
567
+ "#%s 位于模板 #%s 内,派发时必须给出行序号"
568
+ % (target, template.id)))
569
+ return template.as_name, None
570
+ for node in self.program.nodes.values():
571
+ ref = node.attrs.get("template")
572
+ src = node.attrs.get("source")
573
+ if isinstance(ref, Ref) and ref.addr == template.id and isinstance(src, Ref):
574
+ items = self.items.get(src.addr, [])
575
+ if 0 <= row < len(items):
576
+ return template.as_name, items[row]
577
+ diags.append(Diagnostic("ROW_CONTEXT", ERROR,
578
+ "行序号 %s 越界(共 %d 行)" % (row, len(items))))
579
+ return template.as_name, None
580
+ diags.append(Diagnostic("ROW_CONTEXT", ERROR,
581
+ "模板 #%s 未被任何列表使用" % template.id))
582
+ return template.as_name, None
583
+
584
+ def _dispatch(self, target: str, event: str, payload: dict,
585
+ diags: List[Diagnostic], row_name: str = "",
586
+ row=None) -> List[str]:
587
+ """派发处理器。两个绑定互不相干:
588
+
589
+ * 处理器的 `as <名>` → **事件载荷**(`change` 时是 `{value: <新值>}`);
590
+ * 模板声明的 `as <名>` → **行记录**(行上下文)。
591
+ """
592
+ changed: List[str] = []
593
+ for handler in list(self.program.handlers):
594
+ if handler.target == target and handler.event == event:
595
+ changed += self._run_handler(handler, diags, payload, row_name, row)
596
+ return changed
597
+
598
+ def _run_handler(self, handler, diags: List[Diagnostic], payload: dict,
599
+ row_name: str = "", row=None) -> List[str]:
600
+ bindings: Dict[str, dict] = {}
601
+ if handler.bind:
602
+ bindings[handler.bind] = payload if payload is not None else {}
603
+ if row_name and row is not None:
604
+ bindings[row_name] = row
605
+ ctx = _Ctx(self, bindings or None)
606
+ if handler.when is not None:
607
+ try:
608
+ if not evaluate(handler.when, ctx):
609
+ return []
610
+ except EvalError as ex:
611
+ diags.append(Diagnostic(ex.code, ERROR, ex.message, line=handler.line))
612
+ return []
613
+ changed: List[str] = []
614
+ for action in handler.actions:
615
+ changed += self._exec_action(action, diags, ctx, handler.line)
616
+ return changed
617
+
618
+ def _exec_action(self, action, diags: List[Diagnostic], ctx: _Ctx, line: int) -> List[str]:
619
+ changed: List[str] = []
620
+ if action.kind == "set":
621
+ node = self.program.nodes.get(action.target)
622
+ if node is None:
623
+ diags.append(Diagnostic("TARGET_MISSING", ERROR,
624
+ "目标 #%s 不存在" % action.target, line=line))
625
+ return changed
626
+ for key, expr in action.attrs.items():
627
+ try:
628
+ value = evaluate(expr, ctx)
629
+ except EvalError as ex:
630
+ diags.append(Diagnostic(ex.code, ERROR, ex.message, line=line))
631
+ continue
632
+ if key in STATE_FLAGS:
633
+ self.set_flag(action.target, key, bool(value))
634
+ continue
635
+ old = node.attrs.get(key)
636
+ if old is not None and not is_static(old):
637
+ diags.append(Diagnostic("BIND_OVERRIDDEN", INFO,
638
+ "属性 %s 的绑定已被覆盖" % key, line=line))
639
+ node.attrs[key] = Lit(value, repr(value))
640
+ return changed
641
+
642
+ source = action.target
643
+ if source not in self.program.data:
644
+ diags.append(Diagnostic("TARGET_MISSING", ERROR,
645
+ "数据源 #%s 不存在" % source, line=line))
646
+ return changed
647
+
648
+ if action.kind == "append":
649
+ item = self._eval_dict(action.item, ctx, diags, line)
650
+ if item is None:
651
+ return changed
652
+ self.items.setdefault(source, []).append(self._coerce_item(source, item))
653
+ changed.append(source)
654
+ elif action.kind == "remove":
655
+ item = self._eval_dict(action.item, ctx, diags, line)
656
+ if item is None:
657
+ return changed
658
+ rows = self.items.setdefault(source, [])
659
+ for idx, row in enumerate(rows):
660
+ if all(row.get(k) == v for k, v in item.items()):
661
+ rows.pop(idx)
662
+ changed.append(source)
663
+ break
664
+ elif action.kind == "remove_where":
665
+ rows = self.items.setdefault(source, [])
666
+ keep = []
667
+ for row in rows:
668
+ try:
669
+ hit = evaluate(action.where, ctx.child(action.bind, row))
670
+ except EvalError as ex:
671
+ diags.append(Diagnostic(ex.code, ERROR, ex.message, line=line))
672
+ keep.append(row)
673
+ continue
674
+ if hit:
675
+ changed.append(source)
676
+ else:
677
+ keep.append(row)
678
+ self.items[source] = keep
679
+ elif action.kind == "update_where":
680
+ rows = self.items.setdefault(source, [])
681
+ for row in rows:
682
+ sub = ctx.child(action.bind, row)
683
+ try:
684
+ hit = evaluate(action.where, sub)
685
+ except EvalError as ex:
686
+ diags.append(Diagnostic(ex.code, ERROR, ex.message, line=line))
687
+ continue
688
+ if not hit:
689
+ continue
690
+ for key, expr in action.attrs.items():
691
+ try:
692
+ row[key] = evaluate(expr, sub)
693
+ except EvalError as ex:
694
+ diags.append(Diagnostic(ex.code, ERROR, ex.message, line=line))
695
+ changed.append(source)
696
+ elif action.kind == "clear":
697
+ if self.items.get(source):
698
+ self.items[source] = []
699
+ changed.append(source)
700
+ elif action.kind == "sort":
701
+ rows = self.items.setdefault(source, [])
702
+ rows.sort(key=lambda r: (r.get(action.by) is None, r.get(action.by)))
703
+ changed.append(source)
704
+ elif action.kind == "call":
705
+ self._exec_call(action.func, action.params, action.into, diags, ctx)
706
+ if changed:
707
+ self._publish(source, "change")
708
+ return changed
709
+
710
+ def _eval_dict(self, expr, ctx: _Ctx, diags, line) -> Optional[dict]:
711
+ if expr is None:
712
+ return None
713
+ try:
714
+ value = evaluate(expr, ctx)
715
+ except EvalError as ex:
716
+ diags.append(Diagnostic(ex.code, ERROR, ex.message, line=line))
717
+ return None
718
+ return value if isinstance(value, dict) else None
719
+
720
+ # ------------------------------------------------------------ 能力与槽
721
+
722
+ def _exec_call(self, func: str, params: Dict[str, Expr], into: str,
723
+ diags: List[Diagnostic], ctx: _Ctx) -> None:
724
+ cap = self.capabilities.get(func)
725
+ if cap is None:
726
+ diags.append(Diagnostic("CALL_UNKNOWN", ERROR, "能力 %s 不存在" % func))
727
+ self._put_slot(into, "error", None, 0)
728
+ return
729
+ args = {}
730
+ for name, expr in params.items():
731
+ try:
732
+ args[name] = evaluate(expr, ctx)
733
+ except EvalError as ex:
734
+ diags.append(Diagnostic(ex.code, ERROR, ex.message))
735
+ return
736
+ missing = [p.get("name") for p in cap.get("params", [])
737
+ if p.get("required") and p.get("name") not in args]
738
+ if missing:
739
+ diags.append(Diagnostic("CALL_CONTRACT", ERROR,
740
+ "缺少必需参数:%s" % ", ".join(str(m) for m in missing)))
741
+ self._put_slot(into, "error", None, 0)
742
+ return
743
+ known = {p.get("name") for p in cap.get("params", [])}
744
+ extra = [k for k in args if k not in known]
745
+ if extra:
746
+ diags.append(Diagnostic("CALL_CONTRACT", ERROR,
747
+ "多余参数:%s" % ", ".join(sorted(extra))))
748
+ self._put_slot(into, "error", None, 0)
749
+ return
750
+ bad = []
751
+ for p in cap.get("params", []):
752
+ if p.get("name") in args and not type_matches(p.get("type", "any"),
753
+ args[p["name"]]):
754
+ bad.append("%s 期望 %s,实际 %s" % (p["name"], p.get("type"),
755
+ type(args[p["name"]]).__name__))
756
+ if bad:
757
+ diags.append(Diagnostic("CALL_CONTRACT", ERROR,
758
+ "参数类型不符:%s" % ";".join(bad)))
759
+ self._put_slot(into, "error", None, 0)
760
+ return
761
+ self._seq += 1
762
+ seq = self._seq
763
+ previous = self.slots.get(into)
764
+ if previous and previous.get("status") == "pending":
765
+ diags.append(Diagnostic("SLOT_CANCELLED", INFO,
766
+ "槽 #%s 上正在进行的调用被新调用取消" % into))
767
+ self._publish(into, "cancel")
768
+ self._dispatch(into, "cancel", {"status": "cancel"}, diags)
769
+ self._put_slot(into, "pending", None, seq)
770
+ self._publish(into, "pending")
771
+ self._dispatch(into, "pending", {"status": "pending", "value": None}, diags)
772
+ threading.Thread(target=self._run_capability,
773
+ args=(cap, args, into, seq), daemon=True).start()
774
+
775
+ def _put_slot(self, into: str, status: str, value, seq: int) -> None:
776
+ self.slots[into] = {"status": status, "value": value, "seq": seq}
777
+
778
+ def _run_capability(self, cap: dict, args: dict, into: str, seq: int) -> None:
779
+ behavior = cap.get("behavior", "return")
780
+ timeout = float(self.limits.get("callTimeoutMs", 5000)) / 1000.0
781
+ state = {"done": False}
782
+
783
+ def settle(status: str, value, code: str = "", message: str = "") -> None:
784
+ if state["done"]:
785
+ return
786
+ state["done"] = True
787
+ with self.lock:
788
+ current = self.slots.get(into) or {}
789
+ if current.get("seq") != seq:
790
+ self.queue([Diagnostic("SLOT_STALE_DROPPED", INFO,
791
+ "槽 #%s 丢弃陈旧结果" % into)])
792
+ return
793
+ if code:
794
+ self.queue([Diagnostic(code, ERROR, message)])
795
+ self._put_slot(into, status, value, seq)
796
+ self._publish(into, status, None, value)
797
+ slot_diags: List[Diagnostic] = []
798
+ changed = self._dispatch(into, status,
799
+ {"status": status, "value": value}, slot_diags)
800
+ slot_diags += self._cascade(changed)
801
+ self.queue(slot_diags)
802
+ self._refresh()
803
+
804
+ def on_timeout() -> None:
805
+ settle("timeout", None, "SLOT_TIMEOUT",
806
+ "能力 %s 调用超时(%sms)" % (cap["name"], self.limits.get("callTimeoutMs")))
807
+
808
+ timer = threading.Timer(timeout, on_timeout)
809
+ timer.start()
810
+ impl = cap.get("callable")
811
+ try:
812
+ if impl is not None:
813
+ # **真实能力**:`async` 直接 await,同步函数在本线程里跑(已由线程池承载)
814
+ value = (asyncio.run(impl(**args))
815
+ if inspect.iscoroutinefunction(impl) else impl(**args))
816
+ json.dumps(value) # 契约:必须可序列化
817
+ settle("done", value)
818
+ return
819
+ if behavior == "hang":
820
+ timer.join()
821
+ return
822
+ if behavior == "delay":
823
+ time.sleep(float(cap.get("delayMs", 100)) / 1000.0)
824
+ if behavior == "error":
825
+ raise RuntimeError("能力故意失败")
826
+ if behavior == "nonjson":
827
+ settle("error", None, "CALL_RESULT", "返回值不可序列化")
828
+ return
829
+ value = cap.get("value")
830
+ json.dumps(value) # 契约:必须可序列化
831
+ settle("done", value)
832
+ except Exception as ex: # noqa: BLE001 - 一律转为可见失败
833
+ settle("error", None, "CALL_RESULT", "调用失败:%s" % ex)
834
+ finally:
835
+ timer.cancel()
836
+
837
+ # ------------------------------------------------------------ 观察
838
+
839
+ def observe(self) -> dict:
840
+ with self.lock:
841
+ diags = self.pending
842
+ self.pending = []
843
+ if self.dropped:
844
+ diags = [Diagnostic("OBSERVATION_DROPPED", WARNING,
845
+ "观察流队列溢出,已丢弃 %d 条较早的事件" % self.dropped)] + diags
846
+ self.dropped = 0
847
+ events = self.events
848
+ self.events = []
849
+ probes = self.probes
850
+ self.probes = []
851
+ out = {
852
+ "diagnostics": [d.to_dict() for d in diags],
853
+ "events": events,
854
+ "probes": probes,
855
+ "nodes": ["#" + nid for nid in self.program.nodes],
856
+ "attrs": self._observe_attrs(),
857
+ "data": {"#" + k: v for k, v in self.items.items()},
858
+ "flags": self._observe_flags(),
859
+ "slots": {"#" + k: {"status": v.get("status"),
860
+ "value": v.get("value"),
861
+ "seq": v.get("seq", 0)}
862
+ for k, v in self.slots.items()},
863
+ "rows": self._observe_rows(),
864
+ }
865
+ if self.render_geometry:
866
+ out["geometry"] = self._observe_geometry()
867
+ return out
868
+
869
+ def _observe_geometry(self) -> Dict[str, Any]:
870
+ """几何快照(观察面可选字段)。
871
+
872
+ 仅当渲染器提供几何(或注入测试替身)时该字段存在;否则字段**不出现**——
873
+ 驱动者据此判断"这个实现没有几何面",再走截图兜底或看 `where` 的可见降级。
874
+ """
875
+ return {key: rect for key, rect in self.render_geometry.items()
876
+ if key.lstrip("#") in self.program.nodes}
877
+
878
+ def _observe_attrs(self) -> Dict[str, Any]:
879
+ out: Dict[str, Any] = {}
880
+ ctx = _Ctx(self)
881
+ for node in self.program.nodes.values():
882
+ if in_template(self.program, node.id):
883
+ continue
884
+ for name in node.attrs:
885
+ if name in STATE_FLAGS or name in vocab.REF_ATTRS:
886
+ continue
887
+ try:
888
+ value = ctx.value_of(node.id, name)
889
+ except EvalError:
890
+ continue
891
+ if isinstance(value, SERIALIZABLE) and not isinstance(value, CollectionRef):
892
+ out["#%s.%s" % (node.id, name)] = value
893
+ return out
894
+
895
+ def _observe_flags(self) -> Dict[str, bool]:
896
+ out: Dict[str, bool] = {}
897
+ for node in self.program.nodes.values():
898
+ for name in STATE_FLAGS:
899
+ out["#%s.%s" % (node.id, name)] = self.flag(node.id, name)
900
+ return out
901
+
902
+ def _observe_rows(self) -> Dict[str, list]:
903
+ out: Dict[str, list] = {}
904
+ for node in self.program.nodes.values():
905
+ if node.type != "list":
906
+ continue
907
+ tmpl_ref = node.attrs.get("template")
908
+ src_ref = node.attrs.get("source")
909
+ if not isinstance(tmpl_ref, Ref) or not isinstance(src_ref, Ref):
910
+ continue
911
+ template = self.program.nodes.get(tmpl_ref.addr)
912
+ if template is None:
913
+ continue
914
+ rows = []
915
+ for item in self.items.get(src_ref.addr, []):
916
+ entry: Dict[str, dict] = {}
917
+ for kid in self._descendants(template.id):
918
+ attrs = {}
919
+ ctx = _Ctx(self, {template.as_name: item})
920
+ for name in self.program.nodes[kid].attrs:
921
+ if name in STATE_FLAGS:
922
+ continue
923
+ try:
924
+ value = ctx.value_of(kid, name)
925
+ except EvalError:
926
+ continue
927
+ if isinstance(value, SERIALIZABLE) and not isinstance(value, CollectionRef):
928
+ attrs[name] = value
929
+ if attrs:
930
+ entry["#" + kid] = attrs
931
+ rows.append(entry)
932
+ out["#" + node.id] = rows
933
+ return out
934
+
935
+ def _descendants(self, root_id: str) -> List[str]:
936
+ out: List[str] = []
937
+ stack = list(self.program.nodes[root_id].children) if root_id in self.program.nodes else []
938
+ while stack:
939
+ cur = stack.pop(0)
940
+ node = self.program.nodes.get(cur)
941
+ if node is None:
942
+ continue
943
+ out.append(cur)
944
+ stack.extend(node.children)
945
+ return out
946
+
947
+ # ------------------------------------------------------------ 探针
948
+
949
+ def _run_probe(self, verb: str, target: str = "") -> None:
950
+ """探针:只读查询。输出进入观察流的 `probes` 通道;任何降级都必须可见。"""
951
+ target = (target or "").lstrip("#")
952
+ result = None
953
+ diags: List[Diagnostic] = []
954
+ if verb == "tree":
955
+ start = target or ROOT
956
+ if start not in self.program.nodes:
957
+ diags.append(Diagnostic("TARGET_MISSING", ERROR,
958
+ "探针目标 #%s 不存在" % start))
959
+ else:
960
+ result = self._tree(start)
961
+ elif verb == "get":
962
+ node = self.program.nodes.get(target)
963
+ if node is None:
964
+ diags.append(Diagnostic("TARGET_MISSING", ERROR,
965
+ "探针目标 #%s 不存在" % target))
966
+ else:
967
+ result = {"id": "#" + node.id, "type": node.type,
968
+ "attrs": sorted(node.attrs), "children": list(node.children)}
969
+ elif verb == "where":
970
+ if target not in self.program.nodes:
971
+ diags.append(Diagnostic("TARGET_MISSING", ERROR,
972
+ "探针目标 #%s 不存在" % target))
973
+ else:
974
+ rect = self.render_geometry.get("#" + target)
975
+ if rect is not None:
976
+ result = {"id": "#" + target, "geometry": rect}
977
+ else:
978
+ diags.append(Diagnostic(
979
+ "DEGRADED_FEATURE", INFO,
980
+ "几何反查不可用:当前环境未提供几何信息,请改用视觉自检",
981
+ feature="geometry"))
982
+ result = {"id": "#" + target, "geometry": None}
983
+ self.probes.append({"verb": verb,
984
+ "target": ("#" + target) if target else "",
985
+ "result": result})
986
+ self.queue(diags)
987
+
988
+ def snapshot(self) -> dict:
989
+ """视觉快照(可选观察面,仅供驱动者自检)。
990
+
991
+ 参考语义引擎不含渲染器 → 必须**可见降级**,返回空图而非假装成功。
992
+ """
993
+ with self.lock:
994
+ self.queue([Diagnostic("DEGRADED_FEATURE", INFO,
995
+ "截图不可用:参考语义引擎不含渲染器",
996
+ feature="snapshot")])
997
+ return {"image": None, "format": "png"}
998
+
999
+ def deliver_interaction(self, target: str, action: str, value=None) -> dict:
1000
+ """把"用户动作"交给渲染层投递为事件(规范 05 第 4 节)。
1001
+
1002
+ 默认实现**没有渲染层**:做不到就**必须可见降级**,禁止假装送达。
1003
+ 真正把用户动作转成事件的职责在渲染器(见 `puppet/tk_adapter.py`)。
1004
+ """
1005
+ with self.lock:
1006
+ nid = (target or "").lstrip("#")
1007
+ if nid not in self.program.nodes:
1008
+ return {"delivered": False,
1009
+ "diagnostics": [Diagnostic(
1010
+ "TARGET_MISSING", ERROR,
1011
+ "节点 #%s 不存在" % nid).to_dict()]}
1012
+ self.queue([Diagnostic(
1013
+ "DEGRADED_FEATURE", INFO,
1014
+ "本实现没有渲染层,用户动作无法投递为事件(action=%s)" % action,
1015
+ feature="interaction")])
1016
+ return {"delivered": False}
1017
+
1018
+ def probe(self, verb: str, target: str = "") -> dict:
1019
+ with self.lock:
1020
+ if verb == "tree":
1021
+ start = target or ROOT
1022
+ return {"tree": self._tree(start)}
1023
+ if verb == "get":
1024
+ node = self.program.nodes.get(target)
1025
+ if node is None:
1026
+ return {"get": None}
1027
+ return {"get": {"id": "#" + target, "type": node.type,
1028
+ "attrs": [k for k in node.attrs]}}
1029
+ if verb == "where":
1030
+ return {"where": {"id": "#" + target, "geometry": None}}
1031
+ return {}
1032
+
1033
+ def _tree(self, root_id: str) -> dict:
1034
+ node = self.program.nodes.get(root_id)
1035
+ if node is None:
1036
+ return {}
1037
+ return {"id": "#" + node.id, "type": node.type,
1038
+ "children": [self._tree(c) for c in node.children if c in self.program.nodes]}