termwright 0.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.
- termwright/__init__.py +125 -0
- termwright/client.py +708 -0
- termwright/debug.py +169 -0
- termwright/diffing.py +118 -0
- termwright/errors.py +20 -0
- termwright/framing.py +196 -0
- termwright/limits.py +82 -0
- termwright/logging_bridge.py +108 -0
- termwright/logs.py +207 -0
- termwright/marker.py +128 -0
- termwright/messages.py +417 -0
- termwright/roles.py +57 -0
- termwright/textual.py +249 -0
- termwright/tree.py +282 -0
- termwright/validate.py +846 -0
- termwright-0.2.0.dist-info/METADATA +336 -0
- termwright-0.2.0.dist-info/RECORD +25 -0
- termwright-0.2.0.dist-info/WHEEL +4 -0
- termwright_probe/__init__.py +73 -0
- termwright_probe/__main__.py +45 -0
- termwright_probe/bootstrap.py +207 -0
- termwright_probe/defer.py +130 -0
- termwright_probe/session.py +197 -0
- termwright_probe/textual_probe.py +239 -0
- termwright_probe/textual_tree.py +634 -0
termwright/validate.py
ADDED
|
@@ -0,0 +1,846 @@
|
|
|
1
|
+
"""Snapshot validation.
|
|
2
|
+
|
|
3
|
+
A structural port of ``validate.ts``: same invariants, same error codes, same
|
|
4
|
+
order of checks, so a snapshot rejected here is rejected by the driver and vice
|
|
5
|
+
versa. Never raises on hostile input — failures come back as a result object.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
import math
|
|
12
|
+
from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
|
|
13
|
+
|
|
14
|
+
from .errors import ProtocolViolation
|
|
15
|
+
from .framing import encode_json, project_dto
|
|
16
|
+
from .limits import DEFAULT_LIMITS, ProtocolLimits
|
|
17
|
+
from .roles import ACTION_SET, ROLE_SET
|
|
18
|
+
|
|
19
|
+
VALIDATION_ERROR_CODES = (
|
|
20
|
+
"schema",
|
|
21
|
+
"unknown-role",
|
|
22
|
+
"duplicate-id",
|
|
23
|
+
"missing-parent",
|
|
24
|
+
"cycle",
|
|
25
|
+
"depth",
|
|
26
|
+
"count",
|
|
27
|
+
"string-bytes",
|
|
28
|
+
"bad-rect",
|
|
29
|
+
"revision",
|
|
30
|
+
"bytes",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
_MAX_SAFE_INTEGER = 2**53 - 1
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ValidationResult:
|
|
38
|
+
"""Outcome of :func:`validate_snapshot`."""
|
|
39
|
+
|
|
40
|
+
ok: bool
|
|
41
|
+
snapshot: Optional[Dict[str, Any]] = None
|
|
42
|
+
code: Optional[str] = None
|
|
43
|
+
detail: str = ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _fail(code: str, detail: str) -> ValidationResult:
|
|
47
|
+
return ValidationResult(ok=False, code=code, detail=detail)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _Issue(Exception):
|
|
51
|
+
"""A schema-level defect, carrying the path zod would have reported."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, path: Sequence[str], message: str, too_big: bool = False) -> None:
|
|
54
|
+
super().__init__(message)
|
|
55
|
+
self.path: Tuple[str, ...] = tuple(path)
|
|
56
|
+
self.message = message
|
|
57
|
+
self.too_big = too_big
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def code(self) -> str:
|
|
61
|
+
if "role" in self.path:
|
|
62
|
+
return "unknown-role"
|
|
63
|
+
if "revision" in self.path:
|
|
64
|
+
return "revision"
|
|
65
|
+
if "bounds" in self.path or "rect" in self.path:
|
|
66
|
+
return "bad-rect"
|
|
67
|
+
if self.too_big and ("nodes" in self.path or "rootIds" in self.path):
|
|
68
|
+
return "count"
|
|
69
|
+
if "UTF-8 bytes" in self.message:
|
|
70
|
+
return "string-bytes"
|
|
71
|
+
return "schema"
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def detail(self) -> str:
|
|
75
|
+
where = ".".join(self.path) if self.path else "<root>"
|
|
76
|
+
return f"{where}: {self.message}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# --------------------------------------------------------------------------
|
|
80
|
+
# Schema layer — mirrors the zod schema, field for field.
|
|
81
|
+
# --------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _obj(value: Any, path: Sequence[str]) -> Mapping[str, Any]:
|
|
85
|
+
if not isinstance(value, dict):
|
|
86
|
+
raise _Issue(path, "expected an object")
|
|
87
|
+
return value
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _strict(value: Mapping[str, Any], allowed: Sequence[str], path: Sequence[str]) -> None:
|
|
91
|
+
unknown = [key for key in value if key not in allowed]
|
|
92
|
+
if unknown:
|
|
93
|
+
raise _Issue(path, f"Unrecognized key(s) in object: {', '.join(repr(k) for k in unknown)}")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _safe_int(value: Any, path: Sequence[str]) -> int:
|
|
97
|
+
if isinstance(value, bool) or not isinstance(value, int) or abs(value) > _MAX_SAFE_INTEGER:
|
|
98
|
+
raise _Issue(path, "expected a safe integer")
|
|
99
|
+
return value
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _non_negative_int(value: Any, path: Sequence[str]) -> int:
|
|
103
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0 or value > _MAX_SAFE_INTEGER:
|
|
104
|
+
raise _Issue(path, "expected a non-negative safe integer")
|
|
105
|
+
return value
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _positive_int(value: Any, path: Sequence[str]) -> int:
|
|
109
|
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0 or value > _MAX_SAFE_INTEGER:
|
|
110
|
+
raise _Issue(path, "expected a positive safe integer")
|
|
111
|
+
return value
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _text(value: Any, path: Sequence[str], limits: ProtocolLimits) -> str:
|
|
115
|
+
if not isinstance(value, str):
|
|
116
|
+
raise _Issue(path, "expected a string")
|
|
117
|
+
if len(value.encode("utf-8", "surrogatepass")) > limits.maxStringBytes:
|
|
118
|
+
raise _Issue(path, f"expected at most {limits.maxStringBytes} UTF-8 bytes")
|
|
119
|
+
return value
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _bool(value: Any, path: Sequence[str]) -> bool:
|
|
123
|
+
if not isinstance(value, bool):
|
|
124
|
+
raise _Issue(path, "expected a boolean")
|
|
125
|
+
return value
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _rect(value: Any, path: Sequence[str]) -> Mapping[str, int]:
|
|
129
|
+
rect = _obj(value, path)
|
|
130
|
+
_strict(rect, ("row", "column", "width", "height"), path)
|
|
131
|
+
for key in ("row", "column"):
|
|
132
|
+
if key not in rect:
|
|
133
|
+
raise _Issue(tuple(path) + (key,), "expected a safe integer")
|
|
134
|
+
_safe_int(rect[key], tuple(path) + (key,))
|
|
135
|
+
for key in ("width", "height"):
|
|
136
|
+
if key not in rect:
|
|
137
|
+
raise _Issue(tuple(path) + (key,), "expected a non-negative safe integer")
|
|
138
|
+
_non_negative_int(rect[key], tuple(path) + (key,))
|
|
139
|
+
return rect
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
_STATE_BOOLS = (
|
|
143
|
+
"disabled",
|
|
144
|
+
"focused",
|
|
145
|
+
"selected",
|
|
146
|
+
"expanded",
|
|
147
|
+
"modal",
|
|
148
|
+
"busy",
|
|
149
|
+
"hidden",
|
|
150
|
+
"offscreen",
|
|
151
|
+
"readonly",
|
|
152
|
+
"multiline",
|
|
153
|
+
)
|
|
154
|
+
_STATE_KEYS = _STATE_BOOLS + (
|
|
155
|
+
"checked",
|
|
156
|
+
"orientation",
|
|
157
|
+
"level",
|
|
158
|
+
"positionInSet",
|
|
159
|
+
"setSize",
|
|
160
|
+
"scrollOffset",
|
|
161
|
+
"scrollExtent",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _state(value: Any, path: Sequence[str]) -> None:
|
|
166
|
+
state = _obj(value, path)
|
|
167
|
+
_strict(state, _STATE_KEYS, path)
|
|
168
|
+
for key in _STATE_BOOLS:
|
|
169
|
+
if key in state:
|
|
170
|
+
_bool(state[key], tuple(path) + (key,))
|
|
171
|
+
if "checked" in state and not (isinstance(state["checked"], bool) or state["checked"] == "mixed"):
|
|
172
|
+
raise _Issue(tuple(path) + ("checked",), "expected a boolean or 'mixed'")
|
|
173
|
+
if "orientation" in state and state["orientation"] not in ("horizontal", "vertical"):
|
|
174
|
+
raise _Issue(tuple(path) + ("orientation",), "expected 'horizontal' or 'vertical'")
|
|
175
|
+
for key in ("level", "positionInSet"):
|
|
176
|
+
if key in state:
|
|
177
|
+
_positive_int(state[key], tuple(path) + (key,))
|
|
178
|
+
for key in ("setSize", "scrollOffset", "scrollExtent"):
|
|
179
|
+
if key in state:
|
|
180
|
+
_non_negative_int(state[key], tuple(path) + (key,))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
_NODE_KEYS = (
|
|
184
|
+
"id",
|
|
185
|
+
"parentId",
|
|
186
|
+
"role",
|
|
187
|
+
"name",
|
|
188
|
+
"description",
|
|
189
|
+
"value",
|
|
190
|
+
"bounds",
|
|
191
|
+
"state",
|
|
192
|
+
"extended",
|
|
193
|
+
"actions",
|
|
194
|
+
"labelledBy",
|
|
195
|
+
"describedBy",
|
|
196
|
+
"textRanges",
|
|
197
|
+
"testId",
|
|
198
|
+
"frameworkType",
|
|
199
|
+
"occlusion",
|
|
200
|
+
"p",
|
|
201
|
+
"px",
|
|
202
|
+
)
|
|
203
|
+
_NODE_V2_KEYS = tuple(key for key in _NODE_KEYS if key not in ("bounds", "occlusion")) + ("geometry",)
|
|
204
|
+
|
|
205
|
+
_OBSERVATION_EVIDENCE = ("adapter", "probe", "terminal-grid", "viewport-clip", "paint-order", "hit-grid", "legacy-v1")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _observation(value: Any, path: Sequence[str], known, limits: ProtocolLimits) -> None:
|
|
209
|
+
item = _obj(value, path)
|
|
210
|
+
status = item.get("status")
|
|
211
|
+
if status == "known":
|
|
212
|
+
_strict(item, ("status", "value", "evidence"), path)
|
|
213
|
+
if "value" not in item or item.get("evidence") not in _OBSERVATION_EVIDENCE:
|
|
214
|
+
raise _Issue(path, "known observation requires value and evidence")
|
|
215
|
+
known(item["value"], tuple(path) + ("value",))
|
|
216
|
+
elif status == "absent":
|
|
217
|
+
_strict(item, ("status", "reason"), path)
|
|
218
|
+
if item.get("reason") not in ("detached", "not-displayed", "not-laid-out"):
|
|
219
|
+
raise _Issue(tuple(path) + ("reason",), "invalid absent reason")
|
|
220
|
+
elif status == "unknown":
|
|
221
|
+
_strict(item, ("status", "reason"), path)
|
|
222
|
+
if item.get("reason") not in ("not-reported", "temporary", "clip-unobservable", "legacy-unqualified"):
|
|
223
|
+
raise _Issue(tuple(path) + ("reason",), "invalid unknown reason")
|
|
224
|
+
elif status == "unsupported":
|
|
225
|
+
_strict(item, ("status", "capability", "reason"), path)
|
|
226
|
+
_text(item.get("capability"), tuple(path) + ("capability",), limits)
|
|
227
|
+
if item.get("reason") not in ("capability", "framework-unobservable", "not-negotiated"):
|
|
228
|
+
raise _Issue(tuple(path) + ("reason",), "invalid unsupported reason")
|
|
229
|
+
else:
|
|
230
|
+
raise _Issue(tuple(path) + ("status",), "invalid observation status")
|
|
231
|
+
|
|
232
|
+
#: Where a semantic fact came from. Closed set, so an unknown source is a
|
|
233
|
+
#: rejection rather than a silently ignored annotation.
|
|
234
|
+
PROVENANCE_SOURCES = (
|
|
235
|
+
"annotation",
|
|
236
|
+
"recognizer",
|
|
237
|
+
"framework",
|
|
238
|
+
"correlation",
|
|
239
|
+
"heuristic",
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
#: Whether the producer can say if a node's cells are covered. A producer that
|
|
243
|
+
#: cannot see paint order says `unknown`, and the driver refuses to click it.
|
|
244
|
+
OCCLUSION_VALUES = ("known", "unknown")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _extended(value: Any, path: Sequence[str], limits: ProtocolLimits) -> None:
|
|
248
|
+
if value is None or isinstance(value, bool):
|
|
249
|
+
return
|
|
250
|
+
if isinstance(value, str):
|
|
251
|
+
_text(value, path, limits)
|
|
252
|
+
return
|
|
253
|
+
if isinstance(value, (int, float)):
|
|
254
|
+
if not math.isfinite(value) or abs(value) > _MAX_SAFE_INTEGER:
|
|
255
|
+
raise _Issue(path, "expected a finite JSON number in the safe range")
|
|
256
|
+
return
|
|
257
|
+
if isinstance(value, list):
|
|
258
|
+
if len(value) > limits.maxRelationTargets:
|
|
259
|
+
raise _Issue(path, f"expected at most {limits.maxRelationTargets} items", too_big=True)
|
|
260
|
+
for index, item in enumerate(value):
|
|
261
|
+
_extended(item, tuple(path) + (str(index),), limits)
|
|
262
|
+
return
|
|
263
|
+
if isinstance(value, dict):
|
|
264
|
+
if len(value) > limits.maxRelationTargets:
|
|
265
|
+
raise _Issue(path, f"expected at most {limits.maxRelationTargets} properties", too_big=True)
|
|
266
|
+
for key, item in value.items():
|
|
267
|
+
_text(key, tuple(path) + (str(key),), limits)
|
|
268
|
+
_extended(item, tuple(path) + (str(key),), limits)
|
|
269
|
+
return
|
|
270
|
+
raise _Issue(path, "expected JSON scalar, array or object")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _relations(value: Any, path: Sequence[str], limits: ProtocolLimits) -> None:
|
|
274
|
+
if not isinstance(value, list):
|
|
275
|
+
raise _Issue(path, "expected an array")
|
|
276
|
+
if len(value) > limits.maxRelationTargets:
|
|
277
|
+
raise _Issue(path, f"expected at most {limits.maxRelationTargets} items", too_big=True)
|
|
278
|
+
for index, item in enumerate(value):
|
|
279
|
+
_text(item, tuple(path) + (str(index),), limits)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _node_schema(value: Any, path: Sequence[str], limits: ProtocolLimits, v: int = 1) -> None:
|
|
283
|
+
node = _obj(value, path)
|
|
284
|
+
if v == 2 and "bounds" in node:
|
|
285
|
+
raise _Issue(tuple(path) + ("bounds",), "legacy bounds are forbidden in v2")
|
|
286
|
+
_strict(node, _NODE_V2_KEYS if v == 2 else _NODE_KEYS, path)
|
|
287
|
+
|
|
288
|
+
if "id" not in node:
|
|
289
|
+
raise _Issue(tuple(path) + ("id",), "expected a string")
|
|
290
|
+
if _text(node["id"], tuple(path) + ("id",), limits) == "":
|
|
291
|
+
raise _Issue(tuple(path) + ("id",), "node id must not be empty")
|
|
292
|
+
if "parentId" in node:
|
|
293
|
+
_text(node["parentId"], tuple(path) + ("parentId",), limits)
|
|
294
|
+
if node.get("role") not in ROLE_SET:
|
|
295
|
+
raise _Issue(tuple(path) + ("role",), "expected one of the v1 semantic roles")
|
|
296
|
+
if "name" not in node:
|
|
297
|
+
raise _Issue(tuple(path) + ("name",), "expected a string")
|
|
298
|
+
_text(node["name"], tuple(path) + ("name",), limits)
|
|
299
|
+
for key in ("description", "value", "testId", "frameworkType"):
|
|
300
|
+
if key in node:
|
|
301
|
+
_text(node[key], tuple(path) + (key,), limits)
|
|
302
|
+
if "occlusion" in node and node["occlusion"] not in OCCLUSION_VALUES:
|
|
303
|
+
raise _Issue(tuple(path) + ("occlusion",), "expected 'known' or 'unknown'")
|
|
304
|
+
if "p" in node and node["p"] not in PROVENANCE_SOURCES:
|
|
305
|
+
raise _Issue(tuple(path) + ("p",), "expected one of the provenance sources")
|
|
306
|
+
if "px" in node:
|
|
307
|
+
per_field = node["px"]
|
|
308
|
+
if not isinstance(per_field, dict):
|
|
309
|
+
raise _Issue(tuple(path) + ("px",), "expected an object")
|
|
310
|
+
for field, source in per_field.items():
|
|
311
|
+
_text(field, tuple(path) + ("px", str(field)), limits)
|
|
312
|
+
if source not in PROVENANCE_SOURCES:
|
|
313
|
+
raise _Issue(
|
|
314
|
+
tuple(path) + ("px", str(field)),
|
|
315
|
+
"expected one of the provenance sources",
|
|
316
|
+
)
|
|
317
|
+
if node.get("role") == "generic" and not node.get("frameworkType"):
|
|
318
|
+
# An unrecognised widget must at least name what the framework called
|
|
319
|
+
# it. An empty string carries no more than its absence, so both fail.
|
|
320
|
+
raise _Issue(
|
|
321
|
+
tuple(path) + ("frameworkType",),
|
|
322
|
+
f"node {node.get('id')} has role 'generic' without a frameworkType; "
|
|
323
|
+
"an unrecognised widget must name what the framework called it",
|
|
324
|
+
)
|
|
325
|
+
if "bounds" in node:
|
|
326
|
+
_rect(node["bounds"], tuple(path) + ("bounds",))
|
|
327
|
+
if v == 2:
|
|
328
|
+
geometry = _obj(node.get("geometry"), tuple(path) + ("geometry",))
|
|
329
|
+
_strict(geometry, ("displayed", "intendedRect", "visibleRect"), tuple(path) + ("geometry",))
|
|
330
|
+
_observation(geometry.get("displayed"), tuple(path) + ("geometry", "displayed"), _bool, limits)
|
|
331
|
+
_observation(geometry.get("intendedRect"), tuple(path) + ("geometry", "intendedRect"), _rect, limits)
|
|
332
|
+
_observation(geometry.get("visibleRect"), tuple(path) + ("geometry", "visibleRect"), _rect, limits)
|
|
333
|
+
if "state" in node:
|
|
334
|
+
_state(node["state"], tuple(path) + ("state",))
|
|
335
|
+
state = node["state"]
|
|
336
|
+
if isinstance(state, dict) and state.get("offscreen") is True and state.get("hidden") is not True:
|
|
337
|
+
# Every cell outside the visible area and the node still visible
|
|
338
|
+
# cannot both be true. Refusing the pair keeps `offscreen` a claim
|
|
339
|
+
# about scrolling rather than a second, weaker way of saying hidden.
|
|
340
|
+
raise _Issue(
|
|
341
|
+
tuple(path) + ("state", "offscreen"),
|
|
342
|
+
f"node {node.get('id')}: state.offscreen implies state.hidden — every cell is "
|
|
343
|
+
"outside the visible area, so the node cannot also be visible",
|
|
344
|
+
)
|
|
345
|
+
if "extended" in node:
|
|
346
|
+
if not isinstance(node["extended"], dict):
|
|
347
|
+
raise _Issue(tuple(path) + ("extended",), "expected an object")
|
|
348
|
+
_extended(node["extended"], tuple(path) + ("extended",), limits)
|
|
349
|
+
if "actions" in node:
|
|
350
|
+
actions = node["actions"]
|
|
351
|
+
if not isinstance(actions, list):
|
|
352
|
+
raise _Issue(tuple(path) + ("actions",), "expected an array")
|
|
353
|
+
if len(actions) > len(ACTION_SET):
|
|
354
|
+
raise _Issue(tuple(path) + ("actions",), "too many actions", too_big=True)
|
|
355
|
+
for index, action in enumerate(actions):
|
|
356
|
+
if action not in ACTION_SET:
|
|
357
|
+
raise _Issue(
|
|
358
|
+
tuple(path) + ("actions", str(index)), "expected one of the v1 semantic actions"
|
|
359
|
+
)
|
|
360
|
+
for key in ("labelledBy", "describedBy"):
|
|
361
|
+
if key in node:
|
|
362
|
+
_relations(node[key], tuple(path) + (key,), limits)
|
|
363
|
+
if "textRanges" in node:
|
|
364
|
+
ranges = node["textRanges"]
|
|
365
|
+
if not isinstance(ranges, list):
|
|
366
|
+
raise _Issue(tuple(path) + ("textRanges",), "expected an array")
|
|
367
|
+
if len(ranges) > limits.maxRelationTargets:
|
|
368
|
+
raise _Issue(tuple(path) + ("textRanges",), "too many text ranges", too_big=True)
|
|
369
|
+
for index, item in enumerate(ranges):
|
|
370
|
+
item_path = tuple(path) + ("textRanges", str(index))
|
|
371
|
+
entry = _obj(item, item_path)
|
|
372
|
+
_strict(entry, ("startOffset", "endOffset", "rect"), item_path)
|
|
373
|
+
for key in ("startOffset", "endOffset"):
|
|
374
|
+
if key not in entry:
|
|
375
|
+
raise _Issue(item_path + (key,), "expected a non-negative safe integer")
|
|
376
|
+
_non_negative_int(entry[key], item_path + (key,))
|
|
377
|
+
if "rect" not in entry:
|
|
378
|
+
raise _Issue(item_path + ("rect",), "expected an object")
|
|
379
|
+
_rect(entry["rect"], item_path + ("rect",))
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _cursor(value: Any, path: Sequence[str]) -> None:
|
|
383
|
+
cursor = _obj(value, path)
|
|
384
|
+
_strict(cursor, ("row", "column", "visible", "shape"), path)
|
|
385
|
+
for key in ("row", "column"):
|
|
386
|
+
if key not in cursor:
|
|
387
|
+
raise _Issue(tuple(path) + (key,), "expected a non-negative safe integer")
|
|
388
|
+
_non_negative_int(cursor[key], tuple(path) + (key,))
|
|
389
|
+
if "visible" not in cursor:
|
|
390
|
+
raise _Issue(tuple(path) + ("visible",), "expected a boolean")
|
|
391
|
+
_bool(cursor["visible"], tuple(path) + ("visible",))
|
|
392
|
+
if "shape" in cursor and cursor["shape"] not in ("block", "underline", "bar"):
|
|
393
|
+
raise _Issue(tuple(path) + ("shape",), "expected 'block', 'underline' or 'bar'")
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
_SNAPSHOT_KEYS = ("v", "sessionId", "revision", "columns", "rows", "cursor", "rootIds", "nodes")
|
|
397
|
+
_SNAPSHOT_V2_KEYS = _SNAPSHOT_KEYS + ("coordinateSpace", "hitGrid")
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _snapshot_schema(value: Any, limits: ProtocolLimits) -> None:
|
|
401
|
+
snapshot = _obj(value, ())
|
|
402
|
+
version = snapshot.get("v")
|
|
403
|
+
_strict(snapshot, _SNAPSHOT_V2_KEYS if version == 2 else _SNAPSHOT_KEYS, ())
|
|
404
|
+
|
|
405
|
+
if version not in (1, 2):
|
|
406
|
+
raise _Issue(("v",), "expected the literal 1 or 2")
|
|
407
|
+
if "sessionId" not in snapshot:
|
|
408
|
+
raise _Issue(("sessionId",), "expected a string")
|
|
409
|
+
if _text(snapshot["sessionId"], ("sessionId",), limits) == "":
|
|
410
|
+
raise _Issue(("sessionId",), "sessionId must not be empty")
|
|
411
|
+
if "revision" not in snapshot:
|
|
412
|
+
raise _Issue(("revision",), "expected a positive safe integer")
|
|
413
|
+
_positive_int(snapshot["revision"], ("revision",))
|
|
414
|
+
for key in ("columns", "rows"):
|
|
415
|
+
if key not in snapshot:
|
|
416
|
+
raise _Issue((key,), "expected a positive safe integer")
|
|
417
|
+
_positive_int(snapshot[key], (key,))
|
|
418
|
+
if "cursor" in snapshot:
|
|
419
|
+
_cursor(snapshot["cursor"], ("cursor",))
|
|
420
|
+
|
|
421
|
+
root_ids = snapshot.get("rootIds")
|
|
422
|
+
if not isinstance(root_ids, list):
|
|
423
|
+
raise _Issue(("rootIds",), "expected an array")
|
|
424
|
+
if len(root_ids) > limits.maxNodes:
|
|
425
|
+
raise _Issue(("rootIds",), f"expected at most {limits.maxNodes} items", too_big=True)
|
|
426
|
+
for index, item in enumerate(root_ids):
|
|
427
|
+
_text(item, ("rootIds", str(index)), limits)
|
|
428
|
+
|
|
429
|
+
nodes = snapshot.get("nodes")
|
|
430
|
+
if not isinstance(nodes, list):
|
|
431
|
+
raise _Issue(("nodes",), "expected an array")
|
|
432
|
+
if len(nodes) > limits.maxNodes:
|
|
433
|
+
raise _Issue(("nodes",), f"expected at most {limits.maxNodes} items", too_big=True)
|
|
434
|
+
for index, node in enumerate(nodes):
|
|
435
|
+
_node_schema(node, ("nodes", str(index)), limits, version)
|
|
436
|
+
if version == 2:
|
|
437
|
+
_observation(snapshot.get("coordinateSpace"), ("coordinateSpace",), lambda value, path: value in ("viewport-cells", "framework-local-cells") or (_ for _ in ()).throw(_Issue(path, "invalid coordinate space")), limits)
|
|
438
|
+
def _grid(value: Any, path: Sequence[str]) -> None:
|
|
439
|
+
grid = _obj(value, path)
|
|
440
|
+
_strict(grid, ("regions",), path)
|
|
441
|
+
regions = grid.get("regions")
|
|
442
|
+
if not isinstance(regions, list) or len(regions) > limits.maxNodes:
|
|
443
|
+
raise _Issue(tuple(path) + ("regions",), "invalid hit regions")
|
|
444
|
+
previous = None
|
|
445
|
+
for index, region_value in enumerate(regions):
|
|
446
|
+
region_path = tuple(path) + ("regions", str(index))
|
|
447
|
+
region = _obj(region_value, region_path)
|
|
448
|
+
_strict(region, ("rect", "recipientId"), region_path)
|
|
449
|
+
rect = _rect(region.get("rect"), region_path + ("rect",))
|
|
450
|
+
if rect["width"] <= 0 or rect["height"] != 1:
|
|
451
|
+
raise _Issue(
|
|
452
|
+
region_path + ("rect",), "hit regions must be non-empty row runs"
|
|
453
|
+
)
|
|
454
|
+
if previous is not None and (
|
|
455
|
+
rect["row"] < previous["row"]
|
|
456
|
+
or (
|
|
457
|
+
rect["row"] == previous["row"]
|
|
458
|
+
and rect["column"] < previous["column"] + previous["width"]
|
|
459
|
+
)
|
|
460
|
+
):
|
|
461
|
+
raise _Issue(
|
|
462
|
+
region_path + ("rect",),
|
|
463
|
+
"hit regions must be non-overlapping row-major runs",
|
|
464
|
+
)
|
|
465
|
+
previous = rect
|
|
466
|
+
_text(region.get("recipientId"), region_path + ("recipientId",), limits)
|
|
467
|
+
_observation(snapshot.get("hitGrid"), ("hitGrid",), _grid, limits)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
# --------------------------------------------------------------------------
|
|
471
|
+
# Structural layer
|
|
472
|
+
# --------------------------------------------------------------------------
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _rect_intersects_viewport(rect: Mapping[str, int], columns: int, rows: int) -> bool:
|
|
476
|
+
if rect["width"] == 0 or rect["height"] == 0:
|
|
477
|
+
return False
|
|
478
|
+
return (
|
|
479
|
+
rect["column"] < columns
|
|
480
|
+
and rect["row"] < rows
|
|
481
|
+
and rect["column"] + rect["width"] > 0
|
|
482
|
+
and rect["row"] + rect["height"] > 0
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _check_node_shape(
|
|
487
|
+
node: Mapping[str, Any],
|
|
488
|
+
snapshot: Mapping[str, Any],
|
|
489
|
+
ids: Set[str],
|
|
490
|
+
limits: ProtocolLimits,
|
|
491
|
+
) -> Optional[ValidationResult]:
|
|
492
|
+
bounds = node.get("bounds")
|
|
493
|
+
if bounds is not None:
|
|
494
|
+
if (
|
|
495
|
+
abs(bounds["row"] + bounds["height"]) > _MAX_SAFE_INTEGER
|
|
496
|
+
or abs(bounds["column"] + bounds["width"]) > _MAX_SAFE_INTEGER
|
|
497
|
+
):
|
|
498
|
+
return _fail("bad-rect", f"node {node['id']}: bounds overflow the safe-integer range")
|
|
499
|
+
hidden = (node.get("state") or {}).get("hidden") is True
|
|
500
|
+
if not hidden and not _rect_intersects_viewport(bounds, snapshot["columns"], snapshot["rows"]):
|
|
501
|
+
return _fail(
|
|
502
|
+
"bad-rect",
|
|
503
|
+
f"node {node['id']}: bounds do not intersect the "
|
|
504
|
+
f"{snapshot['columns']}x{snapshot['rows']} viewport and the node is not hidden",
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
for text_range in node.get("textRanges") or []:
|
|
508
|
+
if text_range["endOffset"] < text_range["startOffset"]:
|
|
509
|
+
return _fail("bad-rect", f"node {node['id']}: text range ends before it starts")
|
|
510
|
+
rect = text_range["rect"]
|
|
511
|
+
if abs(rect["row"] + rect["height"]) > _MAX_SAFE_INTEGER:
|
|
512
|
+
return _fail(
|
|
513
|
+
"bad-rect", f"node {node['id']}: text range rect overflows the safe-integer range"
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
for field_name in ("labelledBy", "describedBy"):
|
|
517
|
+
targets = node.get(field_name)
|
|
518
|
+
if targets is None:
|
|
519
|
+
continue
|
|
520
|
+
if len(targets) > limits.maxRelationTargets:
|
|
521
|
+
return _fail(
|
|
522
|
+
"count", f"node {node['id']}: {field_name} exceeds {limits.maxRelationTargets} targets"
|
|
523
|
+
)
|
|
524
|
+
for target in targets:
|
|
525
|
+
if target not in ids:
|
|
526
|
+
return _fail(
|
|
527
|
+
"missing-parent",
|
|
528
|
+
f"node {node['id']}: {field_name} references unknown node {target}",
|
|
529
|
+
)
|
|
530
|
+
return None
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _compute_depths(
|
|
534
|
+
nodes: Sequence[Mapping[str, Any]], by_id: Mapping[str, Mapping[str, Any]]
|
|
535
|
+
) -> Tuple[Optional[Dict[str, int]], Optional[str]]:
|
|
536
|
+
"""Depth of every node (roots at 1), or the id where a parent chain closes."""
|
|
537
|
+
depths: Dict[str, int] = {}
|
|
538
|
+
for start in nodes:
|
|
539
|
+
if start["id"] in depths:
|
|
540
|
+
continue
|
|
541
|
+
chain: List[str] = []
|
|
542
|
+
on_chain: Set[str] = set()
|
|
543
|
+
current: Optional[Mapping[str, Any]] = start
|
|
544
|
+
while current is not None and current["id"] not in depths:
|
|
545
|
+
if current["id"] in on_chain:
|
|
546
|
+
return None, current["id"]
|
|
547
|
+
on_chain.add(current["id"])
|
|
548
|
+
chain.append(current["id"])
|
|
549
|
+
parent_id = current.get("parentId")
|
|
550
|
+
current = None if parent_id is None else by_id.get(parent_id)
|
|
551
|
+
depth = 0 if current is None else depths[current["id"]]
|
|
552
|
+
for node_id in reversed(chain):
|
|
553
|
+
depth += 1
|
|
554
|
+
depths[node_id] = depth
|
|
555
|
+
return depths, None
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
DELTA_KEYS = ("type", "baseRevision", "revision", "changed", "removed", "rootIds", "cursor")
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def validate_tree_delta(value: Any, limits: ProtocolLimits = DEFAULT_LIMITS) -> ValidationResult:
|
|
562
|
+
"""Validate the SHAPE of a `tree-delta` message.
|
|
563
|
+
|
|
564
|
+
Only the shape is checkable here. A delta carries no ``columns``/``rows``,
|
|
565
|
+
so whether a parent exists, whether the tree stays acyclic and within the
|
|
566
|
+
depth ceiling, and whether bounds or the cursor fall inside the viewport
|
|
567
|
+
can only be judged once the delta is applied to its base — put the
|
|
568
|
+
assembled tree through :func:`validate_snapshot` for that.
|
|
569
|
+
|
|
570
|
+
What is checkable without the base: sizes, node shape, unique ids, a
|
|
571
|
+
revision that moves forward, and the same id never both upserted and
|
|
572
|
+
removed by one delta.
|
|
573
|
+
"""
|
|
574
|
+
try:
|
|
575
|
+
projected = project_dto(value, limits.maxDepth)
|
|
576
|
+
except ProtocolViolation as error:
|
|
577
|
+
return _fail("depth" if error.code == "dto-depth" else "schema", str(error))
|
|
578
|
+
|
|
579
|
+
try:
|
|
580
|
+
serialised = encode_json(projected)
|
|
581
|
+
except ProtocolViolation:
|
|
582
|
+
return _fail("schema", "delta is not JSON-serialisable")
|
|
583
|
+
if len(serialised) > limits.maxSnapshotBytes:
|
|
584
|
+
return _fail(
|
|
585
|
+
"bytes", f"delta is {len(serialised)} bytes, ceiling is {limits.maxSnapshotBytes}"
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
try:
|
|
589
|
+
_tree_delta_schema(projected, limits)
|
|
590
|
+
except _Issue as issue:
|
|
591
|
+
return _fail(issue.code, issue.detail)
|
|
592
|
+
|
|
593
|
+
delta: Dict[str, Any] = projected
|
|
594
|
+
changed_ids: Set[str] = set()
|
|
595
|
+
for index, node in enumerate(delta["changed"]):
|
|
596
|
+
if node["id"] in changed_ids:
|
|
597
|
+
return _fail("duplicate-id", f"node id {node['id']} appears twice in changed")
|
|
598
|
+
changed_ids.add(node["id"])
|
|
599
|
+
if node.get("parentId") == node["id"]:
|
|
600
|
+
return _fail("cycle", f"node {node['id']} is its own parent")
|
|
601
|
+
_ = index
|
|
602
|
+
|
|
603
|
+
removed_ids: Set[str] = set()
|
|
604
|
+
for node_id in delta["removed"]:
|
|
605
|
+
if node_id in removed_ids:
|
|
606
|
+
return _fail("duplicate-id", f"node id {node_id} appears twice in removed")
|
|
607
|
+
removed_ids.add(node_id)
|
|
608
|
+
|
|
609
|
+
both = changed_ids & removed_ids
|
|
610
|
+
if both:
|
|
611
|
+
# Removals apply before upserts, so this would be a delta arguing with
|
|
612
|
+
# itself about one id rather than moving a node between parents.
|
|
613
|
+
return _fail(
|
|
614
|
+
"schema", f"node id {sorted(both)[0]} is both changed and removed by one delta"
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
if "rootIds" in delta:
|
|
618
|
+
seen: Set[str] = set()
|
|
619
|
+
for node_id in delta["rootIds"]:
|
|
620
|
+
if node_id in seen:
|
|
621
|
+
return _fail("duplicate-id", f"root id {node_id} appears more than once")
|
|
622
|
+
seen.add(node_id)
|
|
623
|
+
|
|
624
|
+
return ValidationResult(ok=True, snapshot=delta)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _tree_delta_schema(value: Any, limits: ProtocolLimits) -> None:
|
|
628
|
+
delta = _obj(value, ())
|
|
629
|
+
_strict(delta, DELTA_KEYS, ())
|
|
630
|
+
|
|
631
|
+
base = _positive_int(delta.get("baseRevision"), ("baseRevision",))
|
|
632
|
+
revision = _positive_int(delta.get("revision"), ("revision",))
|
|
633
|
+
if revision <= base:
|
|
634
|
+
raise _Issue(
|
|
635
|
+
("revision",), f"revision {revision} must move forward from base {base}"
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
changed = delta.get("changed")
|
|
639
|
+
if not isinstance(changed, list):
|
|
640
|
+
raise _Issue(("changed",), "expected an array")
|
|
641
|
+
if len(changed) > limits.maxNodes:
|
|
642
|
+
raise _Issue(("changed",), f"expected at most {limits.maxNodes} items", too_big=True)
|
|
643
|
+
for index, node in enumerate(changed):
|
|
644
|
+
_node_schema(node, ("changed", str(index)), limits)
|
|
645
|
+
|
|
646
|
+
removed = delta.get("removed")
|
|
647
|
+
if not isinstance(removed, list):
|
|
648
|
+
raise _Issue(("removed",), "expected an array")
|
|
649
|
+
if len(removed) > limits.maxNodes:
|
|
650
|
+
raise _Issue(("removed",), f"expected at most {limits.maxNodes} items", too_big=True)
|
|
651
|
+
for index, node_id in enumerate(removed):
|
|
652
|
+
if _text(node_id, ("removed", str(index)), limits) == "":
|
|
653
|
+
raise _Issue(("removed", str(index)), "node id must not be empty")
|
|
654
|
+
|
|
655
|
+
if "rootIds" in delta:
|
|
656
|
+
root_ids = delta["rootIds"]
|
|
657
|
+
if not isinstance(root_ids, list):
|
|
658
|
+
raise _Issue(("rootIds",), "expected an array")
|
|
659
|
+
if len(root_ids) > limits.maxNodes:
|
|
660
|
+
raise _Issue(("rootIds",), f"expected at most {limits.maxNodes} items", too_big=True)
|
|
661
|
+
for index, node_id in enumerate(root_ids):
|
|
662
|
+
_text(node_id, ("rootIds", str(index)), limits)
|
|
663
|
+
|
|
664
|
+
if "cursor" in delta:
|
|
665
|
+
_cursor(delta["cursor"], ("cursor",))
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def validate_snapshot(value: Any, limits: ProtocolLimits = DEFAULT_LIMITS) -> ValidationResult:
|
|
669
|
+
"""Validate an untrusted snapshot against ``limits``.
|
|
670
|
+
|
|
671
|
+
:returns: ``ValidationResult(ok=True, snapshot=...)`` with a projected plain
|
|
672
|
+
copy, or ``ValidationResult(ok=False, code=..., detail=...)``. Never raises.
|
|
673
|
+
"""
|
|
674
|
+
try:
|
|
675
|
+
projected = project_dto(value, limits.maxDepth)
|
|
676
|
+
except ProtocolViolation as error:
|
|
677
|
+
return _fail("depth" if error.code == "dto-depth" else "schema", str(error))
|
|
678
|
+
|
|
679
|
+
try:
|
|
680
|
+
serialised = encode_json(projected)
|
|
681
|
+
except ProtocolViolation:
|
|
682
|
+
return _fail("schema", "snapshot is not JSON-serialisable")
|
|
683
|
+
if len(serialised) > limits.maxSnapshotBytes:
|
|
684
|
+
return _fail(
|
|
685
|
+
"bytes", f"snapshot is {len(serialised)} bytes, ceiling is {limits.maxSnapshotBytes}"
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
try:
|
|
689
|
+
_snapshot_schema(projected, limits)
|
|
690
|
+
except _Issue as issue:
|
|
691
|
+
return _fail(issue.code, issue.detail)
|
|
692
|
+
|
|
693
|
+
snapshot: Dict[str, Any] = projected
|
|
694
|
+
nodes: List[Dict[str, Any]] = snapshot["nodes"]
|
|
695
|
+
|
|
696
|
+
if len(nodes) > limits.maxNodes:
|
|
697
|
+
return _fail("count", f"snapshot carries {len(nodes)} nodes, ceiling is {limits.maxNodes}")
|
|
698
|
+
|
|
699
|
+
by_id: Dict[str, Dict[str, Any]] = {}
|
|
700
|
+
for node in nodes:
|
|
701
|
+
if node["id"] in by_id:
|
|
702
|
+
return _fail("duplicate-id", f"node id {node['id']} appears more than once")
|
|
703
|
+
by_id[node["id"]] = node
|
|
704
|
+
|
|
705
|
+
root_ids: Set[str] = set()
|
|
706
|
+
for node_id in snapshot["rootIds"]:
|
|
707
|
+
if node_id in root_ids:
|
|
708
|
+
return _fail("duplicate-id", f"root id {node_id} appears more than once")
|
|
709
|
+
root_ids.add(node_id)
|
|
710
|
+
node = by_id.get(node_id)
|
|
711
|
+
if node is None:
|
|
712
|
+
return _fail("missing-parent", f"rootIds references unknown node {node_id}")
|
|
713
|
+
if node.get("parentId") is not None:
|
|
714
|
+
return _fail("schema", f"root node {node_id} declares a parent")
|
|
715
|
+
|
|
716
|
+
ids = set(by_id)
|
|
717
|
+
|
|
718
|
+
if snapshot["v"] == 2 and snapshot["hitGrid"]["status"] == "known":
|
|
719
|
+
for region in snapshot["hitGrid"]["value"]["regions"]:
|
|
720
|
+
recipient_id = region["recipientId"]
|
|
721
|
+
if recipient_id not in ids:
|
|
722
|
+
return _fail(
|
|
723
|
+
"missing-parent", f"hitGrid references unknown recipient {recipient_id}"
|
|
724
|
+
)
|
|
725
|
+
if not _rect_intersects_viewport(
|
|
726
|
+
region["rect"], snapshot["columns"], snapshot["rows"]
|
|
727
|
+
):
|
|
728
|
+
return _fail(
|
|
729
|
+
"bad-rect",
|
|
730
|
+
f"hitGrid region for {recipient_id} does not intersect the viewport",
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
for node in nodes:
|
|
734
|
+
parent_id = node.get("parentId")
|
|
735
|
+
if parent_id is None:
|
|
736
|
+
if node["id"] not in root_ids:
|
|
737
|
+
return _fail("schema", f"parentless node {node['id']} is missing from rootIds")
|
|
738
|
+
elif parent_id not in by_id:
|
|
739
|
+
return _fail("missing-parent", f"node {node['id']} references unknown parent {parent_id}")
|
|
740
|
+
elif parent_id == node["id"]:
|
|
741
|
+
return _fail("cycle", f"node {node['id']} is its own parent")
|
|
742
|
+
|
|
743
|
+
problem = _check_node_shape(node, snapshot, ids, limits)
|
|
744
|
+
if problem is not None:
|
|
745
|
+
return problem
|
|
746
|
+
|
|
747
|
+
depths, cycle_at = _compute_depths(nodes, by_id)
|
|
748
|
+
if cycle_at is not None:
|
|
749
|
+
return _fail("cycle", f"parent chain through node {cycle_at} is cyclic")
|
|
750
|
+
assert depths is not None
|
|
751
|
+
for node_id, depth in depths.items():
|
|
752
|
+
if depth > limits.maxDepth:
|
|
753
|
+
return _fail("depth", f"node {node_id} sits at depth {depth}, ceiling is {limits.maxDepth}")
|
|
754
|
+
|
|
755
|
+
cursor = snapshot.get("cursor")
|
|
756
|
+
if cursor is not None:
|
|
757
|
+
if cursor["row"] >= snapshot["rows"] or cursor["column"] >= snapshot["columns"]:
|
|
758
|
+
return _fail(
|
|
759
|
+
"bad-rect", f"cursor ({cursor['row']}, {cursor['column']}) lies outside the viewport"
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
return ValidationResult(ok=True, snapshot=snapshot)
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def apply_tree_delta(
|
|
766
|
+
base: Mapping[str, Any], delta: Mapping[str, Any], limits: ProtocolLimits = DEFAULT_LIMITS
|
|
767
|
+
) -> ValidationResult:
|
|
768
|
+
"""Compose a delta onto the snapshot it names, then validate the result.
|
|
769
|
+
|
|
770
|
+
The four composition rules, in the order they are applied:
|
|
771
|
+
|
|
772
|
+
1. ``removed`` takes each id **with its whole subtree**. The cascade is
|
|
773
|
+
what keeps a delta small — dropping a dialog is one id, not one per
|
|
774
|
+
descendant — and it is the only rule that leaves no orphans behind.
|
|
775
|
+
2. Removals happen **before** upserts, so one delta can move a node out of
|
|
776
|
+
a subtree it is deleting.
|
|
777
|
+
3. ``changed`` upserts by id, **replacing a node wholesale**. Merging would
|
|
778
|
+
need a third state meaning "clear this optional field", which the wire
|
|
779
|
+
cannot express.
|
|
780
|
+
4. ``rootIds`` present replaces the list; absent inherits the base's minus
|
|
781
|
+
whatever the removals took. Adding a new root therefore *requires*
|
|
782
|
+
sending ``rootIds`` — otherwise the parentless node is missing from the
|
|
783
|
+
root list and validation says so, loudly.
|
|
784
|
+
|
|
785
|
+
An absent ``cursor`` is inherited; there is no way to remove one, and none
|
|
786
|
+
is needed, because hiding it is ``visible: false``.
|
|
787
|
+
|
|
788
|
+
A base that disagrees is reported rather than patched around: the caller
|
|
789
|
+
asks for a full snapshot instead of guessing (§8.3).
|
|
790
|
+
|
|
791
|
+
The composed tree then goes through :func:`validate_snapshot`, because a
|
|
792
|
+
delta is trusted to *describe* a valid tree, never to produce one.
|
|
793
|
+
"""
|
|
794
|
+
if delta.get("baseRevision") != base.get("revision"):
|
|
795
|
+
return _fail(
|
|
796
|
+
"revision",
|
|
797
|
+
f"delta is based on revision {delta.get('baseRevision')} but the held snapshot "
|
|
798
|
+
f"is revision {base.get('revision')}; request a full snapshot instead of patching",
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
by_id: Dict[str, Any] = {node["id"]: node for node in base["nodes"]}
|
|
802
|
+
|
|
803
|
+
children_of: Dict[str, List[str]] = {}
|
|
804
|
+
for node in base["nodes"]:
|
|
805
|
+
parent = node.get("parentId")
|
|
806
|
+
if parent is not None:
|
|
807
|
+
children_of.setdefault(parent, []).append(node["id"])
|
|
808
|
+
|
|
809
|
+
for node_id in delta["removed"]:
|
|
810
|
+
if node_id not in by_id:
|
|
811
|
+
return _fail(
|
|
812
|
+
"missing-parent",
|
|
813
|
+
f"delta removes unknown node {node_id}; the producer's base disagrees with "
|
|
814
|
+
"ours, so the tree must be resynchronised rather than patched",
|
|
815
|
+
)
|
|
816
|
+
# Iterative descent: a hostile delta must not be able to blow the stack.
|
|
817
|
+
pending = [node_id]
|
|
818
|
+
while pending:
|
|
819
|
+
current = pending.pop()
|
|
820
|
+
if by_id.pop(current, None) is None:
|
|
821
|
+
continue
|
|
822
|
+
pending.extend(children_of.get(current, ()))
|
|
823
|
+
|
|
824
|
+
for node in delta["changed"]:
|
|
825
|
+
by_id[node["id"]] = node
|
|
826
|
+
|
|
827
|
+
if "rootIds" in delta:
|
|
828
|
+
root_ids = list(delta["rootIds"])
|
|
829
|
+
else:
|
|
830
|
+
root_ids = [node_id for node_id in base["rootIds"] if node_id in by_id]
|
|
831
|
+
|
|
832
|
+
cursor = delta.get("cursor", base.get("cursor"))
|
|
833
|
+
|
|
834
|
+
composed: Dict[str, Any] = {
|
|
835
|
+
"v": 1,
|
|
836
|
+
"sessionId": base["sessionId"],
|
|
837
|
+
"revision": delta["revision"],
|
|
838
|
+
"columns": base["columns"],
|
|
839
|
+
"rows": base["rows"],
|
|
840
|
+
}
|
|
841
|
+
if cursor is not None:
|
|
842
|
+
composed["cursor"] = cursor
|
|
843
|
+
composed["rootIds"] = root_ids
|
|
844
|
+
composed["nodes"] = list(by_id.values())
|
|
845
|
+
|
|
846
|
+
return validate_snapshot(composed, limits)
|