termwright 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. termwright-0.2.0/.gitignore +7 -0
  2. termwright-0.2.0/PKG-INFO +336 -0
  3. termwright-0.2.0/README.md +312 -0
  4. termwright-0.2.0/examples/permission_app.py +57 -0
  5. termwright-0.2.0/pyproject.toml +37 -0
  6. termwright-0.2.0/src/termwright/__init__.py +125 -0
  7. termwright-0.2.0/src/termwright/client.py +708 -0
  8. termwright-0.2.0/src/termwright/debug.py +169 -0
  9. termwright-0.2.0/src/termwright/diffing.py +118 -0
  10. termwright-0.2.0/src/termwright/errors.py +20 -0
  11. termwright-0.2.0/src/termwright/framing.py +196 -0
  12. termwright-0.2.0/src/termwright/limits.py +82 -0
  13. termwright-0.2.0/src/termwright/logging_bridge.py +108 -0
  14. termwright-0.2.0/src/termwright/logs.py +207 -0
  15. termwright-0.2.0/src/termwright/marker.py +128 -0
  16. termwright-0.2.0/src/termwright/messages.py +417 -0
  17. termwright-0.2.0/src/termwright/roles.py +57 -0
  18. termwright-0.2.0/src/termwright/textual.py +249 -0
  19. termwright-0.2.0/src/termwright/tree.py +282 -0
  20. termwright-0.2.0/src/termwright/validate.py +846 -0
  21. termwright-0.2.0/src/termwright_probe/__init__.py +73 -0
  22. termwright-0.2.0/src/termwright_probe/__main__.py +45 -0
  23. termwright-0.2.0/src/termwright_probe/bootstrap.py +207 -0
  24. termwright-0.2.0/src/termwright_probe/defer.py +130 -0
  25. termwright-0.2.0/src/termwright_probe/session.py +197 -0
  26. termwright-0.2.0/src/termwright_probe/textual_probe.py +239 -0
  27. termwright-0.2.0/src/termwright_probe/textual_tree.py +634 -0
  28. termwright-0.2.0/tests/conftest.py +32 -0
  29. termwright-0.2.0/tests/fixtures/vanilla_textual_app.py +32 -0
  30. termwright-0.2.0/tests/test_client.py +507 -0
  31. termwright-0.2.0/tests/test_debug.py +203 -0
  32. termwright-0.2.0/tests/test_deltas.py +117 -0
  33. termwright-0.2.0/tests/test_diffing.py +233 -0
  34. termwright-0.2.0/tests/test_framing.py +67 -0
  35. termwright-0.2.0/tests/test_logs.py +290 -0
  36. termwright-0.2.0/tests/test_marker.py +82 -0
  37. termwright-0.2.0/tests/test_messages.py +117 -0
  38. termwright-0.2.0/tests/test_observation_vectors.py +23 -0
  39. termwright-0.2.0/tests/test_probe_bootstrap.py +373 -0
  40. termwright-0.2.0/tests/test_probe_golden.py +135 -0
  41. termwright-0.2.0/tests/test_probe_session.py +317 -0
  42. termwright-0.2.0/tests/test_probe_tree.py +339 -0
  43. termwright-0.2.0/tests/test_textual_annotations.py +196 -0
  44. termwright-0.2.0/tests/test_textual_probe_hook.py +54 -0
  45. termwright-0.2.0/tests/test_validate.py +142 -0
  46. termwright-0.2.0/uv.lock +444 -0
