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