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.
@@ -0,0 +1,634 @@
1
+ """Turning one observed Textual frame into a semantic tree.
2
+
3
+ What this does differently from the hand-written adapter it replaces, each
4
+ point traceable to a measurement in `docs/architecture/audit/textual.md`:
5
+
6
+ **`bounds` is the visible rectangle.** `Widget.region` is where the widget
7
+ sits in screen coordinates whether or not a container clips it; the audit
8
+ found the adapter publishing that, which reports cells the user cannot see for
9
+ anything scrolled halfway out of a viewport. The truth is
10
+ `MapGeometry.visible_region`, defined by Textual as `clip ∩ region`.
11
+
12
+ **Paint order is real here.** Textual's compositor sorts widgets by
13
+ `MapGeometry.order` (`_compositor.py:763`), a per-ancestor tuple that compares
14
+ lexicographically. Ranking the frame's widgets by that same key gives a
15
+ `paintOrder` that is Textual's own answer rather than our guess, which is what
16
+ lets every node claim `occlusion: 'known'` and unlocks pointer actions the
17
+ driver otherwise refuses.
18
+
19
+ **Not displayed and scrolled out of view are different facts, and the tree now
20
+ says which.** A widget Textual is not displaying is `hidden` with no bounds; one
21
+ that is displayed and entirely clipped is `hidden` **and** `offscreen`, with a
22
+ zero-area rectangle at its own origin. The earlier encoding leaned on absent
23
+ `bounds` to mean "not displayed", which collided with what absent bounds
24
+ already means — "this producer cannot report geometry" — so a consumer reading
25
+ the tree generically could not tell them apart. `state.offscreen` removes the
26
+ ambiguity at the source.
27
+
28
+ The Textual knowledge below — which class means which role, where a widget
29
+ keeps its text — is deliberately copied from the adapter rather than imported
30
+ from it. The adapter is the thing this replaces, and a probe that imported it
31
+ would inherit its decisions instead of making its own. Phase 9 deletes the
32
+ adapter and the duplication with it.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from collections import Counter
38
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
39
+ from weakref import WeakKeyDictionary
40
+
41
+ from termwright.textual import ResolvedAnnotation, resolve_annotation
42
+ from termwright.tree import (
43
+ NodeGeometryObservations,
44
+ Observation as WireObservation,
45
+ Rect,
46
+ SemanticNode,
47
+ SemanticSnapshot,
48
+ SemanticState,
49
+ )
50
+
51
+ #: Widget class name → semantic role, walked along the MRO so a subclass of a
52
+ #: mapped widget inherits its role with no registration at all: `SaveButton`
53
+ #: derived from `Button` is a button because Python already says so.
54
+ ROLE_BY_CLASS: Dict[str, str] = {
55
+ "Button": "button",
56
+ "Input": "textbox",
57
+ "MaskedInput": "textbox",
58
+ "TextArea": "textbox",
59
+ "Checkbox": "checkbox",
60
+ "Switch": "checkbox",
61
+ "RadioButton": "radio",
62
+ "ToggleButton": "checkbox",
63
+ "DataTable": "table",
64
+ "ListView": "list",
65
+ "ListItem": "listitem",
66
+ "OptionList": "list",
67
+ "SelectionList": "list",
68
+ "RadioSet": "list",
69
+ "Select": "list",
70
+ "Tree": "list",
71
+ "DirectoryTree": "list",
72
+ "Tabs": "list",
73
+ "Tab": "tab",
74
+ "TabPane": "region",
75
+ "TabbedContent": "region",
76
+ "Label": "text",
77
+ "Static": "text",
78
+ "Digits": "text",
79
+ "Pretty": "text",
80
+ "Markdown": "text",
81
+ "MarkdownViewer": "region",
82
+ "RichLog": "text",
83
+ "Log": "text",
84
+ "Sparkline": "progressbar",
85
+ "ProgressBar": "progressbar",
86
+ "LoadingIndicator": "status",
87
+ "Toast": "alert",
88
+ "Rule": "separator",
89
+ "ScrollBar": "scrollbar",
90
+ "Header": "region",
91
+ "Footer": "region",
92
+ "Collapsible": "region",
93
+ "ContentSwitcher": "region",
94
+ "Container": "region",
95
+ "ScrollableContainer": "region",
96
+ "Horizontal": "region",
97
+ "Vertical": "region",
98
+ "Grid": "region",
99
+ "Center": "region",
100
+ "Middle": "region",
101
+ "ModalScreen": "dialog",
102
+ "Screen": "application",
103
+ "Widget": "generic",
104
+ }
105
+
106
+ #: Roles whose accessible name comes from what they contain, per the adapter
107
+ #: conventions in the protocol README.
108
+ NAME_FROM_CONTENT_ROLES = frozenset(
109
+ {"listitem", "menuitem", "tab", "button", "checkbox", "radio", "cell", "row", "heading"}
110
+ )
111
+
112
+ #: Longest name derived from a node's contents, in characters.
113
+ MAX_CONTENT_NAME = 200
114
+
115
+
116
+ class Identities:
117
+ """Stable node ids for the lifetime of each widget object.
118
+
119
+ Textual keeps a retained DOM, so a widget object survives between frames
120
+ and its identity can be tracked — the property Ratatui cannot offer at all.
121
+ A `recompose` builds new objects, and those correctly get new ids: the node
122
+ really is new, and pretending otherwise would report a change where a
123
+ replacement happened.
124
+ """
125
+
126
+ def __init__(self) -> None:
127
+ self._ids: "WeakKeyDictionary[Any, str]" = WeakKeyDictionary()
128
+ self._next = 0
129
+
130
+ def of(self, widget: Any, semantic_key: Optional[str] = None) -> str:
131
+ if semantic_key is not None:
132
+ return f"k:{semantic_key}"
133
+ existing = self._ids.get(widget)
134
+ if existing is not None:
135
+ return existing
136
+ self._next += 1
137
+ assigned = f"w{self._next}"
138
+ self._ids[widget] = assigned
139
+ return assigned
140
+
141
+
142
+ def role_for(widget: Any, annotation: Optional[ResolvedAnnotation] = None) -> str:
143
+ """Semantic role: the SDK annotation, then the class ancestry."""
144
+ resolved = annotation if annotation is not None else resolve_annotation(widget)
145
+ if resolved.role is not None:
146
+ return resolved.role
147
+ for klass in type(widget).__mro__:
148
+ role = ROLE_BY_CLASS.get(klass.__name__)
149
+ if role is not None:
150
+ return role
151
+ return "generic"
152
+
153
+
154
+ def _first_text(*candidates: Any) -> str:
155
+ for candidate in candidates:
156
+ if candidate is None:
157
+ continue
158
+ if isinstance(candidate, str):
159
+ if candidate:
160
+ return candidate
161
+ continue
162
+ text = str(candidate).strip()
163
+ if text:
164
+ return text
165
+ return ""
166
+
167
+
168
+ def name_from_content(widget: Any) -> str:
169
+ """Join the text of a widget's descendants, as ARIA names from content."""
170
+ try:
171
+ descendants = widget.query("*")
172
+ except Exception:
173
+ return ""
174
+ parts: List[str] = []
175
+ for child in descendants:
176
+ text = _first_text(
177
+ getattr(child, "label", None),
178
+ getattr(child, "content", None),
179
+ getattr(child, "renderable", None),
180
+ ).strip()
181
+ if text:
182
+ parts.append(text)
183
+ return " ".join(parts)[:MAX_CONTENT_NAME].strip()
184
+
185
+
186
+ def name_for(
187
+ widget: Any,
188
+ role: Optional[str] = None,
189
+ annotation: Optional[ResolvedAnnotation] = None,
190
+ ) -> str:
191
+ """Accessible name: annotation, then own text, then contents, then id."""
192
+ resolved = annotation if annotation is not None else resolve_annotation(widget)
193
+ if resolved.name is not None:
194
+ return resolved.name
195
+
196
+ own = _first_text(
197
+ getattr(widget, "label", None),
198
+ getattr(widget, "placeholder", None),
199
+ getattr(widget, "content", None),
200
+ getattr(widget, "renderable", None),
201
+ )
202
+ if own:
203
+ return own
204
+ if role in NAME_FROM_CONTENT_ROLES:
205
+ from_content = name_from_content(widget)
206
+ if from_content:
207
+ return from_content
208
+ return _first_text(getattr(widget, "name", None), getattr(widget, "id", None))
209
+
210
+
211
+ def test_id_for(widget: Any, annotation: Optional[ResolvedAnnotation] = None) -> Optional[str]:
212
+ """Test id: the author's annotation, then Textual's own DOM id."""
213
+ resolved = annotation if annotation is not None else resolve_annotation(widget)
214
+ if resolved.test_id is not None:
215
+ return resolved.test_id
216
+ native = getattr(widget, "id", None)
217
+ return native if isinstance(native, str) and native else None
218
+
219
+
220
+ def actions_for(role: str) -> Optional[Sequence[str]]:
221
+ if role in ("button", "menuitem", "tab"):
222
+ return ("focus", "activate")
223
+ if role in ("checkbox", "radio"):
224
+ return ("focus", "toggle")
225
+ if role == "textbox":
226
+ return ("focus", "setValue")
227
+ if role in ("list", "table"):
228
+ return ("focus", "scroll", "select")
229
+ return None
230
+
231
+
232
+ def value_for(widget: Any, role: str) -> Optional[str]:
233
+ """Current value of a value-bearing widget, as text.
234
+
235
+ `''` is a value: it says the field is empty, where absence says the widget
236
+ does not bear one at all.
237
+ """
238
+ if role not in ("textbox", "progressbar"):
239
+ return None
240
+ value = getattr(widget, "text", None) if role == "textbox" else None
241
+ if not isinstance(value, str):
242
+ value = getattr(widget, "value", None)
243
+ if isinstance(value, bool) or value is None:
244
+ return None
245
+ return value if isinstance(value, str) else str(value)
246
+
247
+
248
+ def _rect(region: Any) -> Optional[Rect]:
249
+ """A Textual `Region` as a protocol rect, or `None` when there is none."""
250
+ if region is None:
251
+ return None
252
+ try:
253
+ return Rect(
254
+ row=int(region.y),
255
+ column=int(region.x),
256
+ width=max(0, int(region.width)),
257
+ height=max(0, int(region.height)),
258
+ )
259
+ except (AttributeError, TypeError, ValueError):
260
+ return None
261
+
262
+
263
+ class WidgetObservation:
264
+ """One widget as the probe found it, before it becomes a node."""
265
+
266
+ __slots__ = ("widget", "geometry", "displayed", "paint_order")
267
+
268
+ def __init__(self, widget: Any, geometry: Any, displayed: bool) -> None:
269
+ self.widget = widget
270
+ self.geometry = geometry
271
+ self.displayed = displayed
272
+ self.paint_order: Optional[int] = None
273
+
274
+
275
+ def observe(app: Any) -> List[WidgetObservation]:
276
+ """Read the active screen: every widget, its geometry and its display flag.
277
+
278
+ Called from `post_display_hook`, where the compositor has finished, so the
279
+ geometry is this frame's rather than the previous one's.
280
+ """
281
+ screen = app.screen
282
+ widgets = [screen]
283
+ try:
284
+ widgets.extend(screen.query("*"))
285
+ except Exception:
286
+ pass
287
+
288
+ observations: List[WidgetObservation] = []
289
+ for widget in widgets:
290
+ displayed = True
291
+ ancestor = widget
292
+ while ancestor is not None:
293
+ displayed = displayed and bool(getattr(ancestor, "display", True)) and bool(
294
+ getattr(ancestor, "visible", True)
295
+ )
296
+ ancestor = getattr(ancestor, "parent", None)
297
+ geometry = None
298
+ try:
299
+ geometry = screen.find_widget(widget)
300
+ except Exception:
301
+ # A widget the compositor does not know about — mid-mount, or on a
302
+ # screen that is no longer active. It has no geometry this frame,
303
+ # which is a fact rather than an error.
304
+ geometry = None
305
+ observations.append(WidgetObservation(widget, geometry, displayed))
306
+
307
+ _rank_paint_order(observations)
308
+ return observations
309
+
310
+
311
+ def _rank_paint_order(observations: List[WidgetObservation]) -> None:
312
+ """Rank by Textual's own compositing key, so later means on top.
313
+
314
+ `MapGeometry.order` is a tuple of per-ancestor triples and compares
315
+ lexicographically; the compositor sorts by exactly this. Ranking rather
316
+ than publishing the tuple keeps the wire field an integer without losing
317
+ the ordering it encodes.
318
+ """
319
+ ordered = [
320
+ item
321
+ for item in observations
322
+ if item.geometry is not None and getattr(item.geometry, "order", None) is not None
323
+ ]
324
+ try:
325
+ ordered.sort(key=lambda item: item.geometry.order)
326
+ except TypeError:
327
+ # Mixed key shapes across Textual versions: no honest ranking, so no
328
+ # claim of one. Every node then reports occlusion 'unknown'.
329
+ return
330
+ for rank, item in enumerate(ordered):
331
+ item.paint_order = rank
332
+
333
+
334
+ def build_snapshot(
335
+ app: Any,
336
+ identities: Identities,
337
+ *,
338
+ session_id: str,
339
+ revision: int,
340
+ qualified: bool = False,
341
+ ) -> SemanticSnapshot:
342
+ """The semantic tree for the frame that just landed."""
343
+ observations = observe(app)
344
+ included = {id(item.widget) for item in observations}
345
+ annotations = {id(item.widget): _probe_annotation(item.widget) for item in observations}
346
+ key_counts = Counter(
347
+ annotation.key for annotation in annotations.values() if annotation.key is not None
348
+ )
349
+ # Duplicate author keys are not identities. Degrade every colliding node to
350
+ # Textual's retained object identity for this frame; keeping one by
351
+ # traversal order would be unstable, while duplicate ids reject the whole
352
+ # snapshot.
353
+ semantic_keys = {
354
+ widget_id: annotation.key
355
+ if annotation.key is not None and key_counts[annotation.key] == 1
356
+ else None
357
+ for widget_id, annotation in annotations.items()
358
+ }
359
+ screen = app.screen
360
+ focused = getattr(app, "focused", None)
361
+
362
+ nodes: List[SemanticNode] = []
363
+ root_ids: List[str] = []
364
+ for item in observations:
365
+ widget = item.widget
366
+ annotation = annotations[id(widget)]
367
+ role = role_for(widget, annotation)
368
+ semantic_key = semantic_keys[id(widget)]
369
+ node_id = identities.of(widget, semantic_key)
370
+
371
+ parent_id: Optional[str] = None
372
+ parent = getattr(widget, "parent", None)
373
+ while parent is not None and id(parent) not in included:
374
+ parent = getattr(parent, "parent", None)
375
+ if parent is not None and parent is not widget:
376
+ parent_id = identities.of(parent, semantic_keys[id(parent)])
377
+ if parent_id is None:
378
+ root_ids.append(node_id)
379
+
380
+ bounds, hidden, offscreen = _geometry_of(item)
381
+ intended = _rect(getattr(item.geometry, "region", None)) if item.geometry is not None else None
382
+ visible = _rect(getattr(item.geometry, "visible_region", None)) if item.geometry is not None else None
383
+ geometry = None
384
+ if qualified:
385
+ if not item.displayed:
386
+ absent = WireObservation(status="absent", reason="not-displayed")
387
+ geometry = NodeGeometryObservations(
388
+ displayed=WireObservation(status="known", value=False, evidence="probe"),
389
+ intendedRect=absent,
390
+ visibleRect=absent,
391
+ )
392
+ elif item.geometry is None:
393
+ unknown = WireObservation(status="unknown", reason="temporary")
394
+ geometry = NodeGeometryObservations(
395
+ displayed=WireObservation(status="known", value=True, evidence="probe"),
396
+ intendedRect=unknown,
397
+ visibleRect=unknown,
398
+ )
399
+ else:
400
+ geometry = NodeGeometryObservations(
401
+ displayed=WireObservation(status="known", value=True, evidence="probe"),
402
+ intendedRect=(WireObservation(status="known", value=intended, evidence="probe") if intended is not None else WireObservation(status="unknown", reason="not-reported")),
403
+ visibleRect=(WireObservation(status="known", value=visible, evidence="viewport-clip") if visible is not None else WireObservation(status="unsupported", capability="visible-rect", reason="framework-unobservable")),
404
+ )
405
+ annotated = _annotated_fields(annotation, semantic_key is not None)
406
+ nodes.append(
407
+ SemanticNode(
408
+ id=node_id,
409
+ parentId=parent_id,
410
+ role=role,
411
+ name=(
412
+ annotation.name
413
+ if widget is screen and annotation.name is not None
414
+ else _app_name(app)
415
+ if widget is screen
416
+ else name_for(widget, role, annotation)
417
+ ),
418
+ description=annotation.description,
419
+ testId=test_id_for(widget, annotation),
420
+ value=value_for(widget, role),
421
+ bounds=None if qualified else bounds,
422
+ state=_state_of(item, widget, role, focused, hidden, offscreen),
423
+ extended=annotation.extended,
424
+ actions=(
425
+ annotation.actions
426
+ if annotation.actions is not None
427
+ else actions_for(role)
428
+ ),
429
+ labelledBy=_relationship_ids(
430
+ annotation.labelled_by, included, identities, semantic_keys
431
+ ),
432
+ describedBy=_relationship_ids(
433
+ annotation.described_by, included, identities, semantic_keys
434
+ ),
435
+ frameworkType=type(widget).__name__ if role == "generic" else None,
436
+ occlusion=None if qualified else "unknown",
437
+ p="framework",
438
+ px=annotated or None,
439
+ geometry=geometry,
440
+ )
441
+ )
442
+
443
+ hit_regions = _hit_regions(screen, observations, identities, semantic_keys) if qualified else None
444
+
445
+ return SemanticSnapshot(
446
+ sessionId=session_id,
447
+ revision=revision,
448
+ columns=int(getattr(app, "size", _Size()).width),
449
+ rows=int(getattr(app, "size", _Size()).height),
450
+ rootIds=root_ids,
451
+ nodes=nodes,
452
+ v=2 if qualified else 1,
453
+ coordinateSpace=(WireObservation(status="known", value="viewport-cells", evidence="probe") if qualified else None),
454
+ hitGrid=(
455
+ WireObservation(status="known", value={"regions": hit_regions}, evidence="hit-grid")
456
+ if qualified and hit_regions is not None
457
+ else WireObservation(status="unsupported", capability="pointer-hit-grid", reason="framework-unobservable")
458
+ if qualified
459
+ else None
460
+ ),
461
+ )
462
+
463
+
464
+ def _hit_regions(
465
+ screen: Any,
466
+ observations: Sequence[WidgetObservation],
467
+ identities: Identities,
468
+ semantic_keys: Dict[int, Optional[str]],
469
+ ) -> Optional[List[Dict[str, Any]]]:
470
+ """Compress Textual's exact fresh-pointer recipient map into row runs."""
471
+ by_object = {id(item.widget): identities.of(item.widget, semantic_keys[id(item.widget)]) for item in observations}
472
+ width = max(0, int(getattr(getattr(screen, "size", None), "width", 0)))
473
+ height = max(0, int(getattr(getattr(screen, "size", None), "height", 0)))
474
+ regions: List[Dict[str, Any]] = []
475
+ lookup = getattr(screen, "get_widget_at", None)
476
+ if not callable(lookup):
477
+ return None
478
+ for row in range(height):
479
+ run_owner: Optional[str] = None
480
+ run_start = 0
481
+ for column in range(width + 1):
482
+ owner: Optional[str] = None
483
+ if column < width:
484
+ try:
485
+ widget, _region = lookup(column, row)
486
+ if not bool(getattr(widget, "loading", False)):
487
+ owner = by_object.get(id(widget))
488
+ if owner is None:
489
+ # A known framework recipient without a semantic id
490
+ # is not "no recipient". Refuse the entire complete
491
+ # map rather than manufacture a false empty cell.
492
+ return None
493
+ except Exception:
494
+ owner = None
495
+ if owner == run_owner:
496
+ continue
497
+ if run_owner is not None:
498
+ regions.append({"rect": {"row": row, "column": run_start, "width": column - run_start, "height": 1}, "recipientId": run_owner})
499
+ run_owner = owner
500
+ run_start = column
501
+ return regions
502
+
503
+
504
+ class _Size:
505
+ """Fallback when the app has no size yet — an unstarted app has none."""
506
+
507
+ width = 80
508
+ height = 24
509
+
510
+
511
+ def _app_name(app: Any) -> str:
512
+ title = getattr(app, "title", None)
513
+ if isinstance(title, str) and title:
514
+ return title
515
+ return type(app).__name__
516
+
517
+
518
+ def _geometry_of(item: WidgetObservation) -> Tuple[Optional[Rect], bool, bool]:
519
+ """Bounds to publish, whether the node is hidden, and whether it is offscreen.
520
+
521
+ Three cases, and the tree distinguishes all of them:
522
+
523
+ - not displayed: `hidden`, no bounds at all;
524
+ - displayed but entirely clipped: `hidden` and `offscreen`, with a
525
+ zero-area rect at its own origin — "it is somewhere, and none of it is
526
+ on screen";
527
+ - visible: the intersection of its region with the clip.
528
+ """
529
+ if not item.displayed or item.geometry is None:
530
+ return None, True, False
531
+
532
+ visible = _rect(getattr(item.geometry, "visible_region", None))
533
+ if visible is None:
534
+ # An older Textual without the property: fall back to the region and
535
+ # say so by refusing the occlusion claim elsewhere.
536
+ return _rect(getattr(item.geometry, "region", None)), False, False
537
+ if visible.width == 0 or visible.height == 0:
538
+ region = _rect(getattr(item.geometry, "region", None))
539
+ origin = region if region is not None else visible
540
+ return Rect(row=origin.row, column=origin.column, width=0, height=0), True, True
541
+ return visible, False, False
542
+
543
+
544
+ def _state_of(
545
+ item: WidgetObservation,
546
+ widget: Any,
547
+ role: str,
548
+ focused: Any,
549
+ hidden: bool,
550
+ offscreen: bool,
551
+ ) -> Optional[SemanticState]:
552
+ is_focused = focused is widget
553
+ checked: Optional[bool] = None
554
+ if role in ("checkbox", "radio"):
555
+ value = getattr(widget, "value", None)
556
+ if isinstance(value, bool):
557
+ checked = value
558
+ collapsed = getattr(widget, "collapsed", None)
559
+ read_only = getattr(widget, "read_only", None)
560
+
561
+ state = SemanticState(
562
+ # Nothing off-screen holds the focus, whatever the app still points at.
563
+ focused=True if is_focused and not hidden else None,
564
+ disabled=True if bool(getattr(widget, "disabled", False)) else None,
565
+ hidden=True if hidden else None,
566
+ # Only claimed for a node Textual laid out and then clipped entirely
567
+ # away; a widget with display off is hidden without being anywhere.
568
+ offscreen=True if offscreen else None,
569
+ checked=checked,
570
+ expanded=(not collapsed) if isinstance(collapsed, bool) else None,
571
+ multiline=True if type(widget).__name__ == "TextArea" else None,
572
+ readonly=True if read_only is True else None,
573
+ )
574
+ return state if state.to_wire() else None
575
+
576
+
577
+ def _relationship_ids(
578
+ targets: Sequence[Any],
579
+ included: set,
580
+ identities: Identities,
581
+ semantic_keys: Dict[int, Optional[str]],
582
+ ) -> Optional[Sequence[str]]:
583
+ resolved: List[str] = []
584
+ for target in targets:
585
+ target_id = id(target)
586
+ if target_id not in included:
587
+ continue
588
+ resolved.append(identities.of(target, semantic_keys[target_id]))
589
+ return tuple(resolved) if resolved else None
590
+
591
+
592
+ def _probe_annotation(widget: Any) -> ResolvedAnnotation:
593
+ """A broken optional annotation must not remove the framework's facts.
594
+
595
+ Static invalid roles are rejected when the decorator is created. Dynamic
596
+ getters can still throw or return a value of the wrong type later; the
597
+ side-channel then ignores that annotation for this frame instead of
598
+ failing the entire snapshot or reaching into the application's render.
599
+ """
600
+ try:
601
+ return resolve_annotation(widget)
602
+ except Exception:
603
+ return ResolvedAnnotation()
604
+
605
+
606
+ def _annotated_fields(
607
+ annotation: ResolvedAnnotation, semantic_key_applied: bool = True
608
+ ) -> Dict[str, str]:
609
+ """Per-field provenance for whatever the author annotated by hand.
610
+
611
+ The node as a whole is `framework` — we read it from Textual — but a name
612
+ or a test id the author wrote is theirs, and a consumer resolving a
613
+ conflict needs to know which is which.
614
+ """
615
+ annotated: Dict[str, str] = {}
616
+ if annotation.name is not None:
617
+ annotated["name"] = "annotation"
618
+ if annotation.role is not None:
619
+ annotated["role"] = "annotation"
620
+ if annotation.description is not None:
621
+ annotated["description"] = "annotation"
622
+ if annotation.test_id is not None:
623
+ annotated["testId"] = "annotation"
624
+ if annotation.extended is not None:
625
+ annotated["extended"] = "annotation"
626
+ if annotation.labelled_by:
627
+ annotated["labelledBy"] = "annotation"
628
+ if annotation.described_by:
629
+ annotated["describedBy"] = "annotation"
630
+ if annotation.actions is not None:
631
+ annotated["actions"] = "annotation"
632
+ if semantic_key_applied and annotation.key is not None:
633
+ annotated["id"] = "annotation"
634
+ return annotated