@@ -0,0 +1,7 @@
1
+ # Rust
2
+ rust/target/
3
+ # Python
4
+ python/*.egg-info/
5
+ python/build/
6
+ __pycache__/
7
+ .pytest_cache/
@@ -0,0 +1,336 @@
1
+ Metadata-Version: 2.5
2
+ Name: termwright
3
+ Version: 0.2.0
4
+ Summary: Semantic side-channel client, automatic Textual probe, and annotation SDK for Termwright
5
+ Project-URL: Homepage, https://github.com/gorce-ai/termwright
6
+ Project-URL: Source, https://github.com/gorce-ai/termwright/tree/main/clients/python
7
+ Author: termwright contributors
8
+ License: MIT
9
+ Keywords: accessibility,terminal,testing,textual,tui
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Software Development :: Testing
16
+ Requires-Python: >=3.9
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
19
+ Requires-Dist: pytest>=7; extra == 'dev'
20
+ Requires-Dist: textual>=0.60; extra == 'dev'
21
+ Provides-Extra: textual
22
+ Requires-Dist: textual>=0.60; extra == 'textual'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # termwright (Python)
26
+
27
+ Semantic side-channel client, automatic probe and optional annotation SDK for
28
+ [Textual](https://textual.textualize.io).
29
+
30
+ An instrumented app publishes its widget tree over a unix socket and commits
31
+ each render with a signed OSC marker, so the driver can assert on *roles and
32
+ names* instead of screen-scraping cells.
33
+
34
+ **Dormant rule.** Without `TERMWRIGHT_ENDPOINT` and `TERMWRIGHT_TOKEN` the
35
+ probe installs nothing, opens no socket, writes no marker, and renders exactly
36
+ the bytes it would have rendered anyway.
37
+
38
+ ## Install
39
+
40
+ ```sh
41
+ pip install termwright # protocol client + probe + annotation SDK
42
+ pip install "termwright[textual]" # + Textual itself
43
+ ```
44
+
45
+ Requires Python 3.9+. The protocol modules have no third-party dependencies.
46
+
47
+ ## Automatic Textual semantics
48
+
49
+ The application is ordinary Textual code with no Termwright import:
50
+
51
+ ```python
52
+ from textual.app import App, ComposeResult
53
+ from textual.widgets import Button, Input, Label
54
+
55
+ class PermissionApp(App):
56
+ def compose(self) -> ComposeResult:
57
+ yield Label("Allow bash to run?", id="prompt")
58
+ yield Button("Approve", id="approve")
59
+ yield Button("Reject", id="reject")
60
+ yield Input(placeholder="Reason", id="reason")
61
+
62
+ if __name__ == "__main__":
63
+ PermissionApp().run()
64
+ ```
65
+
66
+ The Termwright launcher injects the probe at Python startup. The direct form,
67
+ useful for a custom runner, is:
68
+
69
+ ```sh
70
+ python -m termwright_probe -- python app.py
71
+ ```
72
+
73
+ Under the driver this publishes, after every flushed frame:
74
+
75
+ ```
76
+ application "PermissionApp"
77
+ region bounds=(0,0,80,24)
78
+ text "Allow bash to run?" bounds=(0,0,80,1) testId=prompt
79
+ button "Approve" bounds=(1,0,80,3) testId=approve [focused]
80
+ button "Reject" bounds=(4,0,80,3) testId=reject
81
+ textbox "Reason" bounds=(7,0,80,3) testId=reason
82
+ ```
83
+
84
+ ### Roles and names
85
+
86
+ Roles come from the widget class, matched along the MRO, so your own
87
+ `class SaveButton(Button)` is a `button` without any configuration. `Input` and
88
+ `TextArea` map to `textbox`, `DataTable` to `table`, `ListView`/`OptionList` to
89
+ `list`, `Label`/`Static` to `text`, containers to `region`, `ModalScreen` to
90
+ `dialog`. Names come from the widget's label, placeholder, content, `name`, or DOM `id`,
91
+ in that order; the DOM `id` is also published as `testId`.
92
+
93
+ Roles that ARIA names from content — `listitem`, `menuitem`, `tab`, `button`,
94
+ `checkbox`, `radio`, `cell`, `row`, `heading` — fall back to the text of what
95
+ they contain before they fall back to the id. That is what makes a Textual
96
+ `ListItem(Label("Open settings"))` addressable as
97
+ `getByRole('listitem', { name: 'Open settings' })`: the item holds no text of
98
+ its own, the `Label` inside does. Containers are never named this way — a
99
+ `region` would otherwise be named by everything on the screen.
100
+
101
+ ### Custom widgets
102
+
103
+ Automatic geometry, focus, visibility and framework state still come from
104
+ Textual. A decorator supplies only application intent that the framework
105
+ cannot know:
106
+
107
+ ```python
108
+ from termwright.textual import semantic
109
+
110
+ @semantic(
111
+ role="button",
112
+ name=lambda widget: f"Deploy {widget.environment}",
113
+ test_id="deploy-production",
114
+ extended=lambda widget: {"environment": widget.environment},
115
+ actions=("focus", "activate"),
116
+ key=lambda widget: f"deployment:{widget.environment}",
117
+ )
118
+ class DeployWidget(Widget):
119
+ ...
120
+ ```
121
+
122
+ `name`, `description`, `test_id`, `extended`, `labelled_by`, `described_by`,
123
+ `actions` and `key` accept either constants or callables receiving the
124
+ live widget. The declaration is inherited by subclasses. For a third-party
125
+ instance use `annotate(widget, ...)`; the registry is weak and does not keep a
126
+ discarded widget alive.
127
+
128
+ `labelled_by` and `described_by` may return a widget or a sequence of widgets.
129
+ `key` is the stable semantic identity for a domain component that Textual may
130
+ recreate. `actions` uses the protocol's closed descriptive vocabulary; it never
131
+ registers an out-of-band callback, and interaction still becomes real PTY
132
+ input.
133
+
134
+ The API intentionally has no bounds, focus, visibility, rendered-text or
135
+ portable-state arguments. Those physical facts remain probe-owned, and merge
136
+ tests enforce that the annotation cannot replace them.
137
+
138
+ ### Coexisting with `Pilot`
139
+
140
+ The probe only reads the DOM from `post_display_hook`, so `run_test()` and
141
+ `Pilot` keep working unchanged — semantic tests and pilot tests live in the same
142
+ suite.
143
+
144
+ ## Without Textual
145
+
146
+ Any TUI can drive the client directly. You own the render; the client owns the
147
+ revision numbers and hands you the marker to write after the render's last byte.
148
+
149
+ ```python
150
+ from termwright import SemanticNode, SemanticSnapshot, Rect, client_from_env
151
+
152
+ client = client_from_env(adapter_name="my-tui", adapter_version="1.0.0")
153
+ if client is not None and await client.start():
154
+ marker = await client.publish(
155
+ SemanticSnapshot(
156
+ sessionId="", revision=0, columns=80, rows=24, # both are overwritten
157
+ rootIds=["root"],
158
+ nodes=[
159
+ SemanticNode(id="root", role="dialog", name="Permission"),
160
+ SemanticNode(id="ok", parentId="root", role="button", name="Approve",
161
+ bounds=Rect(row=1, column=2, width=9, height=1)),
162
+ ],
163
+ )
164
+ )
165
+ sys.stdout.write(marker) # only after the render is fully written
166
+ sys.stdout.flush()
167
+ ```
168
+
169
+ `publish_nowait` is the same thing for synchronous render callbacks: it returns
170
+ the marker immediately and sends the frames on a background task, in order.
171
+
172
+ ## Application logs
173
+
174
+ ```python
175
+ from termwright import client_from_env
176
+ from termwright.client import CAPABILITIES_WITH_LOGS
177
+ from termwright.logging_bridge import install_log_handler
178
+
179
+ client = client_from_env(adapter_name="my-tui", adapter_version="1.0.0",
180
+ capabilities=CAPABILITIES_WITH_LOGS)
181
+ if client is not None and await client.start():
182
+ install_log_handler(client) # every logging call now reaches the driver
183
+ ```
184
+
185
+ `install_log_handler(None)` is a no-op, so an app can call it unconditionally.
186
+ Levels map onto the wire's closed ladder (anything below `DEBUG` is `trace`,
187
+ `CRITICAL` is `fatal`), `extra=` fields become flat dotted attributes, and the
188
+ client drops what the budget does not allow — leaving a gap in `seq` so the
189
+ driver can report the loss.
190
+
191
+ ## Diagnostics
192
+
193
+ When the adapter does not attach, nothing anywhere says why: the dormant rule
194
+ means a process with no endpoint behaves exactly like a process that never
195
+ heard of termwright. Point `TERMWRIGHT_DEBUG_FILE` at a file and the adapter
196
+ writes down what it decided.
197
+
198
+ ```
199
+ TERMWRIGHT_DEBUG_FILE=/tmp/adapter.log
200
+ ```
201
+
202
+ ```text
203
+ tw:diag [p41207] 0.000s open adapter=textual pid=41207 platform=darwin python=3.12 argv0=app.py
204
+ tw:diag [p41207] 0.001s dormant: TERMWRIGHT_TOKEN not set
205
+ ```
206
+
207
+ or, on a session that came up:
208
+
209
+ ```text
210
+ tw:sem [p41207] 0.002s dial unix:/tmp/tw-8f21/s timeout=5000ms
211
+ tw:sem [p41207] 0.003s hello sent adapter=textual/1.0.0 caps=tree,bounds,…
212
+ tw:sem [3f9c1a04] 0.011s hello-ack session=3f9c1a04… marker=on subscribe=diffs logs=off
213
+ tw:io [3f9c1a04] 0.048s r1 snapshot nodes=17
214
+ ```
215
+
216
+ Three properties are worth knowing before you rely on it:
217
+
218
+ - **It never writes to stderr.** The application owns the terminal, and a
219
+ diagnostic line in the middle of a render corrupts the screen the driver is
220
+ asserting on. There is no stderr mode to turn on by mistake.
221
+ - **It never fails the application.** An unwritable path, a full disk or a
222
+ closed file turns the log off and changes nothing else.
223
+ - **The token never appears in it.** The endpoint does, because the endpoint
224
+ is how you tell one session's socket from another's.
225
+
226
+ `TERMWRIGHT_DEBUG=<path>` works too, for symmetry with the driver's own
227
+ switch. `TERMWRIGHT_DEBUG=1` does **not**: that value means "log to stderr" to
228
+ the driver, it reaches this process as well, and stderr is the one destination
229
+ an adapter cannot use. Set the value to a path or the adapter stays silent.
230
+
231
+ The line format is the driver's, so `TERMWRIGHT_DEBUG=1` on the driver and
232
+ `TERMWRIGHT_DEBUG_FILE=…` on the app produce two halves of one story that a
233
+ single reader can take.
234
+
235
+ ## Injection details
236
+
237
+ ```sh
238
+ python -m termwright_probe -- python app.py
239
+ ```
240
+
241
+ `app.py` imports no termwright, calls nothing of ours, and is not edited. The
242
+ launcher puts a generated `sitecustomize.py` on `PYTHONPATH`; CPython imports
243
+ it during startup, before the script's own directory reaches `sys.path`; the
244
+ probe waits there until the application imports Textual and attaches to
245
+ `App.post_display_hook`. A driver that already sets `TERMWRIGHT_ENDPOINT` and
246
+ `TERMWRIGHT_TOKEN` can compose the same thing itself:
247
+
248
+ ```python
249
+ from termwright_probe import with_probe
250
+
251
+ command, env, bootstrap = with_probe(["python", "app.py"])
252
+ # run `command` with `env`; call bootstrap.cleanup() when the session ends
253
+ ```
254
+
255
+ Nothing is written into the project. The temporary directory holds one
256
+ generated file and is named only in the child's environment.
257
+
258
+ **Dormant without instrumentation.** No endpoint and no token means the
259
+ launcher creates no directory, and the generated module — if one survived from
260
+ an earlier run — installs nothing. A test runs the same application on a pty
261
+ with and without the bootstrap and compares the two byte streams: they are
262
+ identical. A second test compares an instrumented run against the baseline
263
+ after removing the render-commit markers, and those are identical too, which is
264
+ the claim that the probe observes rather than redraws.
265
+
266
+ **What it reports automatically.**
267
+
268
+ | Fact | Where it comes from |
269
+ |---|---|
270
+ | `bounds` = what is on screen | `MapGeometry.visible_region`, Textual's `clip ∩ region` |
271
+ | `occlusion: "known"` | widgets ranked by `MapGeometry.order`, the compositor's own sort key |
272
+ | roles for your own widget classes | the MRO, so `SaveButton(Button)` is a button with no registration |
273
+ | `frameworkType` on anything unrecognised | the widget's class name |
274
+ | scrolled out of view vs `display = False` | both `hidden`; the first also `state.offscreen`, with a zero-area rect |
275
+
276
+ Because paint order is real here, the driver allows pointer actions against
277
+ Textual nodes; it refuses them for producers that cannot say whether a node's
278
+ cells are covered.
279
+
280
+ **Where the injection reaches**, measured on CPython 3.12 (see
281
+ `docs/architecture/audit/textual.md` for the full table): a plain script,
282
+ `-m`, `-c`, a console-script entry point and `uv run` all work. `python -S`
283
+ and `python -E` do not, and are not meant to — the first disables `site`
284
+ entirely and the second makes the interpreter ignore `PYTHONPATH`. Both are
285
+ the person running the interpreter opting out.
286
+
287
+ ## Deviations
288
+
289
+ Measured against the probe conventions in the protocol README. Everything
290
+ not listed here follows them.
291
+
292
+ - **Windows support is written, not yet observed here.** A `\\.\pipe\…`
293
+ endpoint is opened through the proactor loop's `create_pipe_connection`,
294
+ which exists only on Windows; every test in this repository runs on POSIX,
295
+ so the verdict for a live pipe comes from CI. On a loop without that method
296
+ the connect fails quietly and the application keeps rendering.
297
+
298
+ - **`multiline` is derived from the widget type, not a flag** (rule 4). Textual
299
+ has no `multiline` property: `TextArea` accepts newlines and `Input` does
300
+ not, as a matter of what the classes are. The state is published from the
301
+ type for that reason and for no other — no other state here is inferred.
302
+ - **Widgets on an inactive screen are absent, not `hidden`** (rule 4). The
303
+ probe walks `app.screen`, so a pushed-over screen's widgets are not in the
304
+ tree at all. A widget hidden on the *active* screen (`display = False`) does
305
+ publish `hidden: true`. Textual owns the screen stack; reaching into it would
306
+ mean publishing widgets that no longer receive events.
307
+ - **`poetry` is unverified.** Poetry was not installed on the machine where the
308
+ injection table was measured. It runs the interpreter from the project venv
309
+ as a subprocess and passes the environment through, so it is *expected* to
310
+ behave like the venv row, and that expectation has not been confirmed. Treat
311
+ a poetry-run application as untested for the probe until somebody watches it
312
+ work.
313
+ - **The probe instruments grandchildren too.** `PYTHONPATH` is inherited, so a
314
+ process the application spawns is also instrumented unless the variable is
315
+ scrubbed. Visible to the application as well: it can read its own
316
+ environment. This is a property of the injection mechanism, not a decision.
317
+ - **The probe does not report `frame-begin`** (probe capability). Textual calls
318
+ `post_display_hook` from the `finally` of `App._display`, *after* the frame
319
+ has been flushed, so there is no instant the probe could honestly call the
320
+ start of a frame. Consumers must not read a missing `frame-begin` as "no
321
+ frame in progress".
322
+ - **A `Static` subclass with a custom `render()` is named by its `content`**
323
+ (rule 2), which is the markup it was given rather than what it draws. Textual
324
+ renders to a strip of segments with no text handle the adapter can read.
325
+
326
+ ## Conformance
327
+
328
+ `tests/` runs against `clients/test-vectors/`, which is generated from the
329
+ normative TypeScript implementation in `packages/protocol`. Framing bytes,
330
+ marker MACs, message parsing and snapshot validation are all asserted against
331
+ the same vectors in Python, Go and Rust.
332
+
333
+ ```sh
334
+ pip install -e ".[dev]"
335
+ pytest
336
+ ```
@@ -0,0 +1,312 @@
1
+ # termwright (Python)
2
+
3
+ Semantic side-channel client, automatic probe and optional annotation SDK for
4
+ [Textual](https://textual.textualize.io).
5
+
6
+ An instrumented app publishes its widget tree over a unix socket and commits
7
+ each render with a signed OSC marker, so the driver can assert on *roles and
8
+ names* instead of screen-scraping cells.
9
+
10
+ **Dormant rule.** Without `TERMWRIGHT_ENDPOINT` and `TERMWRIGHT_TOKEN` the
11
+ probe installs nothing, opens no socket, writes no marker, and renders exactly
12
+ the bytes it would have rendered anyway.
13
+
14
+ ## Install
15
+
16
+ ```sh
17
+ pip install termwright # protocol client + probe + annotation SDK
18
+ pip install "termwright[textual]" # + Textual itself
19
+ ```
20
+
21
+ Requires Python 3.9+. The protocol modules have no third-party dependencies.
22
+
23
+ ## Automatic Textual semantics
24
+
25
+ The application is ordinary Textual code with no Termwright import:
26
+
27
+ ```python
28
+ from textual.app import App, ComposeResult
29
+ from textual.widgets import Button, Input, Label
30
+
31
+ class PermissionApp(App):
32
+ def compose(self) -> ComposeResult:
33
+ yield Label("Allow bash to run?", id="prompt")
34
+ yield Button("Approve", id="approve")
35
+ yield Button("Reject", id="reject")
36
+ yield Input(placeholder="Reason", id="reason")
37
+
38
+ if __name__ == "__main__":
39
+ PermissionApp().run()
40
+ ```
41
+
42
+ The Termwright launcher injects the probe at Python startup. The direct form,
43
+ useful for a custom runner, is:
44
+
45
+ ```sh
46
+ python -m termwright_probe -- python app.py
47
+ ```
48
+
49
+ Under the driver this publishes, after every flushed frame:
50
+
51
+ ```
52
+ application "PermissionApp"
53
+ region bounds=(0,0,80,24)
54
+ text "Allow bash to run?" bounds=(0,0,80,1) testId=prompt
55
+ button "Approve" bounds=(1,0,80,3) testId=approve [focused]
56
+ button "Reject" bounds=(4,0,80,3) testId=reject
57
+ textbox "Reason" bounds=(7,0,80,3) testId=reason
58
+ ```
59
+
60
+ ### Roles and names
61
+
62
+ Roles come from the widget class, matched along the MRO, so your own
63
+ `class SaveButton(Button)` is a `button` without any configuration. `Input` and
64
+ `TextArea` map to `textbox`, `DataTable` to `table`, `ListView`/`OptionList` to
65
+ `list`, `Label`/`Static` to `text`, containers to `region`, `ModalScreen` to
66
+ `dialog`. Names come from the widget's label, placeholder, content, `name`, or DOM `id`,
67
+ in that order; the DOM `id` is also published as `testId`.
68
+
69
+ Roles that ARIA names from content — `listitem`, `menuitem`, `tab`, `button`,
70
+ `checkbox`, `radio`, `cell`, `row`, `heading` — fall back to the text of what
71
+ they contain before they fall back to the id. That is what makes a Textual
72
+ `ListItem(Label("Open settings"))` addressable as
73
+ `getByRole('listitem', { name: 'Open settings' })`: the item holds no text of
74
+ its own, the `Label` inside does. Containers are never named this way — a
75
+ `region` would otherwise be named by everything on the screen.
76
+
77
+ ### Custom widgets
78
+
79
+ Automatic geometry, focus, visibility and framework state still come from
80
+ Textual. A decorator supplies only application intent that the framework
81
+ cannot know:
82
+
83
+ ```python
84
+ from termwright.textual import semantic
85
+
86
+ @semantic(
87
+ role="button",
88
+ name=lambda widget: f"Deploy {widget.environment}",
89
+ test_id="deploy-production",
90
+ extended=lambda widget: {"environment": widget.environment},
91
+ actions=("focus", "activate"),
92
+ key=lambda widget: f"deployment:{widget.environment}",
93
+ )
94
+ class DeployWidget(Widget):
95
+ ...
96
+ ```
97
+
98
+ `name`, `description`, `test_id`, `extended`, `labelled_by`, `described_by`,
99
+ `actions` and `key` accept either constants or callables receiving the
100
+ live widget. The declaration is inherited by subclasses. For a third-party
101
+ instance use `annotate(widget, ...)`; the registry is weak and does not keep a
102
+ discarded widget alive.
103
+
104
+ `labelled_by` and `described_by` may return a widget or a sequence of widgets.
105
+ `key` is the stable semantic identity for a domain component that Textual may
106
+ recreate. `actions` uses the protocol's closed descriptive vocabulary; it never
107
+ registers an out-of-band callback, and interaction still becomes real PTY
108
+ input.
109
+
110
+ The API intentionally has no bounds, focus, visibility, rendered-text or
111
+ portable-state arguments. Those physical facts remain probe-owned, and merge
112
+ tests enforce that the annotation cannot replace them.
113
+
114
+ ### Coexisting with `Pilot`
115
+
116
+ The probe only reads the DOM from `post_display_hook`, so `run_test()` and
117
+ `Pilot` keep working unchanged — semantic tests and pilot tests live in the same
118
+ suite.
119
+
120
+ ## Without Textual
121
+
122
+ Any TUI can drive the client directly. You own the render; the client owns the
123
+ revision numbers and hands you the marker to write after the render's last byte.
124
+
125
+ ```python
126
+ from termwright import SemanticNode, SemanticSnapshot, Rect, client_from_env
127
+
128
+ client = client_from_env(adapter_name="my-tui", adapter_version="1.0.0")
129
+ if client is not None and await client.start():
130
+ marker = await client.publish(
131
+ SemanticSnapshot(
132
+ sessionId="", revision=0, columns=80, rows=24, # both are overwritten
133
+ rootIds=["root"],
134
+ nodes=[
135
+ SemanticNode(id="root", role="dialog", name="Permission"),
136
+ SemanticNode(id="ok", parentId="root", role="button", name="Approve",
137
+ bounds=Rect(row=1, column=2, width=9, height=1)),
138
+ ],
139
+ )
140
+ )
141
+ sys.stdout.write(marker) # only after the render is fully written
142
+ sys.stdout.flush()
143
+ ```
144
+
145
+ `publish_nowait` is the same thing for synchronous render callbacks: it returns
146
+ the marker immediately and sends the frames on a background task, in order.
147
+
148
+ ## Application logs
149
+
150
+ ```python
151
+ from termwright import client_from_env
152
+ from termwright.client import CAPABILITIES_WITH_LOGS
153
+ from termwright.logging_bridge import install_log_handler
154
+
155
+ client = client_from_env(adapter_name="my-tui", adapter_version="1.0.0",
156
+ capabilities=CAPABILITIES_WITH_LOGS)
157
+ if client is not None and await client.start():
158
+ install_log_handler(client) # every logging call now reaches the driver
159
+ ```
160
+
161
+ `install_log_handler(None)` is a no-op, so an app can call it unconditionally.
162
+ Levels map onto the wire's closed ladder (anything below `DEBUG` is `trace`,
163
+ `CRITICAL` is `fatal`), `extra=` fields become flat dotted attributes, and the
164
+ client drops what the budget does not allow — leaving a gap in `seq` so the
165
+ driver can report the loss.
166
+
167
+ ## Diagnostics
168
+
169
+ When the adapter does not attach, nothing anywhere says why: the dormant rule
170
+ means a process with no endpoint behaves exactly like a process that never
171
+ heard of termwright. Point `TERMWRIGHT_DEBUG_FILE` at a file and the adapter
172
+ writes down what it decided.
173
+
174
+ ```
175
+ TERMWRIGHT_DEBUG_FILE=/tmp/adapter.log
176
+ ```
177
+
178
+ ```text
179
+ tw:diag [p41207] 0.000s open adapter=textual pid=41207 platform=darwin python=3.12 argv0=app.py
180
+ tw:diag [p41207] 0.001s dormant: TERMWRIGHT_TOKEN not set
181
+ ```
182
+
183
+ or, on a session that came up:
184
+
185
+ ```text
186
+ tw:sem [p41207] 0.002s dial unix:/tmp/tw-8f21/s timeout=5000ms
187
+ tw:sem [p41207] 0.003s hello sent adapter=textual/1.0.0 caps=tree,bounds,…
188
+ tw:sem [3f9c1a04] 0.011s hello-ack session=3f9c1a04… marker=on subscribe=diffs logs=off
189
+ tw:io [3f9c1a04] 0.048s r1 snapshot nodes=17
190
+ ```
191
+
192
+ Three properties are worth knowing before you rely on it:
193
+
194
+ - **It never writes to stderr.** The application owns the terminal, and a
195
+ diagnostic line in the middle of a render corrupts the screen the driver is
196
+ asserting on. There is no stderr mode to turn on by mistake.
197
+ - **It never fails the application.** An unwritable path, a full disk or a
198
+ closed file turns the log off and changes nothing else.
199
+ - **The token never appears in it.** The endpoint does, because the endpoint
200
+ is how you tell one session's socket from another's.
201
+
202
+ `TERMWRIGHT_DEBUG=<path>` works too, for symmetry with the driver's own
203
+ switch. `TERMWRIGHT_DEBUG=1` does **not**: that value means "log to stderr" to
204
+ the driver, it reaches this process as well, and stderr is the one destination
205
+ an adapter cannot use. Set the value to a path or the adapter stays silent.
206
+
207
+ The line format is the driver's, so `TERMWRIGHT_DEBUG=1` on the driver and
208
+ `TERMWRIGHT_DEBUG_FILE=…` on the app produce two halves of one story that a
209
+ single reader can take.
210
+
211
+ ## Injection details
212
+
213
+ ```sh
214
+ python -m termwright_probe -- python app.py
215
+ ```
216
+
217
+ `app.py` imports no termwright, calls nothing of ours, and is not edited. The
218
+ launcher puts a generated `sitecustomize.py` on `PYTHONPATH`; CPython imports
219
+ it during startup, before the script's own directory reaches `sys.path`; the
220
+ probe waits there until the application imports Textual and attaches to
221
+ `App.post_display_hook`. A driver that already sets `TERMWRIGHT_ENDPOINT` and
222
+ `TERMWRIGHT_TOKEN` can compose the same thing itself:
223
+
224
+ ```python
225
+ from termwright_probe import with_probe
226
+
227
+ command, env, bootstrap = with_probe(["python", "app.py"])
228
+ # run `command` with `env`; call bootstrap.cleanup() when the session ends
229
+ ```
230
+
231
+ Nothing is written into the project. The temporary directory holds one
232
+ generated file and is named only in the child's environment.
233
+
234
+ **Dormant without instrumentation.** No endpoint and no token means the
235
+ launcher creates no directory, and the generated module — if one survived from
236
+ an earlier run — installs nothing. A test runs the same application on a pty
237
+ with and without the bootstrap and compares the two byte streams: they are
238
+ identical. A second test compares an instrumented run against the baseline
239
+ after removing the render-commit markers, and those are identical too, which is
240
+ the claim that the probe observes rather than redraws.
241
+
242
+ **What it reports automatically.**
243
+
244
+ | Fact | Where it comes from |
245
+ |---|---|
246
+ | `bounds` = what is on screen | `MapGeometry.visible_region`, Textual's `clip ∩ region` |
247
+ | `occlusion: "known"` | widgets ranked by `MapGeometry.order`, the compositor's own sort key |
248
+ | roles for your own widget classes | the MRO, so `SaveButton(Button)` is a button with no registration |
249
+ | `frameworkType` on anything unrecognised | the widget's class name |
250
+ | scrolled out of view vs `display = False` | both `hidden`; the first also `state.offscreen`, with a zero-area rect |
251
+
252
+ Because paint order is real here, the driver allows pointer actions against
253
+ Textual nodes; it refuses them for producers that cannot say whether a node's
254
+ cells are covered.
255
+
256
+ **Where the injection reaches**, measured on CPython 3.12 (see
257
+ `docs/architecture/audit/textual.md` for the full table): a plain script,
258
+ `-m`, `-c`, a console-script entry point and `uv run` all work. `python -S`
259
+ and `python -E` do not, and are not meant to — the first disables `site`
260
+ entirely and the second makes the interpreter ignore `PYTHONPATH`. Both are
261
+ the person running the interpreter opting out.
262
+
263
+ ## Deviations
264
+
265
+ Measured against the probe conventions in the protocol README. Everything
266
+ not listed here follows them.
267
+
268
+ - **Windows support is written, not yet observed here.** A `\\.\pipe\…`
269
+ endpoint is opened through the proactor loop's `create_pipe_connection`,
270
+ which exists only on Windows; every test in this repository runs on POSIX,
271
+ so the verdict for a live pipe comes from CI. On a loop without that method
272
+ the connect fails quietly and the application keeps rendering.
273
+
274
+ - **`multiline` is derived from the widget type, not a flag** (rule 4). Textual
275
+ has no `multiline` property: `TextArea` accepts newlines and `Input` does
276
+ not, as a matter of what the classes are. The state is published from the
277
+ type for that reason and for no other — no other state here is inferred.
278
+ - **Widgets on an inactive screen are absent, not `hidden`** (rule 4). The
279
+ probe walks `app.screen`, so a pushed-over screen's widgets are not in the
280
+ tree at all. A widget hidden on the *active* screen (`display = False`) does
281
+ publish `hidden: true`. Textual owns the screen stack; reaching into it would
282
+ mean publishing widgets that no longer receive events.
283
+ - **`poetry` is unverified.** Poetry was not installed on the machine where the
284
+ injection table was measured. It runs the interpreter from the project venv
285
+ as a subprocess and passes the environment through, so it is *expected* to
286
+ behave like the venv row, and that expectation has not been confirmed. Treat
287
+ a poetry-run application as untested for the probe until somebody watches it
288
+ work.
289
+ - **The probe instruments grandchildren too.** `PYTHONPATH` is inherited, so a
290
+ process the application spawns is also instrumented unless the variable is
291
+ scrubbed. Visible to the application as well: it can read its own
292
+ environment. This is a property of the injection mechanism, not a decision.
293
+ - **The probe does not report `frame-begin`** (probe capability). Textual calls
294
+ `post_display_hook` from the `finally` of `App._display`, *after* the frame
295
+ has been flushed, so there is no instant the probe could honestly call the
296
+ start of a frame. Consumers must not read a missing `frame-begin` as "no
297
+ frame in progress".
298
+ - **A `Static` subclass with a custom `render()` is named by its `content`**
299
+ (rule 2), which is the markup it was given rather than what it draws. Textual
300
+ renders to a strip of segments with no text handle the adapter can read.
301
+
302
+ ## Conformance
303
+
304
+ `tests/` runs against `clients/test-vectors/`, which is generated from the
305
+ normative TypeScript implementation in `packages/protocol`. Framing bytes,
306
+ marker MACs, message parsing and snapshot validation are all asserted against
307
+ the same vectors in Python, Go and Rust.
308
+
309
+ ```sh
310
+ pip install -e ".[dev]"
311
+ pytest
312
+ ```