smartcli-toolkit 0.1.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.
- smartcli_core/__init__.py +53 -0
- smartcli_core/pty_backend.py +259 -0
- smartcli_core/readiness.py +210 -0
- smartcli_core/screen_model.py +132 -0
- smartcli_core/session.py +244 -0
- smartcli_core/snapshot.py +297 -0
- smartcli_toolkit-0.1.0.dist-info/METADATA +226 -0
- smartcli_toolkit-0.1.0.dist-info/RECORD +11 -0
- smartcli_toolkit-0.1.0.dist-info/WHEEL +5 -0
- smartcli_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- smartcli_toolkit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""SmartCLI shared core.
|
|
2
|
+
|
|
3
|
+
A pluggable-PTY + pyte screen model + semantic snapshot + readiness stack for
|
|
4
|
+
driving interactive terminal programs. Both SmartCLI skills build on this.
|
|
5
|
+
|
|
6
|
+
Quick start::
|
|
7
|
+
|
|
8
|
+
from smartcli_core import PtySession
|
|
9
|
+
|
|
10
|
+
with PtySession(cols=100, rows=30) as sess:
|
|
11
|
+
sess.start("python")
|
|
12
|
+
sess.wait_ready(marker=r">>> $")
|
|
13
|
+
sess.send_line("print('hello')")
|
|
14
|
+
reason, snap = sess.wait_ready(marker=r">>> $")
|
|
15
|
+
print(snap.to_text())
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from .pty_backend import (
|
|
21
|
+
PosixPtyBackend,
|
|
22
|
+
PtyBackend,
|
|
23
|
+
WinptyBackend,
|
|
24
|
+
get_default_backend,
|
|
25
|
+
)
|
|
26
|
+
from .readiness import wait_for_regex, wait_ready, wait_until_stable
|
|
27
|
+
from .screen_model import CellAttrs, ScreenModel
|
|
28
|
+
from .session import KEY_MAP, PtySession
|
|
29
|
+
from .snapshot import Snapshot, Span, build_snapshot
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
# backends
|
|
33
|
+
"PtyBackend",
|
|
34
|
+
"WinptyBackend",
|
|
35
|
+
"PosixPtyBackend",
|
|
36
|
+
"get_default_backend",
|
|
37
|
+
# screen
|
|
38
|
+
"ScreenModel",
|
|
39
|
+
"CellAttrs",
|
|
40
|
+
# snapshot
|
|
41
|
+
"Snapshot",
|
|
42
|
+
"Span",
|
|
43
|
+
"build_snapshot",
|
|
44
|
+
# readiness
|
|
45
|
+
"wait_until_stable",
|
|
46
|
+
"wait_for_regex",
|
|
47
|
+
"wait_ready",
|
|
48
|
+
# session
|
|
49
|
+
"PtySession",
|
|
50
|
+
"KEY_MAP",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Pluggable PTY backends for SmartCLI.
|
|
2
|
+
|
|
3
|
+
A :class:`PtyBackend` abstracts spawning a child process attached to a
|
|
4
|
+
pseudo-terminal and exchanging bytes with it. Two concrete backends ship:
|
|
5
|
+
|
|
6
|
+
* :class:`WinptyBackend` -- uses ``pywinpty`` (ConPTY) on Windows. ``pywinpty``
|
|
7
|
+
reads are blocking and return ``str``; a background reader thread drains the
|
|
8
|
+
process into a queue so :meth:`read_nonblocking` never blocks.
|
|
9
|
+
* :class:`PosixPtyBackend` -- uses the stdlib ``pty``/``os``/``select`` stack.
|
|
10
|
+
The POSIX-only imports are guarded so this module imports cleanly on Windows.
|
|
11
|
+
|
|
12
|
+
Both backends normalise their read side to **bytes** so the rest of the core
|
|
13
|
+
(screen model, readiness) can feed a single long-lived ``pyte.ByteStream``.
|
|
14
|
+
|
|
15
|
+
Use :func:`get_default_backend` to obtain the right backend for the host.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import abc
|
|
21
|
+
import queue
|
|
22
|
+
import sys
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
from typing import Optional, Sequence, Union
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PtyBackend(abc.ABC):
|
|
29
|
+
"""Abstract pseudo-terminal backend.
|
|
30
|
+
|
|
31
|
+
Implementations own a child process running under a PTY sized ``cols`` x
|
|
32
|
+
``rows``. All reads are non-blocking and return raw ``bytes`` (possibly
|
|
33
|
+
empty). Writes accept ``bytes``.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
@abc.abstractmethod
|
|
37
|
+
def spawn(self, cmd: Union[str, Sequence[str]], cols: int, rows: int) -> None:
|
|
38
|
+
"""Launch ``cmd`` in a PTY of size ``cols`` x ``rows``."""
|
|
39
|
+
|
|
40
|
+
@abc.abstractmethod
|
|
41
|
+
def read_nonblocking(self) -> bytes:
|
|
42
|
+
"""Return any bytes available right now; ``b""`` if none. Never blocks."""
|
|
43
|
+
|
|
44
|
+
@abc.abstractmethod
|
|
45
|
+
def write(self, data: bytes) -> None:
|
|
46
|
+
"""Write ``data`` to the child's stdin."""
|
|
47
|
+
|
|
48
|
+
@abc.abstractmethod
|
|
49
|
+
def resize(self, cols: int, rows: int) -> None:
|
|
50
|
+
"""Resize the PTY window to ``cols`` x ``rows``."""
|
|
51
|
+
|
|
52
|
+
@abc.abstractmethod
|
|
53
|
+
def is_alive(self) -> bool:
|
|
54
|
+
"""Return ``True`` while the child process is running."""
|
|
55
|
+
|
|
56
|
+
@abc.abstractmethod
|
|
57
|
+
def terminate(self) -> None:
|
|
58
|
+
"""Terminate the child and release resources. Idempotent."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class WinptyBackend(PtyBackend):
|
|
62
|
+
"""PTY backend built on ``pywinpty`` (ConPTY).
|
|
63
|
+
|
|
64
|
+
``pywinpty`` returns already-decoded ``str`` and its ``read`` can block, so a
|
|
65
|
+
daemon reader thread performs blocking reads and pushes UTF-8 encoded chunks
|
|
66
|
+
into a :class:`queue.Queue`. :meth:`read_nonblocking` drains that queue.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self) -> None:
|
|
70
|
+
self._proc = None # winpty.PtyProcess
|
|
71
|
+
self._queue: "queue.Queue[Optional[bytes]]" = queue.Queue()
|
|
72
|
+
self._reader: Optional[threading.Thread] = None
|
|
73
|
+
self._eof = False
|
|
74
|
+
|
|
75
|
+
def spawn(self, cmd: Union[str, Sequence[str]], cols: int, rows: int) -> None:
|
|
76
|
+
import winpty # imported lazily so non-Windows hosts don't require it
|
|
77
|
+
|
|
78
|
+
# winpty.spawn accepts a command string or an argv list; dimensions are
|
|
79
|
+
# (rows, cols).
|
|
80
|
+
self._proc = winpty.PtyProcess.spawn(cmd, dimensions=(rows, cols))
|
|
81
|
+
self._reader = threading.Thread(target=self._read_loop, daemon=True)
|
|
82
|
+
self._reader.start()
|
|
83
|
+
|
|
84
|
+
def _read_loop(self) -> None:
|
|
85
|
+
"""Blocking-read the child until EOF, pushing bytes into the queue."""
|
|
86
|
+
proc = self._proc
|
|
87
|
+
while True:
|
|
88
|
+
try:
|
|
89
|
+
data = proc.read(65536) # returns str; blocks if nothing ready
|
|
90
|
+
except EOFError:
|
|
91
|
+
break
|
|
92
|
+
except OSError:
|
|
93
|
+
break
|
|
94
|
+
if data:
|
|
95
|
+
self._queue.put(data.encode("utf-8", "replace"))
|
|
96
|
+
self._queue.put(None) # EOF sentinel
|
|
97
|
+
|
|
98
|
+
def read_nonblocking(self) -> bytes:
|
|
99
|
+
out = bytearray()
|
|
100
|
+
while True:
|
|
101
|
+
try:
|
|
102
|
+
item = self._queue.get_nowait()
|
|
103
|
+
except queue.Empty:
|
|
104
|
+
break
|
|
105
|
+
if item is None:
|
|
106
|
+
self._eof = True
|
|
107
|
+
break
|
|
108
|
+
out.extend(item)
|
|
109
|
+
return bytes(out)
|
|
110
|
+
|
|
111
|
+
def write(self, data: bytes) -> None:
|
|
112
|
+
if self._proc is None:
|
|
113
|
+
raise RuntimeError("spawn() must be called before write()")
|
|
114
|
+
# pywinpty.write expects str.
|
|
115
|
+
self._proc.write(data.decode("utf-8", "replace"))
|
|
116
|
+
|
|
117
|
+
def resize(self, cols: int, rows: int) -> None:
|
|
118
|
+
if self._proc is not None:
|
|
119
|
+
self._proc.setwinsize(rows, cols)
|
|
120
|
+
|
|
121
|
+
def is_alive(self) -> bool:
|
|
122
|
+
if self._proc is None:
|
|
123
|
+
return False
|
|
124
|
+
try:
|
|
125
|
+
return bool(self._proc.isalive())
|
|
126
|
+
except Exception:
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
def terminate(self) -> None:
|
|
130
|
+
if self._proc is None:
|
|
131
|
+
return
|
|
132
|
+
try:
|
|
133
|
+
self._proc.terminate(force=True)
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
136
|
+
finally:
|
|
137
|
+
self._proc = None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class PosixPtyBackend(PtyBackend):
|
|
141
|
+
"""PTY backend built on the stdlib ``pty``/``os``/``select`` stack.
|
|
142
|
+
|
|
143
|
+
Only usable on POSIX hosts. The heavy imports happen inside :meth:`spawn`
|
|
144
|
+
(and a small module-level guard) so importing this file on Windows is safe.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
def __init__(self) -> None:
|
|
148
|
+
self._pid: Optional[int] = None
|
|
149
|
+
self._fd: Optional[int] = None
|
|
150
|
+
self._eof = False
|
|
151
|
+
|
|
152
|
+
def spawn(self, cmd: Union[str, Sequence[str]], cols: int, rows: int) -> None:
|
|
153
|
+
import fcntl
|
|
154
|
+
import os
|
|
155
|
+
import pty
|
|
156
|
+
import struct
|
|
157
|
+
import termios
|
|
158
|
+
|
|
159
|
+
if isinstance(cmd, str):
|
|
160
|
+
argv = ["/bin/sh", "-c", cmd]
|
|
161
|
+
else:
|
|
162
|
+
argv = list(cmd)
|
|
163
|
+
|
|
164
|
+
pid, master_fd = pty.fork()
|
|
165
|
+
if pid == 0: # child
|
|
166
|
+
try:
|
|
167
|
+
os.execvp(argv[0], argv)
|
|
168
|
+
except Exception:
|
|
169
|
+
os._exit(127)
|
|
170
|
+
# parent
|
|
171
|
+
self._pid = pid
|
|
172
|
+
self._fd = master_fd
|
|
173
|
+
os.set_blocking(master_fd, False)
|
|
174
|
+
fcntl.ioctl(
|
|
175
|
+
master_fd,
|
|
176
|
+
termios.TIOCSWINSZ,
|
|
177
|
+
struct.pack("HHHH", rows, cols, 0, 0),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def read_nonblocking(self) -> bytes:
|
|
181
|
+
import os
|
|
182
|
+
import select
|
|
183
|
+
|
|
184
|
+
if self._fd is None:
|
|
185
|
+
return b""
|
|
186
|
+
out = bytearray()
|
|
187
|
+
while True:
|
|
188
|
+
r, _, _ = select.select([self._fd], [], [], 0)
|
|
189
|
+
if not r:
|
|
190
|
+
break
|
|
191
|
+
try:
|
|
192
|
+
chunk = os.read(self._fd, 65536)
|
|
193
|
+
except BlockingIOError:
|
|
194
|
+
break
|
|
195
|
+
except OSError: # EIO on child exit (Linux)
|
|
196
|
+
self._eof = True
|
|
197
|
+
break
|
|
198
|
+
if not chunk: # EOF (BSD/mac)
|
|
199
|
+
self._eof = True
|
|
200
|
+
break
|
|
201
|
+
out.extend(chunk)
|
|
202
|
+
return bytes(out)
|
|
203
|
+
|
|
204
|
+
def write(self, data: bytes) -> None:
|
|
205
|
+
import os
|
|
206
|
+
|
|
207
|
+
if self._fd is None:
|
|
208
|
+
raise RuntimeError("spawn() must be called before write()")
|
|
209
|
+
os.write(self._fd, data)
|
|
210
|
+
|
|
211
|
+
def resize(self, cols: int, rows: int) -> None:
|
|
212
|
+
import fcntl
|
|
213
|
+
import struct
|
|
214
|
+
import termios
|
|
215
|
+
|
|
216
|
+
if self._fd is not None:
|
|
217
|
+
fcntl.ioctl(
|
|
218
|
+
self._fd,
|
|
219
|
+
termios.TIOCSWINSZ,
|
|
220
|
+
struct.pack("HHHH", rows, cols, 0, 0),
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
def is_alive(self) -> bool:
|
|
224
|
+
import os
|
|
225
|
+
|
|
226
|
+
if self._pid is None:
|
|
227
|
+
return False
|
|
228
|
+
try:
|
|
229
|
+
pid, _ = os.waitpid(self._pid, os.WNOHANG)
|
|
230
|
+
return pid == 0
|
|
231
|
+
except OSError:
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
def terminate(self) -> None:
|
|
235
|
+
import os
|
|
236
|
+
import signal
|
|
237
|
+
|
|
238
|
+
if self._pid is not None:
|
|
239
|
+
try:
|
|
240
|
+
os.kill(self._pid, signal.SIGTERM)
|
|
241
|
+
except OSError:
|
|
242
|
+
pass
|
|
243
|
+
if self._fd is not None:
|
|
244
|
+
try:
|
|
245
|
+
os.close(self._fd)
|
|
246
|
+
except OSError:
|
|
247
|
+
pass
|
|
248
|
+
self._pid = None
|
|
249
|
+
self._fd = None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def get_default_backend() -> PtyBackend:
|
|
253
|
+
"""Return a backend appropriate for the current platform.
|
|
254
|
+
|
|
255
|
+
Windows -> :class:`WinptyBackend`; everything else -> :class:`PosixPtyBackend`.
|
|
256
|
+
"""
|
|
257
|
+
if sys.platform == "win32":
|
|
258
|
+
return WinptyBackend()
|
|
259
|
+
return PosixPtyBackend()
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Readiness synchronisation for driving an interactive PTY program.
|
|
2
|
+
|
|
3
|
+
Three independent signals, combined so the agent never fires input into a
|
|
4
|
+
program that isn't ready and never hangs on an animation:
|
|
5
|
+
|
|
6
|
+
* **Quiescence** -- no bytes arriving (transport level).
|
|
7
|
+
* **Screen stability** -- the pyte content hash stops changing (semantic level,
|
|
8
|
+
survives chunked/bursty reads). Cursor-only and attribute-only changes are
|
|
9
|
+
excluded from the hash upstream in :meth:`ScreenModel.content_hash`.
|
|
10
|
+
* **Marker match** -- an expected regex appears (strongest signal).
|
|
11
|
+
|
|
12
|
+
Every wait has a hard ``max_wait`` ceiling so spinners/progress bars return the
|
|
13
|
+
last screen instead of hanging.
|
|
14
|
+
|
|
15
|
+
The functions take plain callables (``read_fn`` to pump bytes, ``get_screen_hash_fn``
|
|
16
|
+
to sample stability, ``get_snapshot_fn`` to produce a result) so they stay
|
|
17
|
+
decoupled from the session/backend concrete types.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
import time
|
|
24
|
+
from typing import Callable, Optional, Tuple
|
|
25
|
+
|
|
26
|
+
# Callable aliases (documentation only)
|
|
27
|
+
ReadFn = Callable[[], bytes] # pump: read+feed one batch, return bytes read
|
|
28
|
+
HashFn = Callable[[], int] # sample the cursor-excluded content hash
|
|
29
|
+
TextFn = Callable[[], str] # current rendered screen text (for regex)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _ms(seconds: float) -> float:
|
|
33
|
+
return seconds
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def wait_until_stable(
|
|
37
|
+
read_fn: ReadFn,
|
|
38
|
+
get_screen_hash_fn: HashFn,
|
|
39
|
+
quiet_ms: int = 200,
|
|
40
|
+
poll_ms: int = 30,
|
|
41
|
+
max_wait_ms: int = 8000,
|
|
42
|
+
grace_ms: int = 40,
|
|
43
|
+
min_wait_ms: int = 0,
|
|
44
|
+
) -> bool:
|
|
45
|
+
"""Pump reads until the screen hash is unchanged for ``quiet_ms``.
|
|
46
|
+
|
|
47
|
+
The stable timer resets on *either* new bytes arriving *or* the hash
|
|
48
|
+
changing; quiet time accumulates only when both are absent. After stability
|
|
49
|
+
is declared a short ``grace`` sleep absorbs a late flush, then one final
|
|
50
|
+
drain re-checks; if the flush changed the screen, the wait resumes.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
read_fn: called each poll to read+feed a batch; returns bytes read.
|
|
54
|
+
get_screen_hash_fn: returns the current cursor-excluded content hash.
|
|
55
|
+
quiet_ms: continuous no-change duration required to declare stable.
|
|
56
|
+
poll_ms: sleep between polls when idle.
|
|
57
|
+
max_wait_ms: hard ceiling; returns ``False`` if reached.
|
|
58
|
+
grace_ms: final settle sleep after stability, before returning.
|
|
59
|
+
min_wait_ms: minimum elapsed time before stability may be declared
|
|
60
|
+
(guards the stale-screen race right after sending input).
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
``True`` if the screen settled, ``False`` on timeout.
|
|
64
|
+
"""
|
|
65
|
+
poll = poll_ms / 1000.0
|
|
66
|
+
quiet = quiet_ms / 1000.0
|
|
67
|
+
grace = grace_ms / 1000.0
|
|
68
|
+
min_wait = min_wait_ms / 1000.0
|
|
69
|
+
|
|
70
|
+
start = time.monotonic()
|
|
71
|
+
deadline = start + (max_wait_ms / 1000.0)
|
|
72
|
+
last_hash: Optional[int] = None
|
|
73
|
+
stable_since: Optional[float] = None
|
|
74
|
+
|
|
75
|
+
while True:
|
|
76
|
+
now = time.monotonic()
|
|
77
|
+
data = read_fn()
|
|
78
|
+
h = get_screen_hash_fn()
|
|
79
|
+
elapsed = now - start
|
|
80
|
+
|
|
81
|
+
if not data and h == last_hash:
|
|
82
|
+
if stable_since is None:
|
|
83
|
+
stable_since = now
|
|
84
|
+
elif (now - stable_since) >= quiet and elapsed >= min_wait:
|
|
85
|
+
if grace > 0:
|
|
86
|
+
time.sleep(grace)
|
|
87
|
+
tail = read_fn()
|
|
88
|
+
if tail:
|
|
89
|
+
# late flush: resume waiting
|
|
90
|
+
stable_since = None
|
|
91
|
+
last_hash = get_screen_hash_fn()
|
|
92
|
+
continue
|
|
93
|
+
return True
|
|
94
|
+
else:
|
|
95
|
+
stable_since = None
|
|
96
|
+
last_hash = h
|
|
97
|
+
|
|
98
|
+
if now >= deadline:
|
|
99
|
+
return False
|
|
100
|
+
time.sleep(poll)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def wait_for_regex(
|
|
104
|
+
read_fn: ReadFn,
|
|
105
|
+
get_text_fn: TextFn,
|
|
106
|
+
get_snapshot_fn: Callable[[], object],
|
|
107
|
+
pattern: str,
|
|
108
|
+
timeout_ms: int = 10000,
|
|
109
|
+
poll_ms: int = 30,
|
|
110
|
+
min_wait_ms: int = 0,
|
|
111
|
+
flags: int = 0,
|
|
112
|
+
) -> Tuple[bool, object]:
|
|
113
|
+
"""Pump reads until ``pattern`` matches the rendered screen, or timeout.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
read_fn: called each poll to read+feed a batch.
|
|
117
|
+
get_text_fn: returns the current screen text searched by the regex.
|
|
118
|
+
get_snapshot_fn: builds the :class:`Snapshot` returned to the caller.
|
|
119
|
+
pattern: regular expression searched against the whole screen text.
|
|
120
|
+
timeout_ms: hard ceiling.
|
|
121
|
+
poll_ms: sleep between polls when idle.
|
|
122
|
+
min_wait_ms: ignore matches before this much time has elapsed (guards
|
|
123
|
+
against matching a stale prior prompt).
|
|
124
|
+
flags: extra ``re`` flags (``re.I`` etc.).
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
``(matched, snapshot)`` -- ``snapshot`` is always the current screen,
|
|
128
|
+
even on timeout, so the agent can act on the last state.
|
|
129
|
+
"""
|
|
130
|
+
rx = re.compile(pattern, flags)
|
|
131
|
+
poll = poll_ms / 1000.0
|
|
132
|
+
min_wait = min_wait_ms / 1000.0
|
|
133
|
+
start = time.monotonic()
|
|
134
|
+
deadline = start + (timeout_ms / 1000.0)
|
|
135
|
+
|
|
136
|
+
while True:
|
|
137
|
+
now = time.monotonic()
|
|
138
|
+
read_fn()
|
|
139
|
+
elapsed = now - start
|
|
140
|
+
if elapsed >= min_wait and rx.search(get_text_fn()):
|
|
141
|
+
return True, get_snapshot_fn()
|
|
142
|
+
if now >= deadline:
|
|
143
|
+
return False, get_snapshot_fn()
|
|
144
|
+
time.sleep(poll)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def wait_ready(
|
|
148
|
+
read_fn: ReadFn,
|
|
149
|
+
get_screen_hash_fn: HashFn,
|
|
150
|
+
get_text_fn: TextFn,
|
|
151
|
+
get_snapshot_fn: Callable[[], object],
|
|
152
|
+
marker: Optional[str] = None,
|
|
153
|
+
quiet_ms: int = 200,
|
|
154
|
+
poll_ms: int = 30,
|
|
155
|
+
max_wait_ms: int = 10000,
|
|
156
|
+
min_wait_ms: int = 50,
|
|
157
|
+
grace_ms: int = 40,
|
|
158
|
+
flags: int = 0,
|
|
159
|
+
) -> Tuple[str, object]:
|
|
160
|
+
"""Unified wait: satisfy on ``marker`` OR screen stability, capped by max_wait.
|
|
161
|
+
|
|
162
|
+
A single loop races the marker (if given) against stability so callers get
|
|
163
|
+
the earliest safe moment. ``min_wait_ms`` guards the stale-screen race after
|
|
164
|
+
sending input.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
``(reason, snapshot)`` where ``reason`` is ``"MARKER"``, ``"STABLE"`` or
|
|
168
|
+
``"TIMEOUT"``. The snapshot is always the current screen.
|
|
169
|
+
"""
|
|
170
|
+
rx = re.compile(marker, flags) if marker else None
|
|
171
|
+
poll = poll_ms / 1000.0
|
|
172
|
+
quiet = quiet_ms / 1000.0
|
|
173
|
+
grace = grace_ms / 1000.0
|
|
174
|
+
min_wait = min_wait_ms / 1000.0
|
|
175
|
+
|
|
176
|
+
start = time.monotonic()
|
|
177
|
+
deadline = start + (max_wait_ms / 1000.0)
|
|
178
|
+
last_hash: Optional[int] = None
|
|
179
|
+
stable_since: Optional[float] = None
|
|
180
|
+
|
|
181
|
+
while True:
|
|
182
|
+
now = time.monotonic()
|
|
183
|
+
data = read_fn()
|
|
184
|
+
elapsed = now - start
|
|
185
|
+
|
|
186
|
+
# 1) marker wins immediately (respect min_wait)
|
|
187
|
+
if rx is not None and elapsed >= min_wait and rx.search(get_text_fn()):
|
|
188
|
+
return "MARKER", get_snapshot_fn()
|
|
189
|
+
|
|
190
|
+
# 2) stability
|
|
191
|
+
h = get_screen_hash_fn()
|
|
192
|
+
if not data and h == last_hash:
|
|
193
|
+
if stable_since is None:
|
|
194
|
+
stable_since = now
|
|
195
|
+
elif (now - stable_since) >= quiet and elapsed >= min_wait:
|
|
196
|
+
if grace > 0:
|
|
197
|
+
time.sleep(grace)
|
|
198
|
+
tail = read_fn()
|
|
199
|
+
if tail:
|
|
200
|
+
stable_since = None
|
|
201
|
+
last_hash = get_screen_hash_fn()
|
|
202
|
+
continue
|
|
203
|
+
return "STABLE", get_snapshot_fn()
|
|
204
|
+
else:
|
|
205
|
+
stable_since = None
|
|
206
|
+
last_hash = h
|
|
207
|
+
|
|
208
|
+
if now >= deadline:
|
|
209
|
+
return "TIMEOUT", get_snapshot_fn()
|
|
210
|
+
time.sleep(poll)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Thin wrapper over ``pyte`` that turns a byte stream into an inspectable grid.
|
|
2
|
+
|
|
3
|
+
:class:`ScreenModel` owns a single long-lived ``pyte.ByteStream`` + ``pyte.Screen``
|
|
4
|
+
pair. Feed raw PTY bytes with :meth:`feed`; the stream is stateful and stream-safe
|
|
5
|
+
so partial ANSI escapes and split multibyte UTF-8 across reads are handled
|
|
6
|
+
correctly. Never recreate the stream per read.
|
|
7
|
+
|
|
8
|
+
Exposes plain text (``pyte.screen.display``), the cursor, a stability hash, and a
|
|
9
|
+
per-cell attribute reader that copes with the sparse dict-of-dicts buffer.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import zlib
|
|
15
|
+
from typing import List, NamedTuple, Tuple
|
|
16
|
+
|
|
17
|
+
import pyte
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CellAttrs(NamedTuple):
|
|
21
|
+
"""Reduced view of a single :class:`pyte.screens.Char`."""
|
|
22
|
+
|
|
23
|
+
data: str
|
|
24
|
+
fg: str
|
|
25
|
+
bg: str
|
|
26
|
+
bold: bool
|
|
27
|
+
reverse: bool
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ScreenModel:
|
|
31
|
+
"""Feed PTY bytes into a ``pyte`` screen and read structured state back out."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, cols: int = 80, rows: int = 24) -> None:
|
|
34
|
+
self._cols = cols
|
|
35
|
+
self._rows = rows
|
|
36
|
+
self.screen = pyte.Screen(cols, rows)
|
|
37
|
+
# ByteStream decodes UTF-8 incrementally, so multibyte chars split across
|
|
38
|
+
# feed() boundaries are reassembled correctly.
|
|
39
|
+
self.stream = pyte.ByteStream(self.screen)
|
|
40
|
+
|
|
41
|
+
# -- feeding -----------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
def feed(self, data: bytes) -> None:
|
|
44
|
+
"""Feed raw bytes from the PTY into the screen. Safe with partial data."""
|
|
45
|
+
if data:
|
|
46
|
+
self.stream.feed(data)
|
|
47
|
+
|
|
48
|
+
def resize(self, cols: int, rows: int) -> None:
|
|
49
|
+
"""Resize the underlying screen. Keep this in sync with the PTY winsize."""
|
|
50
|
+
self._cols = cols
|
|
51
|
+
self._rows = rows
|
|
52
|
+
# pyte.Screen.resize takes (lines, columns).
|
|
53
|
+
self.screen.resize(rows, cols)
|
|
54
|
+
|
|
55
|
+
# -- geometry ----------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def cols(self) -> int:
|
|
59
|
+
return self.screen.columns
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def rows(self) -> int:
|
|
63
|
+
return self.screen.lines
|
|
64
|
+
|
|
65
|
+
# -- plain text --------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def display(self) -> List[str]:
|
|
69
|
+
"""List of rendered lines (wide-char aware, right-padded to ``cols``)."""
|
|
70
|
+
return list(self.screen.display)
|
|
71
|
+
|
|
72
|
+
def text(self) -> str:
|
|
73
|
+
"""The full screen joined with newlines (trailing padding preserved)."""
|
|
74
|
+
return "\n".join(self.screen.display)
|
|
75
|
+
|
|
76
|
+
# -- cursor ------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def cursor(self) -> Tuple[int, int]:
|
|
80
|
+
"""Cursor as ``(row, col)``, both 0-based."""
|
|
81
|
+
return (self.screen.cursor.y, self.screen.cursor.x)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def cursor_hidden(self) -> bool:
|
|
85
|
+
return bool(self.screen.cursor.hidden)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def title(self) -> str:
|
|
89
|
+
return self.screen.title or ""
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def base_reverse(self) -> bool:
|
|
93
|
+
"""Screen-wide reverse baseline (DECSCNM). Highlight is measured vs this."""
|
|
94
|
+
return bool(self.screen.default_char.reverse)
|
|
95
|
+
|
|
96
|
+
# -- attributes --------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
def cell(self, row: int, col: int) -> CellAttrs:
|
|
99
|
+
"""Return attributes for one cell.
|
|
100
|
+
|
|
101
|
+
Safe against the sparse buffer: ``screen.buffer`` is a real ``defaultdict``
|
|
102
|
+
(indexing a missing *row* would insert it), so we only index rows within
|
|
103
|
+
range; missing *cells* fall back to the screen default char without
|
|
104
|
+
mutating anything.
|
|
105
|
+
"""
|
|
106
|
+
if not (0 <= row < self.screen.lines and 0 <= col < self.screen.columns):
|
|
107
|
+
dc = self.screen.default_char
|
|
108
|
+
return CellAttrs(dc.data, dc.fg, dc.bg, dc.bold, dc.reverse)
|
|
109
|
+
ch = self.screen.buffer[row][col] # StaticDefaultDict: missing col -> default
|
|
110
|
+
return CellAttrs(ch.data, ch.fg, ch.bg, ch.bold, ch.reverse)
|
|
111
|
+
|
|
112
|
+
def row_cells(self, row: int) -> List[CellAttrs]:
|
|
113
|
+
"""Return the attribute cells for a whole row, left to right."""
|
|
114
|
+
if not (0 <= row < self.screen.lines):
|
|
115
|
+
return []
|
|
116
|
+
buf = self.screen.buffer[row]
|
|
117
|
+
out = []
|
|
118
|
+
for col in range(self.screen.columns):
|
|
119
|
+
ch = buf[col]
|
|
120
|
+
out.append(CellAttrs(ch.data, ch.fg, ch.bg, ch.bold, ch.reverse))
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
# -- stability ---------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
def content_hash(self) -> int:
|
|
126
|
+
"""CRC32 of the plain-text display.
|
|
127
|
+
|
|
128
|
+
Excludes cursor position and all attributes, so cursor movement and
|
|
129
|
+
attribute-only churn (blink/reverse cycling) do not count as changes for
|
|
130
|
+
stability detection.
|
|
131
|
+
"""
|
|
132
|
+
return zlib.crc32(self.text().encode("utf-8", "replace"))
|