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,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