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/textual.py ADDED
@@ -0,0 +1,249 @@
1
+ """Optional semantic intent for custom Textual widgets.
2
+
3
+ Textual's automatic probe already knows the DOM, geometry, focus and widget
4
+ state. This module deliberately cannot describe any of those physical facts;
5
+ it only supplies application meaning that the framework cannot infer.
6
+
7
+ The class decorator is the normal API::
8
+
9
+ @semantic(
10
+ role="button",
11
+ name=lambda widget: widget.label,
12
+ test_id="deploy-production",
13
+ extended=lambda widget: {"environment": widget.environment},
14
+ key=lambda widget: f"deployment:{widget.environment}",
15
+ )
16
+ class DeployWidget(Widget):
17
+ ...
18
+
19
+ ``annotate`` is available for third-party widget instances that cannot be
20
+ decorated. Both APIs are dormant metadata only: importing this module opens
21
+ no socket and does not install the Textual probe.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass, replace
27
+ from typing import Any, Callable, Mapping, Optional, Sequence, Tuple, TypeVar, Union
28
+ from weakref import WeakKeyDictionary
29
+
30
+ from .roles import ACTION_SET, SEMANTIC_ROLES
31
+
32
+ T = TypeVar("T")
33
+ ResolvedOrFactory = Union[T, Callable[[Any], T]]
34
+ Relationship = Union[Any, Sequence[Any]]
35
+
36
+ _CLASS_ANNOTATION = "__termwright_textual_semantics__"
37
+ _instances: "WeakKeyDictionary[Any, SemanticAnnotation]" = WeakKeyDictionary()
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class SemanticAnnotation:
42
+ """Developer-owned semantic intent.
43
+
44
+ There are intentionally no bounds, focus, visibility, rendered-text or
45
+ portable framework-state fields. Those are observed facts and an
46
+ annotation must not be able to contradict them.
47
+ """
48
+
49
+ role: Optional[ResolvedOrFactory[str]] = None
50
+ name: Optional[ResolvedOrFactory[str]] = None
51
+ description: Optional[ResolvedOrFactory[str]] = None
52
+ test_id: Optional[ResolvedOrFactory[str]] = None
53
+ extended: Optional[ResolvedOrFactory[Mapping[str, Any]]] = None
54
+ labelled_by: Optional[ResolvedOrFactory[Relationship]] = None
55
+ described_by: Optional[ResolvedOrFactory[Relationship]] = None
56
+ actions: Optional[ResolvedOrFactory[Sequence[str]]] = None
57
+ key: Optional[ResolvedOrFactory[str]] = None
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class ResolvedAnnotation:
62
+ """One annotation evaluated against the current widget instance."""
63
+
64
+ role: Optional[str] = None
65
+ name: Optional[str] = None
66
+ description: Optional[str] = None
67
+ test_id: Optional[str] = None
68
+ extended: Optional[Mapping[str, Any]] = None
69
+ labelled_by: Tuple[Any, ...] = ()
70
+ described_by: Tuple[Any, ...] = ()
71
+ actions: Optional[Tuple[str, ...]] = None
72
+ key: Optional[str] = None
73
+
74
+
75
+ def semantic(
76
+ *,
77
+ role: Optional[ResolvedOrFactory[str]] = None,
78
+ name: Optional[ResolvedOrFactory[str]] = None,
79
+ description: Optional[ResolvedOrFactory[str]] = None,
80
+ test_id: Optional[ResolvedOrFactory[str]] = None,
81
+ extended: Optional[ResolvedOrFactory[Mapping[str, Any]]] = None,
82
+ labelled_by: Optional[ResolvedOrFactory[Relationship]] = None,
83
+ described_by: Optional[ResolvedOrFactory[Relationship]] = None,
84
+ actions: Optional[ResolvedOrFactory[Sequence[str]]] = None,
85
+ key: Optional[ResolvedOrFactory[str]] = None,
86
+ ) -> Callable[[T], T]:
87
+ """Decorate a Textual widget class with semantic intent.
88
+
89
+ Values may be constants or callables receiving the live widget. A
90
+ subclass inherits the declaration naturally; another decorator on the
91
+ subclass replaces only the fields it explicitly supplies.
92
+ """
93
+
94
+ annotation = _make_annotation(
95
+ role=role,
96
+ name=name,
97
+ description=description,
98
+ test_id=test_id,
99
+ extended=extended,
100
+ labelled_by=labelled_by,
101
+ described_by=described_by,
102
+ actions=actions,
103
+ key=key,
104
+ )
105
+
106
+ def decorate(klass: T) -> T:
107
+ inherited = getattr(klass, _CLASS_ANNOTATION, SemanticAnnotation())
108
+ setattr(klass, _CLASS_ANNOTATION, _merge(inherited, annotation))
109
+ return klass
110
+
111
+ return decorate
112
+
113
+
114
+ def annotate(
115
+ widget: T,
116
+ *,
117
+ role: Optional[ResolvedOrFactory[str]] = None,
118
+ name: Optional[ResolvedOrFactory[str]] = None,
119
+ description: Optional[ResolvedOrFactory[str]] = None,
120
+ test_id: Optional[ResolvedOrFactory[str]] = None,
121
+ extended: Optional[ResolvedOrFactory[Mapping[str, Any]]] = None,
122
+ labelled_by: Optional[ResolvedOrFactory[Relationship]] = None,
123
+ described_by: Optional[ResolvedOrFactory[Relationship]] = None,
124
+ actions: Optional[ResolvedOrFactory[Sequence[str]]] = None,
125
+ key: Optional[ResolvedOrFactory[str]] = None,
126
+ ) -> T:
127
+ """Attach semantic intent to one retained widget and return that widget."""
128
+
129
+ current = _instances.get(widget, SemanticAnnotation())
130
+ _instances[widget] = _merge(
131
+ current,
132
+ _make_annotation(
133
+ role=role,
134
+ name=name,
135
+ description=description,
136
+ test_id=test_id,
137
+ extended=extended,
138
+ labelled_by=labelled_by,
139
+ described_by=described_by,
140
+ actions=actions,
141
+ key=key,
142
+ ),
143
+ )
144
+ return widget
145
+
146
+
147
+ def remove_annotation(widget: Any) -> None:
148
+ """Remove an instance annotation; class annotations remain in force."""
149
+
150
+ _instances.pop(widget, None)
151
+
152
+
153
+ def resolve_annotation(widget: Any) -> ResolvedAnnotation:
154
+ """Resolve inherited and instance declarations for the automatic probe."""
155
+
156
+ declared = getattr(type(widget), _CLASS_ANNOTATION, SemanticAnnotation())
157
+ instance = _instances.get(widget)
158
+ annotation = _merge(declared, instance) if instance is not None else declared
159
+
160
+ role = _optional_string(_resolve(annotation.role, widget), "role")
161
+ if role is not None and role not in SEMANTIC_ROLES:
162
+ raise ValueError(f"unknown semantic role: {role!r}")
163
+
164
+ extended_value = _resolve(annotation.extended, widget)
165
+ if extended_value is not None and not isinstance(extended_value, Mapping):
166
+ raise TypeError("extended must resolve to a mapping")
167
+ extended = dict(extended_value) if extended_value is not None else None
168
+
169
+ action_value = _resolve(annotation.actions, widget)
170
+ actions = _semantic_actions(action_value)
171
+
172
+ return ResolvedAnnotation(
173
+ role=role,
174
+ name=_optional_string(_resolve(annotation.name, widget), "name", allow_empty=True),
175
+ description=_optional_string(
176
+ _resolve(annotation.description, widget), "description", allow_empty=True
177
+ ),
178
+ test_id=_optional_string(_resolve(annotation.test_id, widget), "test_id"),
179
+ extended=extended,
180
+ labelled_by=_relationships(_resolve(annotation.labelled_by, widget)),
181
+ described_by=_relationships(_resolve(annotation.described_by, widget)),
182
+ actions=actions,
183
+ key=_optional_string(_resolve(annotation.key, widget), "key"),
184
+ )
185
+
186
+
187
+ def _make_annotation(**values: Any) -> SemanticAnnotation:
188
+ role = values.get("role")
189
+ if isinstance(role, str) and role not in SEMANTIC_ROLES:
190
+ raise ValueError(f"unknown semantic role: {role!r}")
191
+ actions = values.get("actions")
192
+ if actions is not None and not callable(actions):
193
+ _semantic_actions(actions)
194
+ return SemanticAnnotation(**values)
195
+
196
+
197
+ def _semantic_actions(value: Any) -> Optional[Tuple[str, ...]]:
198
+ if value is None:
199
+ return None
200
+ if isinstance(value, (str, bytes)) or not isinstance(value, Sequence):
201
+ raise TypeError("actions must resolve to a sequence of semantic actions")
202
+ actions = tuple(value)
203
+ if any(not isinstance(action, str) or action not in ACTION_SET for action in actions):
204
+ raise ValueError("actions must contain only v1 semantic actions")
205
+ if len(set(actions)) != len(actions):
206
+ raise ValueError("actions must not contain duplicates")
207
+ return actions
208
+
209
+
210
+ def _merge(base: SemanticAnnotation, override: Optional[SemanticAnnotation]) -> SemanticAnnotation:
211
+ if override is None:
212
+ return base
213
+ values = {
214
+ field: getattr(override, field)
215
+ if getattr(override, field) is not None
216
+ else getattr(base, field)
217
+ for field in SemanticAnnotation.__dataclass_fields__
218
+ }
219
+ return replace(base, **values)
220
+
221
+
222
+ def _resolve(value: Optional[ResolvedOrFactory[T]], widget: Any) -> Optional[T]:
223
+ return value(widget) if callable(value) else value
224
+
225
+
226
+ def _optional_string(value: Any, field: str, *, allow_empty: bool = False) -> Optional[str]:
227
+ if value is None:
228
+ return None
229
+ if not isinstance(value, str) or (not allow_empty and not value):
230
+ qualifier = "a string" if allow_empty else "a non-empty string"
231
+ raise TypeError(f"{field} must resolve to {qualifier}")
232
+ return value
233
+
234
+
235
+ def _relationships(value: Any) -> Tuple[Any, ...]:
236
+ if value is None:
237
+ return ()
238
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
239
+ return tuple(value)
240
+ return (value,)
241
+
242
+
243
+ __all__ = [
244
+ "ResolvedAnnotation",
245
+ "SemanticAnnotation",
246
+ "annotate",
247
+ "remove_annotation",
248
+ "semantic",
249
+ ]
termwright/tree.py ADDED
@@ -0,0 +1,282 @@
1
+ """Semantic tree DTOs.
2
+
3
+ These mirror ``@termwright/protocol``'s ``tree.ts``. ``to_wire`` drops unset
4
+ optionals, because the wire schema is strict: an explicit ``null`` is a
5
+ validation failure, not "absent".
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Dict, List, Mapping, Optional, Sequence, Union
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Rect:
16
+ """Zero-based viewport cell coordinates."""
17
+
18
+ row: int
19
+ column: int
20
+ width: int
21
+ height: int
22
+
23
+ def to_wire(self) -> Dict[str, int]:
24
+ return {"row": self.row, "column": self.column, "width": self.width, "height": self.height}
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Observation:
29
+ """Evidence-qualified wire fact; value is present only for ``known``."""
30
+
31
+ status: str
32
+ value: Any = None
33
+ evidence: Optional[str] = None
34
+ reason: Optional[str] = None
35
+ capability: Optional[str] = None
36
+
37
+ def to_wire(self) -> Dict[str, Any]:
38
+ wire: Dict[str, Any] = {"status": self.status}
39
+ if self.status == "known":
40
+ wire["value"] = self.value.to_wire() if hasattr(self.value, "to_wire") else self.value
41
+ wire["evidence"] = self.evidence
42
+ elif self.status == "unsupported":
43
+ wire["capability"] = self.capability
44
+ wire["reason"] = self.reason
45
+ else:
46
+ wire["reason"] = self.reason
47
+ return wire
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class NodeGeometryObservations:
52
+ displayed: Observation
53
+ intendedRect: Observation
54
+ visibleRect: Observation
55
+
56
+ def to_wire(self) -> Dict[str, Any]:
57
+ return {"displayed": self.displayed.to_wire(), "intendedRect": self.intendedRect.to_wire(), "visibleRect": self.visibleRect.to_wire()}
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class SemanticState:
62
+ """Closed state set; unset members are omitted from the wire form."""
63
+
64
+ disabled: Optional[bool] = None
65
+ focused: Optional[bool] = None
66
+ selected: Optional[bool] = None
67
+ checked: Optional[Union[bool, str]] = None
68
+ expanded: Optional[bool] = None
69
+ modal: Optional[bool] = None
70
+ busy: Optional[bool] = None
71
+ hidden: Optional[bool] = None
72
+ #: Every cell is outside the visible area — scrolled away, not undisplayed.
73
+ #: Implies ``hidden``; the pair without it is refused by validation.
74
+ offscreen: Optional[bool] = None
75
+ readonly: Optional[bool] = None
76
+ multiline: Optional[bool] = None
77
+ orientation: Optional[str] = None
78
+ level: Optional[int] = None
79
+ positionInSet: Optional[int] = None
80
+ setSize: Optional[int] = None
81
+ scrollOffset: Optional[int] = None
82
+ scrollExtent: Optional[int] = None
83
+
84
+ def to_wire(self) -> Dict[str, Any]:
85
+ return {
86
+ name: value
87
+ for name, value in self.__dict__.items()
88
+ if value is not None
89
+ }
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class SemanticTextRange:
94
+ """Maps grapheme offsets of a node's text onto cell coordinates."""
95
+
96
+ startOffset: int
97
+ endOffset: int
98
+ rect: Rect
99
+
100
+ def to_wire(self) -> Dict[str, Any]:
101
+ return {
102
+ "startOffset": self.startOffset,
103
+ "endOffset": self.endOffset,
104
+ "rect": self.rect.to_wire(),
105
+ }
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class SemanticNode:
110
+ """One accessible node. ``bounds`` are absolute viewport cells."""
111
+
112
+ id: str
113
+ role: str
114
+ name: str = ""
115
+ parentId: Optional[str] = None
116
+ description: Optional[str] = None
117
+ value: Optional[str] = None
118
+ bounds: Optional[Rect] = None
119
+ state: Optional[SemanticState] = None
120
+ #: Application-defined JSON state. Portable flags stay in ``state``.
121
+ extended: Optional[Mapping[str, Any]] = None
122
+ actions: Optional[Sequence[str]] = None
123
+ labelledBy: Optional[Sequence[str]] = None
124
+ describedBy: Optional[Sequence[str]] = None
125
+ textRanges: Optional[Sequence[SemanticTextRange]] = None
126
+ testId: Optional[str] = None
127
+ #: What the UI framework calls this widget. Required when ``role`` is
128
+ #: ``generic``: an unrecognised widget must at least name its own type, so
129
+ #: a reader can tell one unknown thing from another.
130
+ frameworkType: Optional[str] = None
131
+ #: Whether the producer can say if these cells are covered by something
132
+ #: painted later. Only a producer that observes paint order may say
133
+ #: ``"known"``; the driver refuses pointer actions on anything else.
134
+ occlusion: Optional[str] = None
135
+ #: Where this node's facts came from, as a whole.
136
+ p: Optional[str] = None
137
+ #: Where individual fields came from, when they differ from ``p``.
138
+ px: Optional[Mapping[str, str]] = None
139
+ geometry: Optional[NodeGeometryObservations] = None
140
+
141
+ def to_wire(self) -> Dict[str, Any]:
142
+ wire: Dict[str, Any] = {"id": self.id, "role": self.role, "name": self.name}
143
+ if self.parentId is not None:
144
+ wire["parentId"] = self.parentId
145
+ if self.description is not None:
146
+ wire["description"] = self.description
147
+ if self.value is not None:
148
+ wire["value"] = self.value
149
+ if self.bounds is not None:
150
+ wire["bounds"] = self.bounds.to_wire()
151
+ if self.state is not None:
152
+ state = self.state.to_wire()
153
+ if state:
154
+ wire["state"] = state
155
+ if self.extended is not None:
156
+ wire["extended"] = _canonical_extended(self.extended)
157
+ if self.actions is not None:
158
+ wire["actions"] = list(self.actions)
159
+ if self.labelledBy is not None:
160
+ wire["labelledBy"] = list(self.labelledBy)
161
+ if self.describedBy is not None:
162
+ wire["describedBy"] = list(self.describedBy)
163
+ if self.textRanges is not None:
164
+ wire["textRanges"] = [item.to_wire() for item in self.textRanges]
165
+ if self.testId is not None:
166
+ wire["testId"] = self.testId
167
+ if self.frameworkType is not None:
168
+ wire["frameworkType"] = self.frameworkType
169
+ if self.occlusion is not None:
170
+ wire["occlusion"] = self.occlusion
171
+ if self.p is not None:
172
+ wire["p"] = self.p
173
+ if self.px:
174
+ wire["px"] = dict(self.px)
175
+ if self.geometry is not None:
176
+ wire["geometry"] = self.geometry.to_wire()
177
+ return wire
178
+
179
+
180
+ def _canonical_extended(value: Any) -> Any:
181
+ """Copy JSON-like domain state with stable object-key ordering."""
182
+ if isinstance(value, Mapping):
183
+ return {key: _canonical_extended(value[key]) for key in sorted(value)}
184
+ if isinstance(value, (list, tuple)):
185
+ return [_canonical_extended(item) for item in value]
186
+ return value
187
+
188
+
189
+ @dataclass(frozen=True)
190
+ class CursorInfo:
191
+ """Terminal cursor position, in viewport cells."""
192
+
193
+ row: int
194
+ column: int
195
+ visible: bool
196
+ shape: Optional[str] = None
197
+
198
+ def to_wire(self) -> Dict[str, Any]:
199
+ wire: Dict[str, Any] = {"row": self.row, "column": self.column, "visible": self.visible}
200
+ if self.shape is not None:
201
+ wire["shape"] = self.shape
202
+ return wire
203
+
204
+
205
+ @dataclass(frozen=True)
206
+ class SemanticSnapshot:
207
+ """A whole tree for one committed render."""
208
+
209
+ sessionId: str
210
+ revision: int
211
+ columns: int
212
+ rows: int
213
+ rootIds: Sequence[str] = field(default_factory=list)
214
+ nodes: Sequence[SemanticNode] = field(default_factory=list)
215
+ cursor: Optional[CursorInfo] = None
216
+ v: int = 1
217
+ coordinateSpace: Optional[Observation] = None
218
+ hitGrid: Optional[Observation] = None
219
+
220
+ def to_wire(self) -> Dict[str, Any]:
221
+ wire: Dict[str, Any] = {
222
+ "v": self.v,
223
+ "sessionId": self.sessionId,
224
+ "revision": self.revision,
225
+ "columns": self.columns,
226
+ "rows": self.rows,
227
+ }
228
+ if self.cursor is not None:
229
+ wire["cursor"] = self.cursor.to_wire()
230
+ wire["rootIds"] = list(self.rootIds)
231
+ wire["nodes"] = [node.to_wire() for node in self.nodes]
232
+ if self.coordinateSpace is not None:
233
+ wire["coordinateSpace"] = self.coordinateSpace.to_wire()
234
+ if self.hitGrid is not None:
235
+ wire["hitGrid"] = self.hitGrid.to_wire()
236
+ return wire
237
+
238
+
239
+ def snapshot_from_wire(value: Dict[str, Any]) -> SemanticSnapshot:
240
+ """Rebuild a snapshot from an already-validated wire object."""
241
+ nodes: List[SemanticNode] = []
242
+ for raw in value["nodes"]:
243
+ bounds = raw.get("bounds")
244
+ state = raw.get("state")
245
+ ranges = raw.get("textRanges")
246
+ nodes.append(
247
+ SemanticNode(
248
+ id=raw["id"],
249
+ role=raw["role"],
250
+ name=raw.get("name", ""),
251
+ parentId=raw.get("parentId"),
252
+ description=raw.get("description"),
253
+ value=raw.get("value"),
254
+ bounds=Rect(**bounds) if bounds is not None else None,
255
+ state=SemanticState(**state) if state is not None else None,
256
+ actions=tuple(raw["actions"]) if raw.get("actions") is not None else None,
257
+ labelledBy=tuple(raw["labelledBy"]) if raw.get("labelledBy") is not None else None,
258
+ describedBy=tuple(raw["describedBy"]) if raw.get("describedBy") is not None else None,
259
+ textRanges=tuple(
260
+ SemanticTextRange(
261
+ startOffset=item["startOffset"],
262
+ endOffset=item["endOffset"],
263
+ rect=Rect(**item["rect"]),
264
+ )
265
+ for item in ranges
266
+ )
267
+ if ranges is not None
268
+ else None,
269
+ testId=raw.get("testId"),
270
+ )
271
+ )
272
+ cursor = value.get("cursor")
273
+ return SemanticSnapshot(
274
+ sessionId=value["sessionId"],
275
+ revision=value["revision"],
276
+ columns=value["columns"],
277
+ rows=value["rows"],
278
+ rootIds=tuple(value["rootIds"]),
279
+ nodes=tuple(nodes),
280
+ cursor=CursorInfo(**cursor) if cursor is not None else None,
281
+ v=value["v"],
282
+ )