termwright 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- termwright/__init__.py +125 -0
- termwright/client.py +708 -0
- termwright/debug.py +169 -0
- termwright/diffing.py +118 -0
- termwright/errors.py +20 -0
- termwright/framing.py +196 -0
- termwright/limits.py +82 -0
- termwright/logging_bridge.py +108 -0
- termwright/logs.py +207 -0
- termwright/marker.py +128 -0
- termwright/messages.py +417 -0
- termwright/roles.py +57 -0
- termwright/textual.py +249 -0
- termwright/tree.py +282 -0
- termwright/validate.py +846 -0
- termwright-0.2.0.dist-info/METADATA +336 -0
- termwright-0.2.0.dist-info/RECORD +25 -0
- termwright-0.2.0.dist-info/WHEEL +4 -0
- termwright_probe/__init__.py +73 -0
- termwright_probe/__main__.py +45 -0
- termwright_probe/bootstrap.py +207 -0
- termwright_probe/defer.py +130 -0
- termwright_probe/session.py +197 -0
- termwright_probe/textual_probe.py +239 -0
- termwright_probe/textual_tree.py +634 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Attaching to Textual, and the assumptions that have to hold first.
|
|
2
|
+
|
|
3
|
+
The attachment point is `App.post_display_hook`, chosen in the Phase 0 audit
|
|
4
|
+
(`docs/architecture/audit/textual.md` §5): Textual calls it from the `finally`
|
|
5
|
+
of `App._display`, after the compositor's output has been written and flushed,
|
|
6
|
+
which is the one moment when the DOM, the layout and the screen agree.
|
|
7
|
+
|
|
8
|
+
Two consequences of *when* it runs shape everything downstream:
|
|
9
|
+
|
|
10
|
+
- **The flush precedes the hook.** By the time we are called the frame is
|
|
11
|
+
already on the terminal, so there is no frame-begin signal to report and the
|
|
12
|
+
probe does not claim the `frame-begin` capability. This is the opposite of
|
|
13
|
+
tview's `afterDraw`, and reading "no frame-begin" as "no frame in progress"
|
|
14
|
+
would be wrong.
|
|
15
|
+
- **Geometry read here is fresh**, because the compositor has finished. That
|
|
16
|
+
is what makes `visible_region` trustworthy at this point and nowhere earlier.
|
|
17
|
+
|
|
18
|
+
Textual is a moving target — the repository declares `textual>=0.60`, which
|
|
19
|
+
spans several renames — so the probe asserts what it needs at attach time and
|
|
20
|
+
declines to attach when an assumption does not hold, rather than publishing a
|
|
21
|
+
tree assembled from guesses.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from functools import wraps
|
|
27
|
+
from typing import Any, Callable, List, Optional
|
|
28
|
+
from weakref import WeakKeyDictionary, WeakSet
|
|
29
|
+
|
|
30
|
+
#: Everything the probe touches on Textual's public surface. Checked once, at
|
|
31
|
+
#: attach time, so a version that moved one of them produces a diagnostic
|
|
32
|
+
#: instead of a half-built tree.
|
|
33
|
+
REQUIRED_APP_ATTRIBUTES = ("post_display_hook", "screen", "focused")
|
|
34
|
+
|
|
35
|
+
#: What a frame observer is handed: the app, at the instant its frame landed.
|
|
36
|
+
FrameObserver = Callable[[Any], None]
|
|
37
|
+
|
|
38
|
+
_observers: List[FrameObserver] = []
|
|
39
|
+
_attached_modules: List[int] = []
|
|
40
|
+
|
|
41
|
+
#: Frames seen since attach. Only the first is worth a log line — after that
|
|
42
|
+
#: the count is a number the producer reports when the session closes.
|
|
43
|
+
_frames = 0
|
|
44
|
+
|
|
45
|
+
# A subclass hook commonly calls ``super()``. Both methods are wrapped, but a
|
|
46
|
+
# rendered frame must produce exactly one observation — at the outermost hook,
|
|
47
|
+
# before application code is allowed to mutate state again.
|
|
48
|
+
_hooks_in_progress: "WeakSet[Any]" = WeakSet()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _wrap_hook(owner: Any) -> None:
|
|
52
|
+
"""Wrap an own ``post_display_hook`` once, preserving its exceptions."""
|
|
53
|
+
|
|
54
|
+
original = owner.__dict__.get("post_display_hook")
|
|
55
|
+
if original is None or getattr(original, "__termwright_observed__", False):
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
@wraps(original)
|
|
59
|
+
def observed(self: Any) -> None:
|
|
60
|
+
outermost = self not in _hooks_in_progress
|
|
61
|
+
if outermost:
|
|
62
|
+
_hooks_in_progress.add(self)
|
|
63
|
+
# Read the compositor immediately after Textual flushed it. The
|
|
64
|
+
# application's hook runs afterwards and may legitimately mutate
|
|
65
|
+
# state for a future frame; observing after it would pair that
|
|
66
|
+
# future state with the bytes of the previous frame.
|
|
67
|
+
_notify(self)
|
|
68
|
+
try:
|
|
69
|
+
original(self)
|
|
70
|
+
finally:
|
|
71
|
+
if outermost:
|
|
72
|
+
_hooks_in_progress.discard(self)
|
|
73
|
+
|
|
74
|
+
observed.__termwright_observed__ = True # type: ignore[attr-defined]
|
|
75
|
+
setattr(owner, "post_display_hook", observed)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def on_frame(observer: FrameObserver) -> None:
|
|
79
|
+
"""Register a callback for every completed frame.
|
|
80
|
+
|
|
81
|
+
The tree producer registers here; keeping the hook and the producer apart
|
|
82
|
+
is what lets the attachment be tested without a socket.
|
|
83
|
+
"""
|
|
84
|
+
_observers.append(observer)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def missing_assumptions(app_class: Any) -> List[str]:
|
|
88
|
+
"""Names the probe needs on `App` and did not find.
|
|
89
|
+
|
|
90
|
+
Returned rather than raised: the caller decides whether a missing name is
|
|
91
|
+
worth a diagnostic or a refusal, and a probe must never turn a version
|
|
92
|
+
difference into a crashed application.
|
|
93
|
+
"""
|
|
94
|
+
return [name for name in REQUIRED_APP_ATTRIBUTES if not hasattr(app_class, name)]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def attach_to_app_module(module: Any) -> bool:
|
|
98
|
+
"""Patch `App.post_display_hook` on a freshly imported `textual.app`.
|
|
99
|
+
|
|
100
|
+
Returns whether the patch was installed. Idempotent per module object: a
|
|
101
|
+
second import of the same module — or a second probe install — does not
|
|
102
|
+
stack two hooks.
|
|
103
|
+
"""
|
|
104
|
+
app_class = getattr(module, "App", None)
|
|
105
|
+
if app_class is None:
|
|
106
|
+
_log("diag", "textual.app has no App class; not attaching")
|
|
107
|
+
return False
|
|
108
|
+
if id(module) in _attached_modules:
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
absent = missing_assumptions(app_class)
|
|
112
|
+
if absent:
|
|
113
|
+
_log(
|
|
114
|
+
"diag",
|
|
115
|
+
"not attaching: this Textual is missing " + ", ".join(absent),
|
|
116
|
+
)
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
_wrap_hook(app_class)
|
|
120
|
+
|
|
121
|
+
# A normal Textual application overrides the hook on its App subclass. A
|
|
122
|
+
# base-class monkey patch alone is bypassed by Python's method resolution.
|
|
123
|
+
# Wrap already-created subclasses and every future subclass, too. This is
|
|
124
|
+
# installed at import time, but covering both sides removes import-order as
|
|
125
|
+
# a hidden reliability condition.
|
|
126
|
+
def descendants(owner: Any):
|
|
127
|
+
for child in owner.__subclasses__():
|
|
128
|
+
yield child
|
|
129
|
+
yield from descendants(child)
|
|
130
|
+
|
|
131
|
+
for child in descendants(app_class):
|
|
132
|
+
_wrap_hook(child)
|
|
133
|
+
|
|
134
|
+
init_descriptor = app_class.__dict__.get("__init_subclass__")
|
|
135
|
+
if isinstance(init_descriptor, classmethod):
|
|
136
|
+
original_init_subclass = init_descriptor.__func__
|
|
137
|
+
|
|
138
|
+
def init_subclass(cls: Any, *args: Any, **kwargs: Any) -> None:
|
|
139
|
+
original_init_subclass(cls, *args, **kwargs)
|
|
140
|
+
_wrap_hook(cls)
|
|
141
|
+
|
|
142
|
+
setattr(app_class, "__init_subclass__", classmethod(init_subclass))
|
|
143
|
+
_attached_modules.append(id(module))
|
|
144
|
+
_log("sem", f"attached to Textual {_textual_version()}")
|
|
145
|
+
_publish_frames()
|
|
146
|
+
return True
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
#: One session per application object. Keyed weakly: an app that goes away
|
|
150
|
+
#: takes its session with it, and a process may legitimately run several.
|
|
151
|
+
_sessions: "WeakKeyDictionary[Any, Any]" = WeakKeyDictionary()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _publish_frames() -> None:
|
|
155
|
+
"""Register the observer that turns frames into published trees."""
|
|
156
|
+
|
|
157
|
+
def publish(app: Any) -> None:
|
|
158
|
+
session = _sessions.get(app)
|
|
159
|
+
if session is None:
|
|
160
|
+
from .session import session_for
|
|
161
|
+
|
|
162
|
+
session = session_for(app, _textual_version())
|
|
163
|
+
if session is None:
|
|
164
|
+
# Not instrumented after all — nothing to publish to. Recorded
|
|
165
|
+
# so the app is not asked again on every frame.
|
|
166
|
+
_sessions[app] = _DORMANT
|
|
167
|
+
return
|
|
168
|
+
_sessions[app] = session
|
|
169
|
+
if session is not _DORMANT:
|
|
170
|
+
session.on_frame()
|
|
171
|
+
|
|
172
|
+
on_frame(publish)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class _Dormant:
|
|
176
|
+
"""Marker for an app we already decided not to publish for."""
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
_DORMANT = _Dormant()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def frames_seen() -> int:
|
|
183
|
+
"""How many completed frames the probe has observed."""
|
|
184
|
+
return _frames
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _notify(app: Any) -> None:
|
|
188
|
+
global _frames
|
|
189
|
+
_frames += 1
|
|
190
|
+
if _frames == 1:
|
|
191
|
+
# The one frame worth naming: it proves the hook is live, which is
|
|
192
|
+
# otherwise invisible from outside the process.
|
|
193
|
+
_log("sem", "first frame observed")
|
|
194
|
+
for observer in list(_observers):
|
|
195
|
+
try:
|
|
196
|
+
observer(app)
|
|
197
|
+
except Exception as error: # pragma: no cover - defensive
|
|
198
|
+
# One bad observer must not stop the others, and must never reach
|
|
199
|
+
# the application's render path.
|
|
200
|
+
_log("diag", f"frame observer failed: {type(error).__name__}: {error}")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _textual_version() -> str:
|
|
204
|
+
try:
|
|
205
|
+
from importlib.metadata import version
|
|
206
|
+
|
|
207
|
+
return version("textual")
|
|
208
|
+
except Exception:
|
|
209
|
+
return "unknown"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
_debug: Optional[Any] = None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _log(category: str, message: str) -> None:
|
|
216
|
+
"""Write to the adapter-side diagnostic log, when one is enabled.
|
|
217
|
+
|
|
218
|
+
The probe has no terminal to complain to — the application owns it — so
|
|
219
|
+
this file is the only place a refusal to attach can be seen.
|
|
220
|
+
"""
|
|
221
|
+
global _debug
|
|
222
|
+
if _debug is None:
|
|
223
|
+
try:
|
|
224
|
+
from termwright.debug import DebugLog
|
|
225
|
+
|
|
226
|
+
_debug = DebugLog.from_env(adapter="textual-probe") or False
|
|
227
|
+
except Exception:
|
|
228
|
+
_debug = False
|
|
229
|
+
if _debug:
|
|
230
|
+
_debug.line(category, message)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def reset() -> None:
|
|
234
|
+
"""Forget observers and attachments. For tests only."""
|
|
235
|
+
global _frames
|
|
236
|
+
_sessions.clear()
|
|
237
|
+
_observers.clear()
|
|
238
|
+
_attached_modules.clear()
|
|
239
|
+
_frames = 0
|