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/messages.py ADDED
@@ -0,0 +1,417 @@
1
+ """Wire messages: builders for what an adapter sends, parsers for what it receives.
2
+
3
+ Transport is length-prefixed JSON frames (see :mod:`termwright.framing`). The
4
+ adapter pushes commits; the driver issues requests; either side may send an
5
+ error and close. Everything is validated against the active limits before it is
6
+ retained, and failures are returned as typed results rather than raised.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Any, Dict, Mapping, Optional, Sequence
13
+
14
+ from .errors import ProtocolViolation
15
+ from .framing import project_dto
16
+ from .limits import DEFAULT_LIMITS, LIMIT_FIELDS, ProtocolLimits
17
+ from .logs import LogRecord, validate_log_record
18
+ from .roles import CAPABILITY_SET
19
+ from .validate import validate_snapshot, validate_tree_delta
20
+
21
+ PROTOCOL_ID = "termwright/1"
22
+ PROTOCOL_V2_ID = "termwright/2"
23
+ PROTOCOL_VERSION = 1
24
+
25
+ ERROR_CODES = ("bad-token", "bad-version", "malformed", "limit-exceeded", "internal")
26
+ SUBSCRIBE_MODES = ("snapshots", "revisions", "diffs")
27
+
28
+ MAX_IDENTIFIER_LENGTH = 1024
29
+ _MAX_SAFE_INTEGER = 2**53 - 1
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class ParseResult:
34
+ """Outcome of parsing one wire message."""
35
+
36
+ ok: bool
37
+ message: Optional[Dict[str, Any]] = None
38
+ code: Optional[str] = None
39
+ detail: str = ""
40
+
41
+
42
+ def _malformed(detail: str) -> ParseResult:
43
+ return ParseResult(ok=False, code="malformed", detail=detail)
44
+
45
+
46
+ # --------------------------------------------------------------------------
47
+ # Builders (adapter → driver)
48
+ # --------------------------------------------------------------------------
49
+
50
+
51
+ def hello(
52
+ token: str,
53
+ adapter_name: str,
54
+ adapter_version: str,
55
+ capabilities: Sequence[str],
56
+ probe: Optional[Mapping[str, Any]] = None,
57
+ protocol: str = PROTOCOL_ID,
58
+ ) -> Dict[str, Any]:
59
+ """Build the handshake message. Unknown capabilities are refused locally.
60
+
61
+ ``probe`` is present when the sender is a probe rather than a hand-written
62
+ adapter, and carries what it can actually observe — framework and
63
+ versions, the best identity it can offer, and its optional abilities — so
64
+ the driver negotiates against measured capability instead of a floor.
65
+ """
66
+ unknown = [item for item in capabilities if item not in CAPABILITY_SET]
67
+ if unknown:
68
+ raise ProtocolViolation("marker-argument", f"unknown capabilities: {', '.join(unknown)}")
69
+ message: Dict[str, Any] = {
70
+ "type": "hello",
71
+ "protocol": protocol,
72
+ "token": token,
73
+ "adapter": {"name": adapter_name, "version": adapter_version},
74
+ "capabilities": list(capabilities),
75
+ }
76
+ if probe is not None:
77
+ message["probe"] = dict(probe)
78
+ return message
79
+
80
+
81
+ def snapshot_message(snapshot: Mapping[str, Any]) -> Dict[str, Any]:
82
+ """Wrap a wire-form snapshot in its envelope."""
83
+ return {"type": "snapshot", "snapshot": dict(snapshot)}
84
+
85
+
86
+ def revision_commit(revision: int) -> Dict[str, Any]:
87
+ """Announce that ``revision`` has been committed to the terminal."""
88
+ return {"type": "revision-commit", "revision": revision}
89
+
90
+
91
+ def get_tree_result(request_id: int, snapshot: Optional[Mapping[str, Any]] = None, error: Optional[str] = None) -> Dict[str, Any]:
92
+ """Answer a ``get-tree`` request with exactly one of a snapshot or an error."""
93
+ if (snapshot is None) == (error is None):
94
+ raise ProtocolViolation("marker-argument", "exactly one of snapshot or error must be given")
95
+ if snapshot is not None:
96
+ return {"type": "get-tree-result", "requestId": request_id, "snapshot": dict(snapshot)}
97
+ return {"type": "get-tree-result", "requestId": request_id, "error": error}
98
+
99
+
100
+ def log_message(record: LogRecord) -> Dict[str, Any]:
101
+ """Wrap a log record in its envelope."""
102
+ return {"type": "log", "record": record.to_wire()}
103
+
104
+
105
+ def protocol_error(code: str, message: str) -> Dict[str, Any]:
106
+ """Build a terminal error message; the sender closes after emitting it."""
107
+ if code not in ERROR_CODES:
108
+ raise ProtocolViolation("marker-argument", f"unknown error code {code}")
109
+ return {"type": "error", "code": code, "message": message}
110
+
111
+
112
+ # --------------------------------------------------------------------------
113
+ # Parsers
114
+ # --------------------------------------------------------------------------
115
+
116
+
117
+ def _project(value: Any, limits: ProtocolLimits) -> ParseResult:
118
+ try:
119
+ return ParseResult(ok=True, message=project_dto(value, limits.maxDepth))
120
+ except ProtocolViolation as error:
121
+ if error.code == "dto-depth":
122
+ return ParseResult(ok=False, code="limit-exceeded", detail=str(error))
123
+ return _malformed(str(error))
124
+
125
+
126
+ def _identifier(value: Any, field: str, allow_empty: bool = False) -> Optional[str]:
127
+ if not isinstance(value, str):
128
+ return f"{field}: expected a string"
129
+ if len(value) > MAX_IDENTIFIER_LENGTH:
130
+ return f"{field}: expected at most {MAX_IDENTIFIER_LENGTH} characters"
131
+ if not allow_empty and not value:
132
+ return f"{field}: expected a non-empty string"
133
+ return None
134
+
135
+
136
+ def _index(value: Any, field: str) -> Optional[str]:
137
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0 or value > _MAX_SAFE_INTEGER:
138
+ return f"{field}: expected a non-negative safe integer"
139
+ return None
140
+
141
+
142
+ def _revision(value: Any, field: str) -> Optional[str]:
143
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0 or value > _MAX_SAFE_INTEGER:
144
+ return f"{field}: expected a positive safe integer"
145
+ return None
146
+
147
+
148
+ def _required_keys(message: Mapping[str, Any], required: Sequence[str]) -> Optional[str]:
149
+ """Check that every required key is present, tolerating unknown ones."""
150
+ missing = [key for key in required if key not in message]
151
+ if missing:
152
+ return f"missing field(s): {', '.join(missing)}"
153
+ return None
154
+
155
+
156
+ def _exact_keys(message: Mapping[str, Any], required: Sequence[str], optional: Sequence[str] = ()) -> Optional[str]:
157
+ missing = [key for key in required if key not in message]
158
+ if missing:
159
+ return f"missing field(s): {', '.join(missing)}"
160
+ allowed = set(required) | set(optional)
161
+ unknown = [key for key in message if key not in allowed]
162
+ if unknown:
163
+ return f"unrecognized key(s): {', '.join(unknown)}"
164
+ return None
165
+
166
+
167
+ def _check_tree_delta(value: Any, limits: ProtocolLimits) -> Optional[ParseResult]:
168
+ """Map a delta shape failure onto the wire taxonomy."""
169
+ result = validate_tree_delta(value, limits)
170
+ if result.ok:
171
+ return None
172
+ over_capacity = result.code in ("bytes", "count", "depth", "string-bytes")
173
+ return ParseResult(
174
+ ok=False,
175
+ code="limit-exceeded" if over_capacity else "malformed",
176
+ detail=f"tree-delta {result.code}: {result.detail}",
177
+ )
178
+
179
+
180
+ def _check_log_record(value: Any, limits: ProtocolLimits) -> Optional[ParseResult]:
181
+ """Map a record failure onto the wire taxonomy, as the reference does."""
182
+ result = validate_log_record(value, limits)
183
+ if result.ok:
184
+ return None
185
+ over_capacity = result.code in ("bytes", "count", "depth", "string-bytes")
186
+ return ParseResult(
187
+ ok=False,
188
+ code="limit-exceeded" if over_capacity else "malformed",
189
+ detail=f"log record {result.code}: {result.detail}",
190
+ )
191
+
192
+
193
+ def _check_log_budget(value: Any) -> Optional[str]:
194
+ """Validate the optional log-channel budget carried by ``hello-ack``.
195
+
196
+ The field is absent unless the adapter announced the ``logs`` capability,
197
+ and absent means logs are disabled.
198
+ """
199
+ if not isinstance(value, dict):
200
+ return "logs: expected an object"
201
+ issue = _required_keys(value, ("enabled", "maxRecordsPerSecond", "burst"))
202
+ if issue:
203
+ return f"logs: {issue}"
204
+ if not isinstance(value["enabled"], bool):
205
+ return "logs.enabled: expected a boolean"
206
+ if _revision(value["maxRecordsPerSecond"], "logs.maxRecordsPerSecond"):
207
+ return "logs.maxRecordsPerSecond: expected a positive safe integer"
208
+ if _index(value["burst"], "logs.burst"):
209
+ return "logs.burst: expected a non-negative safe integer"
210
+ return None
211
+
212
+
213
+ def _check_snapshot(value: Any, limits: ProtocolLimits) -> Optional[ParseResult]:
214
+ result = validate_snapshot(value, limits)
215
+ if result.ok:
216
+ return None
217
+ over_capacity = result.code in ("bytes", "count", "depth", "string-bytes")
218
+ return ParseResult(
219
+ ok=False,
220
+ code="limit-exceeded" if over_capacity else "malformed",
221
+ detail=f"snapshot {result.code}: {result.detail}",
222
+ )
223
+
224
+
225
+ def _check_error(message: Mapping[str, Any], strict: bool = True) -> Optional[str]:
226
+ issue = (
227
+ _exact_keys(message, ("type", "code", "message"))
228
+ if strict
229
+ else _required_keys(message, ("type", "code", "message"))
230
+ )
231
+ if issue:
232
+ return issue
233
+ if message["code"] not in ERROR_CODES:
234
+ return f"code: expected one of {', '.join(ERROR_CODES)}"
235
+ return _identifier(message["message"], "message", allow_empty=True)
236
+
237
+
238
+ def parse_adapter_message(value: Any, limits: ProtocolLimits = DEFAULT_LIMITS) -> ParseResult:
239
+ """Parse one adapter → driver message. Never raises.
240
+
241
+ Strict: an unknown field from an adapter is a protocol error, not an
242
+ extension. See :func:`parse_driver_message` for the other direction.
243
+ """
244
+ projected = _project(value, limits)
245
+ if not projected.ok:
246
+ return projected
247
+ message = projected.message
248
+ if not isinstance(message, dict) or not isinstance(message.get("type"), str):
249
+ return _malformed("unknown or missing message type")
250
+
251
+ kind = message["type"]
252
+ if kind == "hello":
253
+ protocol = message.get("protocol")
254
+ if isinstance(protocol, str) and protocol not in (PROTOCOL_ID, PROTOCOL_V2_ID):
255
+ return ParseResult(ok=False, code="bad-version", detail=f"unsupported protocol {protocol}")
256
+ issue = _exact_keys(message, ("type", "protocol", "token", "adapter", "capabilities"))
257
+ if issue:
258
+ return _malformed(issue)
259
+ if message["protocol"] not in (PROTOCOL_ID, PROTOCOL_V2_ID):
260
+ return _malformed("protocol: expected termwright/1 or termwright/2")
261
+ issue = _identifier(message["token"], "token")
262
+ if issue:
263
+ return _malformed(issue)
264
+ adapter = message["adapter"]
265
+ if not isinstance(adapter, dict):
266
+ return _malformed("adapter: expected an object")
267
+ issue = _exact_keys(adapter, ("name", "version"))
268
+ if issue:
269
+ return _malformed(f"adapter: {issue}")
270
+ for field in ("name", "version"):
271
+ issue = _identifier(adapter[field], f"adapter.{field}")
272
+ if issue:
273
+ return _malformed(issue)
274
+ capabilities = message["capabilities"]
275
+ if not isinstance(capabilities, list) or len(capabilities) > len(CAPABILITY_SET):
276
+ return _malformed("capabilities: expected a bounded array")
277
+ for item in capabilities:
278
+ if item not in CAPABILITY_SET:
279
+ return _malformed(f"capabilities: unknown capability {item!r}")
280
+ qualified = "qualified-observations" in capabilities
281
+ if (message["protocol"] == PROTOCOL_V2_ID) != qualified:
282
+ return _malformed("termwright/2 and qualified-observations must be negotiated together")
283
+ if "pointer-hit-grid" in capabilities and not qualified:
284
+ return _malformed("pointer-hit-grid requires qualified-observations")
285
+ return ParseResult(ok=True, message=message)
286
+
287
+ if kind == "revision-commit":
288
+ issue = _exact_keys(message, ("type", "revision")) or _revision(message.get("revision"), "revision")
289
+ return _malformed(issue) if issue else ParseResult(ok=True, message=message)
290
+
291
+ if kind == "snapshot":
292
+ issue = _exact_keys(message, ("type", "snapshot"))
293
+ if issue:
294
+ return _malformed(issue)
295
+ bad = _check_snapshot(message["snapshot"], limits)
296
+ return bad if bad is not None else ParseResult(ok=True, message=message)
297
+
298
+ if kind == "get-tree-result":
299
+ issue = _exact_keys(message, ("type", "requestId"), ("snapshot", "error"))
300
+ if issue:
301
+ return _malformed(issue)
302
+ issue = _index(message["requestId"], "requestId")
303
+ if issue:
304
+ return _malformed(issue)
305
+ has_snapshot = "snapshot" in message
306
+ has_error = "error" in message
307
+ if has_snapshot == has_error:
308
+ return _malformed("exactly one of snapshot or error must be present")
309
+ if has_error:
310
+ issue = _identifier(message["error"], "error", allow_empty=True)
311
+ return _malformed(issue) if issue else ParseResult(ok=True, message=message)
312
+ bad = _check_snapshot(message["snapshot"], limits)
313
+ return bad if bad is not None else ParseResult(ok=True, message=message)
314
+
315
+ if kind == "tree-delta":
316
+ bad = _check_tree_delta(message, limits)
317
+ return bad if bad is not None else ParseResult(ok=True, message=message)
318
+
319
+ if kind == "log":
320
+ issue = _exact_keys(message, ("type", "record"))
321
+ if issue:
322
+ return _malformed(issue)
323
+ bad = _check_log_record(message["record"], limits)
324
+ return bad if bad is not None else ParseResult(ok=True, message=message)
325
+
326
+ if kind == "error":
327
+ issue = _check_error(message)
328
+ return _malformed(issue) if issue else ParseResult(ok=True, message=message)
329
+
330
+ return _malformed("unknown or missing message type")
331
+
332
+
333
+ def parse_driver_message(value: Any, limits: ProtocolLimits = DEFAULT_LIMITS) -> ParseResult:
334
+ """Parse one driver → adapter message. Never raises.
335
+
336
+ Driver traffic is read tolerantly: unknown fields in the envelope and in
337
+ the driver's nested objects (``marker``, ``logs``, ``limits``) are ignored
338
+ and passed through to the caller, so a newer driver can add a field without
339
+ breaking an adapter that was published before it existed.
340
+
341
+ The asymmetry is about who is speaking, not about the message. Adapter
342
+ traffic crosses an untrusted boundary, where an unknown field is a signal
343
+ rather than an extension, so :func:`parse_adapter_message` stays strict.
344
+ Tolerance is not leniency either: known fields keep their types, and the
345
+ closed sets (message types, error codes, ``subscribe``, roles, actions)
346
+ stay closed in both directions.
347
+ """
348
+ projected = _project(value, limits)
349
+ if not projected.ok:
350
+ return projected
351
+ message = projected.message
352
+ if not isinstance(message, dict) or not isinstance(message.get("type"), str):
353
+ return _malformed("unknown or missing message type")
354
+
355
+ kind = message["type"]
356
+ if kind == "hello-ack":
357
+ protocol = message.get("protocol")
358
+ if isinstance(protocol, str) and protocol not in (PROTOCOL_ID, PROTOCOL_V2_ID):
359
+ return ParseResult(ok=False, code="bad-version", detail=f"unsupported protocol {protocol}")
360
+ issue = _required_keys(
361
+ message, ("type", "protocol", "sessionId", "limits", "subscribe", "marker")
362
+ )
363
+ if issue:
364
+ return _malformed(issue)
365
+ if message["protocol"] not in (PROTOCOL_ID, PROTOCOL_V2_ID):
366
+ return _malformed("protocol: expected termwright/1 or termwright/2")
367
+ issue = _identifier(message["sessionId"], "sessionId")
368
+ if issue:
369
+ return _malformed(issue)
370
+ limits_value = message["limits"]
371
+ if not isinstance(limits_value, dict):
372
+ return _malformed("limits: expected an object")
373
+ # Required keys must all be present, but unknown ones are ignored:
374
+ # `limits` is the one object on the wire that grows between versions,
375
+ # and a client that rejected a ceiling it had never heard of would
376
+ # drop the channel every time the protocol gained one.
377
+ issue = _required_keys(limits_value, LIMIT_FIELDS)
378
+ if issue:
379
+ return _malformed(f"limits: {issue}")
380
+ for field in LIMIT_FIELDS:
381
+ issue = _revision(limits_value[field], f"limits.{field}")
382
+ if issue:
383
+ return _malformed(issue)
384
+ if message["subscribe"] not in SUBSCRIBE_MODES:
385
+ return _malformed("subscribe: expected 'snapshots', 'revisions' or 'diffs'")
386
+ marker = message["marker"]
387
+ if not isinstance(marker, dict):
388
+ return _malformed("marker: expected an object")
389
+ issue = _required_keys(marker, ("enabled",))
390
+ if issue:
391
+ return _malformed(f"marker: {issue}")
392
+ if not isinstance(marker["enabled"], bool):
393
+ return _malformed("marker.enabled: expected a boolean")
394
+ if "logs" in message:
395
+ issue = _check_log_budget(message["logs"])
396
+ if issue:
397
+ return _malformed(issue)
398
+ return ParseResult(ok=True, message=message)
399
+
400
+ if kind == "get-tree":
401
+ issue = _required_keys(message, ("type", "requestId"))
402
+ if issue:
403
+ return _malformed(issue)
404
+ issue = _index(message["requestId"], "requestId")
405
+ if issue:
406
+ return _malformed(issue)
407
+ if "revision" in message:
408
+ issue = _revision(message["revision"], "revision")
409
+ if issue:
410
+ return _malformed(issue)
411
+ return ParseResult(ok=True, message=message)
412
+
413
+ if kind == "error":
414
+ issue = _check_error(message, strict=False)
415
+ return _malformed(issue) if issue else ParseResult(ok=True, message=message)
416
+
417
+ return _malformed("unknown or missing message type")
termwright/roles.py ADDED
@@ -0,0 +1,57 @@
1
+ """Closed vocabularies. Unknown members are rejected, never passed through."""
2
+
3
+ from __future__ import annotations
4
+
5
+ SEMANTIC_ROLES = (
6
+ "application",
7
+ "region",
8
+ "dialog",
9
+ "alert",
10
+ "status",
11
+ "list",
12
+ "listitem",
13
+ "menu",
14
+ "menuitem",
15
+ "button",
16
+ "checkbox",
17
+ "radio",
18
+ "tab",
19
+ "textbox",
20
+ "heading",
21
+ "text",
22
+ "progressbar",
23
+ "separator",
24
+ "scrollbar",
25
+ "table",
26
+ "row",
27
+ "cell",
28
+ "generic",
29
+ )
30
+
31
+ SEMANTIC_ACTIONS = (
32
+ "focus",
33
+ "activate",
34
+ "toggle",
35
+ "setValue",
36
+ "scroll",
37
+ "select",
38
+ "expand",
39
+ )
40
+
41
+ ADAPTER_CAPABILITIES = (
42
+ "tree",
43
+ "bounds",
44
+ "absolute-bounds",
45
+ "states",
46
+ "actions",
47
+ "text-ranges",
48
+ "render-revisions",
49
+ "tree-diffs",
50
+ "logs",
51
+ "qualified-observations",
52
+ "pointer-hit-grid",
53
+ )
54
+
55
+ ROLE_SET = frozenset(SEMANTIC_ROLES)
56
+ ACTION_SET = frozenset(SEMANTIC_ACTIONS)
57
+ CAPABILITY_SET = frozenset(ADAPTER_CAPABILITIES)