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.
- termwright-0.2.0/.gitignore +7 -0
- termwright-0.2.0/PKG-INFO +336 -0
- termwright-0.2.0/README.md +312 -0
- termwright-0.2.0/examples/permission_app.py +57 -0
- termwright-0.2.0/pyproject.toml +37 -0
- termwright-0.2.0/src/termwright/__init__.py +125 -0
- termwright-0.2.0/src/termwright/client.py +708 -0
- termwright-0.2.0/src/termwright/debug.py +169 -0
- termwright-0.2.0/src/termwright/diffing.py +118 -0
- termwright-0.2.0/src/termwright/errors.py +20 -0
- termwright-0.2.0/src/termwright/framing.py +196 -0
- termwright-0.2.0/src/termwright/limits.py +82 -0
- termwright-0.2.0/src/termwright/logging_bridge.py +108 -0
- termwright-0.2.0/src/termwright/logs.py +207 -0
- termwright-0.2.0/src/termwright/marker.py +128 -0
- termwright-0.2.0/src/termwright/messages.py +417 -0
- termwright-0.2.0/src/termwright/roles.py +57 -0
- termwright-0.2.0/src/termwright/textual.py +249 -0
- termwright-0.2.0/src/termwright/tree.py +282 -0
- termwright-0.2.0/src/termwright/validate.py +846 -0
- termwright-0.2.0/src/termwright_probe/__init__.py +73 -0
- termwright-0.2.0/src/termwright_probe/__main__.py +45 -0
- termwright-0.2.0/src/termwright_probe/bootstrap.py +207 -0
- termwright-0.2.0/src/termwright_probe/defer.py +130 -0
- termwright-0.2.0/src/termwright_probe/session.py +197 -0
- termwright-0.2.0/src/termwright_probe/textual_probe.py +239 -0
- termwright-0.2.0/src/termwright_probe/textual_tree.py +634 -0
- termwright-0.2.0/tests/conftest.py +32 -0
- termwright-0.2.0/tests/fixtures/vanilla_textual_app.py +32 -0
- termwright-0.2.0/tests/test_client.py +507 -0
- termwright-0.2.0/tests/test_debug.py +203 -0
- termwright-0.2.0/tests/test_deltas.py +117 -0
- termwright-0.2.0/tests/test_diffing.py +233 -0
- termwright-0.2.0/tests/test_framing.py +67 -0
- termwright-0.2.0/tests/test_logs.py +290 -0
- termwright-0.2.0/tests/test_marker.py +82 -0
- termwright-0.2.0/tests/test_messages.py +117 -0
- termwright-0.2.0/tests/test_observation_vectors.py +23 -0
- termwright-0.2.0/tests/test_probe_bootstrap.py +373 -0
- termwright-0.2.0/tests/test_probe_golden.py +135 -0
- termwright-0.2.0/tests/test_probe_session.py +317 -0
- termwright-0.2.0/tests/test_probe_tree.py +339 -0
- termwright-0.2.0/tests/test_textual_annotations.py +196 -0
- termwright-0.2.0/tests/test_textual_probe_hook.py +54 -0
- termwright-0.2.0/tests/test_validate.py +142 -0
- termwright-0.2.0/uv.lock +444 -0
|
@@ -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
|
+
```
|