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,207 @@
1
+ """Getting the probe into a process that imports nothing of ours.
2
+
3
+ CPython's `site` module imports `sitecustomize` during startup, before the
4
+ script's own directory reaches `sys.path`. Putting a directory that contains
5
+ one on `PYTHONPATH` therefore runs our code first, in an application we never
6
+ touched — which is the whole trick behind zero-config instrumentation for
7
+ Textual.
8
+
9
+ Two properties are load-bearing, both measured on this machine and written up
10
+ in `docs/architecture/audit/textual.md`:
11
+
12
+ **We shadow whatever `sitecustomize` the environment already had.**
13
+ `PYTHONPATH` precedes `site-packages` and the stdlib, and `site` imports
14
+ exactly one `sitecustomize`. Homebrew's CPython ships one that reorders
15
+ `sys.path` and validates `PYTHONPATH` against the interpreter version, so
16
+ silently replacing it would change import semantics *only when instrumented* —
17
+ a bug that looks like it was caused by the tool and cannot be reproduced
18
+ without it. The generated module therefore removes its own directory from
19
+ `sys.path` and re-imports, running the module it displaced before doing
20
+ anything of its own.
21
+
22
+ **Nothing is written into the project.** The directory is temporary and is
23
+ named only in the child's environment. The one caveat, which the README
24
+ repeats: `PYTHONPATH` is visible to the application and inherited by anything
25
+ it spawns, so grandchildren are instrumented too unless the variable is
26
+ scrubbed.
27
+
28
+ Where it does not reach, measured: `python -S` (no `site`, so no
29
+ `sitecustomize` at all) and `python -E` (ignores `PYTHONPATH`). Both are
30
+ deliberate opt-outs by the person running the interpreter. `poetry` is
31
+ **unverified** — see the README's Deviations.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import os
37
+ import shutil
38
+ import tempfile
39
+ from typing import Dict, List, Mapping, Optional, Sequence, Tuple
40
+
41
+ #: Environment variables that switch instrumentation on. Kept as literals
42
+ #: rather than imported from `termwright`: the generated `sitecustomize` runs
43
+ #: before anything of ours is importable, so it cannot rely on our package
44
+ #: being on the path yet.
45
+ ENV_ENDPOINT = "TERMWRIGHT_ENDPOINT"
46
+ ENV_TOKEN = "TERMWRIGHT_TOKEN"
47
+
48
+ #: Name of the file CPython looks for. Not ours to choose.
49
+ SITECUSTOMIZE = "sitecustomize.py"
50
+
51
+
52
+ def is_instrumented(env: Mapping[str, str]) -> bool:
53
+ """Whether this environment asks for instrumentation.
54
+
55
+ The dormant rule: without both variables the probe installs nothing at
56
+ all. The launcher already knows not to inject in that case; the generated
57
+ module checks again, for a `PYTHONPATH` that outlived the run it was
58
+ written for.
59
+ """
60
+ return bool(env.get(ENV_ENDPOINT)) and bool(env.get(ENV_TOKEN))
61
+
62
+
63
+ def sitecustomize_source(*, package_root: str) -> str:
64
+ """The text of the `sitecustomize.py` we generate.
65
+
66
+ `package_root` is prepended to `sys.path` so the generated module can
67
+ import `termwright_probe` even when the probe is not installed in the
68
+ target's environment — an application under test has no reason to have our
69
+ package, and requiring it would be configuration by another name.
70
+ """
71
+ return f'''"""Generated by termwright. Ephemeral: delete this directory freely.
72
+
73
+ CPython imports this file during startup because its directory is on
74
+ PYTHONPATH. Everything here runs before the application's first line.
75
+ """
76
+
77
+ import os
78
+ import sys
79
+
80
+ _HERE = os.path.dirname(os.path.abspath(__file__))
81
+
82
+
83
+ def _chain() -> None:
84
+ """Run the sitecustomize this one displaced, if there is one.
85
+
86
+ Homebrew's CPython ships a sitecustomize that reorders sys.path. Shadowing
87
+ it silently would change import semantics under instrumentation only, so
88
+ the displaced module runs first and its effects are in place before the
89
+ probe installs anything.
90
+ """
91
+ remaining = [entry for entry in sys.path if os.path.abspath(entry or ".") != _HERE]
92
+ if len(remaining) == len(sys.path):
93
+ return
94
+ ours = sys.modules.pop("sitecustomize", None)
95
+ saved = list(sys.path)
96
+ sys.path[:] = remaining
97
+ try:
98
+ import sitecustomize # noqa: F401 the displaced one
99
+ except Exception:
100
+ # A broken neighbour is not ours to fix, and it must not stop the
101
+ # application from starting.
102
+ pass
103
+ finally:
104
+ sys.path[:] = saved
105
+ if ours is not None:
106
+ sys.modules["sitecustomize"] = ours
107
+
108
+
109
+ def _install() -> None:
110
+ if not (os.environ.get({ENV_ENDPOINT!r}) and os.environ.get({ENV_TOKEN!r})):
111
+ # Dormant: a PYTHONPATH that outlived its run installs nothing.
112
+ return
113
+ root = {package_root!r}
114
+ if root not in sys.path:
115
+ sys.path.insert(0, root)
116
+ try:
117
+ from termwright_probe import install
118
+ except Exception:
119
+ # The probe is unreachable or broken. The application is not ours to
120
+ # take down over it, and there is no terminal to complain to.
121
+ return
122
+ install()
123
+
124
+
125
+ _chain()
126
+ _install()
127
+ '''
128
+
129
+
130
+ class Bootstrap:
131
+ """A temporary directory holding the generated `sitecustomize.py`.
132
+
133
+ Usable as a context manager, which is how tests and short-lived launchers
134
+ should take it. A driver that outlives the call site keeps the object and
135
+ calls :meth:`cleanup` when the session ends.
136
+ """
137
+
138
+ def __init__(self, directory: str) -> None:
139
+ self.directory = directory
140
+
141
+ @property
142
+ def sitecustomize(self) -> str:
143
+ """Full path of the generated file."""
144
+ return os.path.join(self.directory, SITECUSTOMIZE)
145
+
146
+ def env(self, base: Optional[Mapping[str, str]] = None) -> Dict[str, str]:
147
+ """`base` with our directory prepended to `PYTHONPATH`.
148
+
149
+ Prepended, not replaced: an application that relies on its own
150
+ `PYTHONPATH` keeps it. Ours goes first because `site` imports the first
151
+ `sitecustomize` it finds, and being displaced would mean instrumenting
152
+ nothing.
153
+ """
154
+ env = dict(os.environ if base is None else base)
155
+ existing = env.get("PYTHONPATH", "")
156
+ env["PYTHONPATH"] = (
157
+ self.directory if not existing else self.directory + os.pathsep + existing
158
+ )
159
+ return env
160
+
161
+ def cleanup(self) -> None:
162
+ """Remove the directory. Safe to call more than once."""
163
+ shutil.rmtree(self.directory, ignore_errors=True)
164
+
165
+ def __enter__(self) -> "Bootstrap":
166
+ return self
167
+
168
+ def __exit__(self, *exc: object) -> None:
169
+ self.cleanup()
170
+
171
+
172
+ def write_bootstrap(*, package_root: Optional[str] = None) -> Bootstrap:
173
+ """Create the ephemeral directory and write the generated module into it.
174
+
175
+ :param package_root: Directory containing `termwright_probe`. Defaults to
176
+ the one this module was imported from, which is what a launcher
177
+ running out of an installed package wants.
178
+ """
179
+ if package_root is None:
180
+ package_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
181
+ directory = tempfile.mkdtemp(prefix="termwright-probe-")
182
+ bootstrap = Bootstrap(directory)
183
+ with open(bootstrap.sitecustomize, "w", encoding="utf-8") as handle:
184
+ handle.write(sitecustomize_source(package_root=package_root))
185
+ return bootstrap
186
+
187
+
188
+ def with_probe(
189
+ argv: Sequence[str],
190
+ *,
191
+ env: Optional[Mapping[str, str]] = None,
192
+ ) -> Tuple[List[str], Dict[str, str], Optional[Bootstrap]]:
193
+ """Compose the command and environment that run `argv` instrumented.
194
+
195
+ Returns the command unchanged — Python needs no extra flag, only the
196
+ variable — together with the environment to run it in and the
197
+ :class:`Bootstrap` whose directory must outlive the process.
198
+
199
+ Without instrumentation in `env` the third element is ``None`` and the
200
+ environment comes back untouched: no directory is created, nothing is
201
+ written, and the command is exactly what the caller passed.
202
+ """
203
+ source: Mapping[str, str] = os.environ if env is None else env
204
+ if not is_instrumented(source):
205
+ return list(argv), dict(source), None
206
+ bootstrap = write_bootstrap()
207
+ return list(argv), bootstrap.env(source), bootstrap
@@ -0,0 +1,130 @@
1
+ """Running code the moment a module the application imports arrives.
2
+
3
+ The probe is installed during interpreter startup, long before the application
4
+ has imported its framework. Importing Textual ourselves at that point would be
5
+ wrong twice over: it would pay the framework's import cost in processes that
6
+ never use it, and it would fix the import order in a way the application did
7
+ not choose.
8
+
9
+ So the probe waits. A finder on `sys.meta_path` wraps the loader of the module
10
+ it is watching for and fires a callback the moment that module finishes
11
+ executing — which is the first instant its classes exist and can be patched.
12
+
13
+ Wrapping the loader rather than reacting to `find_spec` is the whole design.
14
+ `find_spec` runs *before* the module body, so nothing is there to patch yet,
15
+ and deferring to "the next import that comes past" would leave the callback
16
+ unfired whenever the watched module is the last one imported.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import sys
22
+ from importlib.abc import Loader, MetaPathFinder
23
+ from importlib.machinery import ModuleSpec
24
+ from types import ModuleType
25
+ from typing import Callable, Dict, List, Optional
26
+
27
+ Callback = Callable[[ModuleType], None]
28
+
29
+
30
+ class _NotifyingLoader(Loader):
31
+ """Delegates to the real loader, then fires the callbacks."""
32
+
33
+ def __init__(self, inner: Loader, fire: Callable[[ModuleType], None]) -> None:
34
+ self._inner = inner
35
+ self._fire = fire
36
+
37
+ def create_module(self, spec: ModuleSpec) -> Optional[ModuleType]:
38
+ creator = getattr(self._inner, "create_module", None)
39
+ return None if creator is None else creator(spec)
40
+
41
+ def exec_module(self, module: ModuleType) -> None:
42
+ executor = getattr(self._inner, "exec_module", None)
43
+ if executor is not None:
44
+ executor(module)
45
+ self._fire(module)
46
+
47
+ def __getattr__(self, name: str) -> object:
48
+ # Loaders carry more than the two methods above — `get_source` and
49
+ # friends — and anything we do not override belongs to the real one.
50
+ return getattr(self._inner, name)
51
+
52
+
53
+ class _Waiter(MetaPathFinder):
54
+ """Watches for named modules and notifies once each has executed."""
55
+
56
+ def __init__(self) -> None:
57
+ self._waiting: Dict[str, List[Callback]] = {}
58
+
59
+ def watch(self, name: str, callback: Callback) -> None:
60
+ module = sys.modules.get(name)
61
+ if module is not None:
62
+ # Already imported: the application got there first, which is the
63
+ # normal case when the probe is installed by hand rather than at
64
+ # interpreter startup.
65
+ callback(module)
66
+ return
67
+ self._waiting.setdefault(name, []).append(callback)
68
+
69
+ def find_spec(
70
+ self,
71
+ fullname: str,
72
+ path: object = None,
73
+ target: object = None,
74
+ ) -> Optional[ModuleSpec]:
75
+ if fullname not in self._waiting:
76
+ return None
77
+ spec = self._delegate(fullname, path, target)
78
+ if spec is None or spec.loader is None:
79
+ return None
80
+ spec.loader = _NotifyingLoader(spec.loader, lambda module: self._fire(fullname, module))
81
+ return spec
82
+
83
+ def _delegate(self, fullname: str, path: object, target: object) -> Optional[ModuleSpec]:
84
+ """Ask the rest of the meta path who would really load this module."""
85
+ for finder in list(sys.meta_path):
86
+ if finder is self:
87
+ continue
88
+ find = getattr(finder, "find_spec", None)
89
+ if find is None:
90
+ continue
91
+ try:
92
+ spec = find(fullname, path, target)
93
+ except Exception:
94
+ # A finder that raises is not ours to fix, and must not turn
95
+ # an ordinary import into a failure because we were watching.
96
+ continue
97
+ if spec is not None:
98
+ return spec
99
+ return None
100
+
101
+ def _fire(self, fullname: str, module: ModuleType) -> None:
102
+ for callback in self._waiting.pop(fullname, []):
103
+ try:
104
+ callback(module)
105
+ except Exception:
106
+ # A probe that cannot attach leaves the application running.
107
+ pass
108
+
109
+
110
+ _FINDER: Optional[_Waiter] = None
111
+
112
+
113
+ def when_imported(name: str, callback: Callback) -> None:
114
+ """Call `callback(module)` once `name` has finished importing.
115
+
116
+ Fires immediately when the module is already in `sys.modules`.
117
+ """
118
+ global _FINDER
119
+ if _FINDER is None:
120
+ _FINDER = _Waiter()
121
+ sys.meta_path.insert(0, _FINDER)
122
+ _FINDER.watch(name, callback)
123
+
124
+
125
+ def reset() -> None:
126
+ """Remove the finder. For tests; the probe never uninstalls itself."""
127
+ global _FINDER
128
+ if _FINDER is not None and _FINDER in sys.meta_path:
129
+ sys.meta_path.remove(_FINDER)
130
+ _FINDER = None
@@ -0,0 +1,197 @@
1
+ """One instrumented Textual application: connect, publish, commit.
2
+
3
+ The session lives between the frame hook and the protocol client. Each
4
+ completed frame becomes a snapshot, the snapshot's revision becomes a marker,
5
+ and the marker is written after the frame's last byte — which is what lets the
6
+ driver match a tree to the pixels that were on screen when it was true.
7
+
8
+ Everything here is written to fail quietly. The application under test owns
9
+ the terminal and the exit code; a side channel that cannot connect, cannot
10
+ build a tree, or cannot write must leave the app running exactly as it would
11
+ have run on its own.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import sys
18
+ from typing import Any, Dict, Optional
19
+
20
+ from termwright.client import DEFAULT_CAPABILITIES, SemanticClient, client_from_env
21
+
22
+ from . import __version__
23
+ from .textual_tree import Identities, build_snapshot
24
+
25
+ #: What this probe tells the driver it can do.
26
+ #:
27
+ #: `frame-begin` is deliberately absent: `post_display_hook` runs after the
28
+ #: flush, so there is no moment we could honestly report as the start of a
29
+ #: frame. `paint-order` and `visible-rect` are claimed because Textual
30
+ #: computes both and we read them rather than deriving them.
31
+ PROBE_CAPABILITIES = ("stable-identity", "visible-rect", "annotations", "paint-order")
32
+
33
+
34
+ def probe_info(framework_version: Optional[str] = None) -> Dict[str, Any]:
35
+ """The `ProbeInfo` this probe sends with `hello`."""
36
+ info: Dict[str, Any] = {
37
+ "framework": "textual",
38
+ "probeVersion": __version__,
39
+ # Textual keeps a retained DOM, so a widget object outlives the frame
40
+ # and its identity can be correlated across frames.
41
+ "identityKind": "stable",
42
+ "capabilities": list(PROBE_CAPABILITIES),
43
+ }
44
+ if framework_version:
45
+ info["frameworkVersion"] = framework_version
46
+ return info
47
+
48
+
49
+ class ProbeSession:
50
+ """Publishes one Textual application's tree for the life of the process."""
51
+
52
+ def __init__(self, app: Any, client: SemanticClient) -> None:
53
+ self._app = app
54
+ self._client = client
55
+ self._identities = Identities()
56
+ self._starting = False
57
+ self._started = False
58
+ # Coalesced snapshot of the latest *completed* frame seen while the
59
+ # handshake is in flight. It is not an event queue: one immutable
60
+ # terminal state is retained so a stationary application still gets a
61
+ # semantic tree after connecting, without waiting for an unrelated
62
+ # future repaint.
63
+ self._pending_snapshot = None
64
+ #: Frames that arrived before the handshake finished, or while a
65
+ #: previous publish was still in flight. Counted, never queued: at most
66
+ #: one coalesced observation of the latest completed frame is retained.
67
+ self.frames_dropped = 0
68
+
69
+ @property
70
+ def client(self) -> SemanticClient:
71
+ return self._client
72
+
73
+ def on_frame(self) -> None:
74
+ """Called once per completed frame. Never raises into Textual."""
75
+ try:
76
+ self._on_frame()
77
+ except Exception as error: # pragma: no cover - defensive
78
+ _log("diag", f"frame handling failed: {type(error).__name__}: {error}")
79
+
80
+ def _on_frame(self) -> None:
81
+ if not self._started:
82
+ self._begin()
83
+ self._capture_pending()
84
+ self._drop()
85
+ return
86
+ if not self._client.connected:
87
+ self._capture_pending()
88
+ self._drop()
89
+ return
90
+
91
+ snapshot = self._snapshot()
92
+ marker = self._client.publish_nowait(snapshot)
93
+ if marker:
94
+ self._write(marker)
95
+
96
+ def _snapshot(self):
97
+ return build_snapshot(
98
+ self._app,
99
+ self._identities,
100
+ session_id=self._client.session_id or "pending",
101
+ revision=self._client.revision + 1,
102
+ qualified=self._client.protocol == "termwright/2",
103
+ )
104
+
105
+ def _capture_pending(self) -> None:
106
+ """Retain only the newest completed frame while connecting."""
107
+ self._pending_snapshot = self._snapshot()
108
+
109
+ def _publish_pending(self) -> None:
110
+ snapshot, self._pending_snapshot = self._pending_snapshot, None
111
+ if snapshot is None or not self._client.connected:
112
+ return
113
+ marker = self._client.publish_nowait(snapshot)
114
+ if marker:
115
+ self._write(marker)
116
+
117
+ def _drop(self) -> None:
118
+ """Record a frame that never reached the driver.
119
+
120
+ The count is diagnostics; the obligation is protocol. A tree the driver
121
+ never saw means the next one it does see must be whole — a patch would
122
+ be applied to a state that never accounted for what was skipped, and
123
+ nothing would report the divergence.
124
+ """
125
+ self.frames_dropped += 1
126
+ self._client.require_full_snapshot()
127
+
128
+ def _begin(self) -> None:
129
+ """Start the handshake, once, from inside the running event loop."""
130
+ if self._starting:
131
+ return
132
+ self._starting = True
133
+
134
+ async def connect() -> None:
135
+ ok = await self._client.start()
136
+ self._started = ok
137
+ if ok:
138
+ self._publish_pending()
139
+ else:
140
+ _log("diag", "probe session did not start; publishing nothing")
141
+
142
+ try:
143
+ asyncio.ensure_future(connect())
144
+ except RuntimeError:
145
+ # No running loop: nothing to attach to, and nothing to report to
146
+ # either. The application keeps its terminal.
147
+ self._starting = False
148
+
149
+ def _write(self, text: str) -> None:
150
+ """Emit the marker on the same stream the frame went out on.
151
+
152
+ Textual's driver is preferred: writing through it keeps our bytes in
153
+ the same ordering as the frame's, which is the whole point of a marker
154
+ that commits the bytes before it.
155
+ """
156
+ driver = getattr(self._app, "_driver", None)
157
+ if driver is not None and hasattr(driver, "write"):
158
+ try:
159
+ driver.write(text)
160
+ driver.flush()
161
+ return
162
+ except Exception:
163
+ pass
164
+ stream = sys.__stdout__ or sys.stdout
165
+ try:
166
+ stream.write(text)
167
+ stream.flush()
168
+ except Exception:
169
+ pass
170
+
171
+
172
+ def session_for(app: Any, framework_version: Optional[str] = None) -> Optional[ProbeSession]:
173
+ """Build a session for `app`, or `None` when the process is not instrumented.
174
+
175
+ The dormant rule reaches all the way here: `client_from_env` returns `None`
176
+ without an endpoint and a token, and then no session exists to publish
177
+ anything.
178
+ """
179
+ client = client_from_env(
180
+ adapter_name="textual-probe",
181
+ adapter_version=__version__,
182
+ # The zero-config probe publishes semantic frames only. Application
183
+ # logs remain an explicit client feature: no handler is installed here,
184
+ # so advertising `logs` would promise traffic this path cannot emit.
185
+ capabilities=DEFAULT_CAPABILITIES,
186
+ qualified_capabilities=("pointer-hit-grid",),
187
+ probe=probe_info(framework_version),
188
+ )
189
+ if client is None:
190
+ return None
191
+ return ProbeSession(app, client)
192
+
193
+
194
+ def _log(category: str, message: str) -> None:
195
+ from .textual_probe import _log as write
196
+
197
+ write(category, message)