tuicast 0.0.1__tar.gz
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.
- tuicast-0.0.1/.gitignore +20 -0
- tuicast-0.0.1/PKG-INFO +22 -0
- tuicast-0.0.1/README.md +4 -0
- tuicast-0.0.1/pyproject.toml +48 -0
- tuicast-0.0.1/src/tuicast/__init__.py +35 -0
- tuicast-0.0.1/src/tuicast/client.py +566 -0
- tuicast-0.0.1/src/tuicast/model.py +205 -0
- tuicast-0.0.1/tests/helper_driver.py +100 -0
- tuicast-0.0.1/tests/test_sdk.py +128 -0
tuicast-0.0.1/.gitignore
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
notes.txt
|
|
2
|
+
scratch.md
|
|
3
|
+
docs/proposal-reference-tui.md
|
|
4
|
+
node_modules/
|
|
5
|
+
cucumber-reports/
|
|
6
|
+
.coverage
|
|
7
|
+
.mypy_cache/
|
|
8
|
+
.pytest_cache/
|
|
9
|
+
.ruff_cache/
|
|
10
|
+
__pycache__/
|
|
11
|
+
*.egg-info/
|
|
12
|
+
sdk/python/.venv/
|
|
13
|
+
sdk/python/build/
|
|
14
|
+
sdk/python/dist/
|
|
15
|
+
sdk/python/htmlcov/
|
|
16
|
+
sdk/python/coverage.xml
|
|
17
|
+
sdk/typescript/coverage/
|
|
18
|
+
sdk/typescript/dist/
|
|
19
|
+
sdk/typescript/*.tgz
|
|
20
|
+
target/
|
tuicast-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tuicast
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Python client for the TUICast terminal automation driver
|
|
5
|
+
Author: CastingCode
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: behave<2,>=1.2.6; extra == 'dev'
|
|
12
|
+
Requires-Dist: build<2,>=1.2; extra == 'dev'
|
|
13
|
+
Requires-Dist: mypy<2,>=1.15; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest-cov<7,>=6; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest<9,>=8.3; extra == 'dev'
|
|
16
|
+
Requires-Dist: ruff<1,>=0.11; extra == 'dev'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# TUICast Python SDK
|
|
20
|
+
|
|
21
|
+
An idiomatic synchronous Python 3.12+ client for `tuicast-driver`. See
|
|
22
|
+
[`design/python-sdk.md`](../../design/python-sdk.md) for setup and commands.
|
tuicast-0.0.1/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tuicast"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "Python client for the TUICast terminal automation driver"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "CastingCode" }]
|
|
13
|
+
classifiers = ["Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.12"]
|
|
14
|
+
dependencies = []
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
dev = [
|
|
18
|
+
"behave>=1.2.6,<2",
|
|
19
|
+
"build>=1.2,<2",
|
|
20
|
+
"mypy>=1.15,<2",
|
|
21
|
+
"pytest>=8.3,<9",
|
|
22
|
+
"pytest-cov>=6,<7",
|
|
23
|
+
"ruff>=0.11,<1",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.wheel]
|
|
27
|
+
packages = ["src/tuicast"]
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
addopts = "--strict-markers --cov=tuicast --cov-report=term-missing --cov-fail-under=85"
|
|
31
|
+
testpaths = ["tests"]
|
|
32
|
+
markers = ["reference: requires local reference TUI services"]
|
|
33
|
+
|
|
34
|
+
[tool.coverage.run]
|
|
35
|
+
branch = true
|
|
36
|
+
|
|
37
|
+
[tool.ruff]
|
|
38
|
+
line-length = 100
|
|
39
|
+
target-version = "py312"
|
|
40
|
+
|
|
41
|
+
[tool.ruff.lint]
|
|
42
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
|
|
43
|
+
ignore = ["F401", "UP046", "SIM105"]
|
|
44
|
+
|
|
45
|
+
[tool.mypy]
|
|
46
|
+
python_version = "3.12"
|
|
47
|
+
strict = true
|
|
48
|
+
packages = ["tuicast"]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from .client import (
|
|
2
|
+
SSH,
|
|
3
|
+
Connection,
|
|
4
|
+
Driver,
|
|
5
|
+
EventSubscription,
|
|
6
|
+
ProtocolError,
|
|
7
|
+
RPCError,
|
|
8
|
+
RPCTimeoutError,
|
|
9
|
+
ScreenSubscription,
|
|
10
|
+
Session,
|
|
11
|
+
Telnet,
|
|
12
|
+
TUICastError,
|
|
13
|
+
WaitError,
|
|
14
|
+
is_timeout,
|
|
15
|
+
)
|
|
16
|
+
from .model import (
|
|
17
|
+
Attributes,
|
|
18
|
+
Cell,
|
|
19
|
+
Color,
|
|
20
|
+
Cursor,
|
|
21
|
+
Key,
|
|
22
|
+
Modifier,
|
|
23
|
+
Position,
|
|
24
|
+
Screen,
|
|
25
|
+
Terminal,
|
|
26
|
+
TerminalEvent,
|
|
27
|
+
all_of,
|
|
28
|
+
any_of,
|
|
29
|
+
contains,
|
|
30
|
+
cursor_at,
|
|
31
|
+
line_equals,
|
|
32
|
+
not_,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = [name for name in globals() if not name.startswith("_")]
|
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import queue
|
|
7
|
+
import subprocess
|
|
8
|
+
import threading
|
|
9
|
+
from collections.abc import Iterator, Mapping
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any, BinaryIO, Generic, Literal, TextIO, TypeVar, overload
|
|
12
|
+
|
|
13
|
+
from .model import Key, Matcher, Modifier, Screen, Terminal, TerminalEvent, contains, not_
|
|
14
|
+
|
|
15
|
+
PROTOCOL_VERSION = "1"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TUICastError(Exception):
|
|
19
|
+
"""Base SDK error."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ProtocolError(TUICastError):
|
|
23
|
+
"""Malformed or incompatible driver protocol."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RPCTimeoutError(TimeoutError, TUICastError):
|
|
27
|
+
"""A client-side RPC deadline expired."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RPCError(TUICastError):
|
|
31
|
+
def __init__(self, code: int, message: str, data: Any = None):
|
|
32
|
+
super().__init__(f"driver error {code}: {message}")
|
|
33
|
+
self.code, self.message, self.data = code, message, data
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class WaitError(RPCError):
|
|
37
|
+
def __init__(self, code: int, message: str, data: Mapping[str, Any]):
|
|
38
|
+
super().__init__(code, message, data)
|
|
39
|
+
self.kind = str(data.get("kind", ""))
|
|
40
|
+
self.expected = str(data.get("expected", ""))
|
|
41
|
+
self.last_screen = Screen.from_dict(data["screen"])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def is_timeout(error: BaseException) -> bool:
|
|
45
|
+
return isinstance(error, (TimeoutError, RPCTimeoutError)) or (
|
|
46
|
+
isinstance(error, WaitError) and error.kind == "timeout"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class Telnet:
|
|
52
|
+
address: str
|
|
53
|
+
timeout: float | None = None
|
|
54
|
+
|
|
55
|
+
def params(self, default_timeout: float) -> tuple[dict[str, Any], float]:
|
|
56
|
+
if not self.address:
|
|
57
|
+
raise ValueError("configuring Telnet connection: address is required")
|
|
58
|
+
timeout = _positive(self.timeout or default_timeout, "connection timeout")
|
|
59
|
+
return {
|
|
60
|
+
"protocol": "telnet",
|
|
61
|
+
"address": self.address,
|
|
62
|
+
"connectTimeoutMilliseconds": _milliseconds(timeout),
|
|
63
|
+
}, timeout
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class SSH:
|
|
68
|
+
address: str
|
|
69
|
+
username: str
|
|
70
|
+
password: str = ""
|
|
71
|
+
private_key: str = ""
|
|
72
|
+
private_key_passphrase: str = ""
|
|
73
|
+
known_hosts_file: str = ""
|
|
74
|
+
host_key_fingerprint: str = ""
|
|
75
|
+
insecure_skip_host_key_check: bool = False
|
|
76
|
+
timeout: float | None = None
|
|
77
|
+
|
|
78
|
+
def params(self, default_timeout: float) -> tuple[dict[str, Any], float]:
|
|
79
|
+
if not self.address or not self.username:
|
|
80
|
+
raise ValueError("configuring SSH connection: address and username are required")
|
|
81
|
+
if not self.password and not self.private_key:
|
|
82
|
+
raise ValueError("configuring SSH connection: password or private key is required")
|
|
83
|
+
checks = sum(
|
|
84
|
+
bool(v)
|
|
85
|
+
for v in (
|
|
86
|
+
self.known_hosts_file,
|
|
87
|
+
self.host_key_fingerprint,
|
|
88
|
+
self.insecure_skip_host_key_check,
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
if checks != 1:
|
|
92
|
+
raise ValueError(
|
|
93
|
+
"configuring SSH connection: exactly one host-key verification option is required"
|
|
94
|
+
)
|
|
95
|
+
timeout = _positive(self.timeout or default_timeout, "connection timeout")
|
|
96
|
+
return {
|
|
97
|
+
"protocol": "ssh",
|
|
98
|
+
"address": self.address,
|
|
99
|
+
"username": self.username,
|
|
100
|
+
"password": self.password,
|
|
101
|
+
"privateKey": self.private_key,
|
|
102
|
+
"privateKeyPassphrase": self.private_key_passphrase,
|
|
103
|
+
"knownHostsFile": self.known_hosts_file,
|
|
104
|
+
"hostKeyFingerprint": self.host_key_fingerprint,
|
|
105
|
+
"insecureSkipHostKeyCheck": self.insecure_skip_host_key_check,
|
|
106
|
+
"connectTimeoutMilliseconds": _milliseconds(timeout),
|
|
107
|
+
}, timeout
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Driver:
|
|
111
|
+
def __init__(self, process: subprocess.Popen[bytes], *, default_timeout: float = 30.0):
|
|
112
|
+
self._process, self.default_timeout = process, _positive(default_timeout, "default timeout")
|
|
113
|
+
self._lock, self._write_lock = threading.Lock(), threading.Lock()
|
|
114
|
+
self._pending: dict[int, queue.Queue[Any]] = {}
|
|
115
|
+
self._subscriptions: dict[int, _Subscription[Any]] = {}
|
|
116
|
+
self._queued: dict[int, list[tuple[str, Mapping[str, Any]]]] = {}
|
|
117
|
+
self._next_id = 0
|
|
118
|
+
self._failure: BaseException | None = None
|
|
119
|
+
self._closed = False
|
|
120
|
+
self._reader = threading.Thread(target=self._read, name="tuicast-jsonrpc", daemon=True)
|
|
121
|
+
self._reader.start()
|
|
122
|
+
|
|
123
|
+
@classmethod
|
|
124
|
+
def launch(
|
|
125
|
+
cls,
|
|
126
|
+
driver_path: str | os.PathLike[str] = "tuicast-driver",
|
|
127
|
+
*,
|
|
128
|
+
args: tuple[str, ...] = (),
|
|
129
|
+
environment: Mapping[str, str] | None = None,
|
|
130
|
+
stderr: int | TextIO | BinaryIO | None = None,
|
|
131
|
+
default_timeout: float = 30.0,
|
|
132
|
+
) -> Driver:
|
|
133
|
+
env = os.environ.copy()
|
|
134
|
+
if environment:
|
|
135
|
+
env.update(environment)
|
|
136
|
+
process = subprocess.Popen(
|
|
137
|
+
[str(driver_path), *args],
|
|
138
|
+
stdin=subprocess.PIPE,
|
|
139
|
+
stdout=subprocess.PIPE,
|
|
140
|
+
stderr=stderr,
|
|
141
|
+
env=env,
|
|
142
|
+
)
|
|
143
|
+
driver = cls(process, default_timeout=default_timeout)
|
|
144
|
+
try:
|
|
145
|
+
ping = driver._call("driver.ping", timeout=default_timeout)
|
|
146
|
+
version = ping.get("protocolVersion") if isinstance(ping, dict) else None
|
|
147
|
+
if version != PROTOCOL_VERSION:
|
|
148
|
+
raise ProtocolError(
|
|
149
|
+
f"client requires protocol version 1, driver reported {version!r}"
|
|
150
|
+
)
|
|
151
|
+
return driver
|
|
152
|
+
except BaseException:
|
|
153
|
+
driver._stop()
|
|
154
|
+
raise
|
|
155
|
+
|
|
156
|
+
def _read(self) -> None:
|
|
157
|
+
assert self._process.stdout is not None
|
|
158
|
+
try:
|
|
159
|
+
for raw in self._process.stdout:
|
|
160
|
+
try:
|
|
161
|
+
message = json.loads(raw)
|
|
162
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as error:
|
|
163
|
+
raise ProtocolError(f"malformed driver response: {error}") from error
|
|
164
|
+
if not isinstance(message, dict) or message.get("jsonrpc") != "2.0":
|
|
165
|
+
raise ProtocolError("driver response has unsupported JSON-RPC version")
|
|
166
|
+
if "id" in message:
|
|
167
|
+
identifier = message["id"]
|
|
168
|
+
if not isinstance(identifier, int) or identifier <= 0:
|
|
169
|
+
raise ProtocolError("driver response identifier must be a positive integer")
|
|
170
|
+
with self._lock:
|
|
171
|
+
pending = self._pending.pop(identifier, None)
|
|
172
|
+
if pending is not None:
|
|
173
|
+
pending.put(message)
|
|
174
|
+
elif "method" in message:
|
|
175
|
+
self._notify(str(message["method"]), message.get("params", {}))
|
|
176
|
+
else:
|
|
177
|
+
raise ProtocolError("driver message is neither response nor notification")
|
|
178
|
+
if not self._closed:
|
|
179
|
+
raise ProtocolError("driver output closed unexpectedly")
|
|
180
|
+
except BaseException as error:
|
|
181
|
+
self._fail(error)
|
|
182
|
+
|
|
183
|
+
def _notify(self, method: str, params: Any) -> None:
|
|
184
|
+
if method not in ("session.screen", "session.event") or not isinstance(params, dict):
|
|
185
|
+
return
|
|
186
|
+
identifier = params.get("subscriptionId")
|
|
187
|
+
if not isinstance(identifier, int):
|
|
188
|
+
return
|
|
189
|
+
with self._lock:
|
|
190
|
+
subscription = self._subscriptions.get(identifier)
|
|
191
|
+
if subscription is None:
|
|
192
|
+
queued = self._queued.setdefault(identifier, [])
|
|
193
|
+
if method == "session.screen":
|
|
194
|
+
queued[:] = [(method, params)]
|
|
195
|
+
elif len(queued) < 64:
|
|
196
|
+
queued.append((method, params))
|
|
197
|
+
if subscription is not None:
|
|
198
|
+
subscription._deliver(method, params)
|
|
199
|
+
|
|
200
|
+
def _fail(self, error: BaseException) -> None:
|
|
201
|
+
with self._lock:
|
|
202
|
+
if self._failure is None:
|
|
203
|
+
self._failure = error
|
|
204
|
+
pending, subscriptions = (
|
|
205
|
+
list(self._pending.values()),
|
|
206
|
+
list(self._subscriptions.values()),
|
|
207
|
+
)
|
|
208
|
+
self._pending.clear()
|
|
209
|
+
self._subscriptions.clear()
|
|
210
|
+
for result in pending:
|
|
211
|
+
result.put(error)
|
|
212
|
+
for subscription in subscriptions:
|
|
213
|
+
subscription._finish()
|
|
214
|
+
|
|
215
|
+
def _call(
|
|
216
|
+
self, method: str, params: Mapping[str, Any] | None = None, *, timeout: float | None = None
|
|
217
|
+
) -> Any:
|
|
218
|
+
duration = _positive(timeout or self.default_timeout, "operation timeout")
|
|
219
|
+
result: queue.Queue[Any] = queue.Queue(maxsize=1)
|
|
220
|
+
with self._lock:
|
|
221
|
+
if self._failure:
|
|
222
|
+
raise TUICastError(f"calling {method}: driver failed") from self._failure
|
|
223
|
+
self._next_id += 1
|
|
224
|
+
identifier = self._next_id
|
|
225
|
+
self._pending[identifier] = result
|
|
226
|
+
request = {"jsonrpc": "2.0", "id": identifier, "method": method}
|
|
227
|
+
if params is not None:
|
|
228
|
+
request["params"] = params
|
|
229
|
+
try:
|
|
230
|
+
assert self._process.stdin is not None
|
|
231
|
+
with self._write_lock:
|
|
232
|
+
self._process.stdin.write(
|
|
233
|
+
json.dumps(_plain(request), separators=(",", ":")).encode() + b"\n"
|
|
234
|
+
)
|
|
235
|
+
self._process.stdin.flush()
|
|
236
|
+
except BaseException as error:
|
|
237
|
+
with self._lock:
|
|
238
|
+
self._pending.pop(identifier, None)
|
|
239
|
+
self._fail(error)
|
|
240
|
+
raise TUICastError(f"calling {method}: writing request failed") from error
|
|
241
|
+
try:
|
|
242
|
+
reply = result.get(timeout=duration)
|
|
243
|
+
except queue.Empty as error:
|
|
244
|
+
with self._lock:
|
|
245
|
+
self._pending.pop(identifier, None)
|
|
246
|
+
raise RPCTimeoutError(f"calling {method} timed out after {duration:g}s") from error
|
|
247
|
+
if isinstance(reply, BaseException):
|
|
248
|
+
raise TUICastError(f"calling {method}: driver failed") from reply
|
|
249
|
+
if "error" in reply:
|
|
250
|
+
rpc = reply["error"]
|
|
251
|
+
data = rpc.get("data")
|
|
252
|
+
if isinstance(data, dict) and data.get("kind") and isinstance(data.get("screen"), dict):
|
|
253
|
+
raise WaitError(int(rpc["code"]), str(rpc["message"]), data)
|
|
254
|
+
raise RPCError(int(rpc["code"]), str(rpc["message"]), data)
|
|
255
|
+
if "result" not in reply:
|
|
256
|
+
raise ProtocolError(f"calling {method}: response has no result or error")
|
|
257
|
+
return reply["result"]
|
|
258
|
+
|
|
259
|
+
def connect(self, config: Telnet | SSH) -> Connection:
|
|
260
|
+
if not isinstance(config, (Telnet, SSH)):
|
|
261
|
+
raise TypeError("connection configuration must be SSH or Telnet")
|
|
262
|
+
params, timeout = config.params(self.default_timeout)
|
|
263
|
+
result = self._call("connection.open", params, timeout=timeout + 1)
|
|
264
|
+
return Connection(self, int(result["connectionId"]))
|
|
265
|
+
|
|
266
|
+
def close(self) -> None:
|
|
267
|
+
with self._lock:
|
|
268
|
+
if self._closed:
|
|
269
|
+
return
|
|
270
|
+
self._closed = True
|
|
271
|
+
try:
|
|
272
|
+
if self._process.poll() is None:
|
|
273
|
+
self._call("driver.shutdown", timeout=self.default_timeout)
|
|
274
|
+
except TUICastError:
|
|
275
|
+
pass
|
|
276
|
+
finally:
|
|
277
|
+
self._stop()
|
|
278
|
+
|
|
279
|
+
def _stop(self) -> None:
|
|
280
|
+
if self._process.stdin:
|
|
281
|
+
self._process.stdin.close()
|
|
282
|
+
try:
|
|
283
|
+
self._process.wait(timeout=self.default_timeout)
|
|
284
|
+
except subprocess.TimeoutExpired:
|
|
285
|
+
self._process.kill()
|
|
286
|
+
self._process.wait()
|
|
287
|
+
|
|
288
|
+
def __enter__(self) -> Driver:
|
|
289
|
+
return self
|
|
290
|
+
|
|
291
|
+
def __exit__(self, *_: object) -> None:
|
|
292
|
+
self.close()
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class Connection:
|
|
296
|
+
def __init__(self, driver: Driver, identifier: int):
|
|
297
|
+
self.driver, self.id, self._closed = driver, identifier, False
|
|
298
|
+
self._lock = threading.Lock()
|
|
299
|
+
|
|
300
|
+
def open_session(
|
|
301
|
+
self,
|
|
302
|
+
*,
|
|
303
|
+
terminal: Terminal = Terminal.VT220,
|
|
304
|
+
width: int = 80,
|
|
305
|
+
height: int = 24,
|
|
306
|
+
answerback: str = "",
|
|
307
|
+
) -> Session:
|
|
308
|
+
if terminal not in Terminal:
|
|
309
|
+
raise ValueError(f"unsupported terminal profile {terminal!r}")
|
|
310
|
+
if width <= 0 or height <= 0:
|
|
311
|
+
raise ValueError("width and height must be positive")
|
|
312
|
+
if len(answerback.encode("ascii", "strict")) > 20 or any(
|
|
313
|
+
not 32 <= ord(c) <= 126 for c in answerback
|
|
314
|
+
):
|
|
315
|
+
raise ValueError("answerback must be at most 20 bytes of printable ASCII")
|
|
316
|
+
result = self.driver._call(
|
|
317
|
+
"session.open",
|
|
318
|
+
{
|
|
319
|
+
"connectionId": self.id,
|
|
320
|
+
"terminal": str(terminal),
|
|
321
|
+
"width": width,
|
|
322
|
+
"height": height,
|
|
323
|
+
"answerback": answerback,
|
|
324
|
+
},
|
|
325
|
+
)
|
|
326
|
+
return Session(self, int(result["sessionId"]))
|
|
327
|
+
|
|
328
|
+
def close(self) -> None:
|
|
329
|
+
with self._lock:
|
|
330
|
+
if self._closed:
|
|
331
|
+
return
|
|
332
|
+
self.driver._call("connection.close", {"connectionId": self.id})
|
|
333
|
+
self._closed = True
|
|
334
|
+
|
|
335
|
+
def __enter__(self) -> Connection:
|
|
336
|
+
return self
|
|
337
|
+
|
|
338
|
+
def __exit__(self, *_: object) -> None:
|
|
339
|
+
self.close()
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
class Session:
|
|
343
|
+
def __init__(self, connection: Connection, identifier: int):
|
|
344
|
+
self.connection, self.id, self._closed = connection, identifier, False
|
|
345
|
+
self._lock = threading.Lock()
|
|
346
|
+
|
|
347
|
+
@property
|
|
348
|
+
def driver(self) -> Driver:
|
|
349
|
+
return self.connection.driver
|
|
350
|
+
|
|
351
|
+
def type(self, text: str) -> None:
|
|
352
|
+
self.driver._call("session.send", {"sessionId": self.id, "text": text})
|
|
353
|
+
|
|
354
|
+
def send(self, data: bytes) -> None:
|
|
355
|
+
self.driver._call(
|
|
356
|
+
"session.send", {"sessionId": self.id, "base64": base64.b64encode(data).decode("ascii")}
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
def press(self, key: Key | str, *modifiers: Modifier) -> None:
|
|
360
|
+
value = str(key)
|
|
361
|
+
named = {str(member) for member in Key}
|
|
362
|
+
if value not in named and (len(value) != 1 or not value.isprintable()):
|
|
363
|
+
raise ValueError(f"unsupported key {value!r}")
|
|
364
|
+
self.driver._call(
|
|
365
|
+
"session.press",
|
|
366
|
+
{
|
|
367
|
+
"sessionId": self.id,
|
|
368
|
+
"key": value,
|
|
369
|
+
"modifiers": [str(modifier) for modifier in modifiers],
|
|
370
|
+
},
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
def resize(self, width: int, height: int) -> None:
|
|
374
|
+
if width <= 0 or height <= 0:
|
|
375
|
+
raise ValueError("width and height must be positive")
|
|
376
|
+
self.driver._call(
|
|
377
|
+
"session.resize", {"sessionId": self.id, "width": width, "height": height}
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
def screen(self) -> Screen:
|
|
381
|
+
return Screen.from_dict(self.driver._call("session.screen", {"sessionId": self.id}))
|
|
382
|
+
|
|
383
|
+
def wait_for(
|
|
384
|
+
self, matcher: Matcher, *, timeout: float | None = None, stable_for: float = 0
|
|
385
|
+
) -> Screen:
|
|
386
|
+
duration = _positive(timeout or self.driver.default_timeout, "wait timeout")
|
|
387
|
+
if stable_for < 0:
|
|
388
|
+
raise ValueError("stable_for must not be negative")
|
|
389
|
+
params = {
|
|
390
|
+
"sessionId": self.id,
|
|
391
|
+
"matcher": matcher,
|
|
392
|
+
"timeoutMilliseconds": _milliseconds(duration),
|
|
393
|
+
"stableMilliseconds": _milliseconds(stable_for) if stable_for else 0,
|
|
394
|
+
}
|
|
395
|
+
return Screen.from_dict(self.driver._call("session.wait", params, timeout=duration + 1))
|
|
396
|
+
|
|
397
|
+
def wait_for_text(self, text: str, **options: float) -> Screen:
|
|
398
|
+
return self.wait_for(contains(text), **options)
|
|
399
|
+
|
|
400
|
+
def wait_for_text_gone(self, text: str, **options: float) -> Screen:
|
|
401
|
+
return self.wait_for(not_(contains(text)), **options)
|
|
402
|
+
|
|
403
|
+
def wait_for_idle(self, *, timeout: float | None = None, quiet_for: float = 0.1) -> Screen:
|
|
404
|
+
duration = _positive(timeout or self.driver.default_timeout, "idle timeout")
|
|
405
|
+
quiet = _positive(quiet_for, "idle quiet period")
|
|
406
|
+
params = {
|
|
407
|
+
"sessionId": self.id,
|
|
408
|
+
"timeoutMilliseconds": _milliseconds(duration),
|
|
409
|
+
"quietMilliseconds": _milliseconds(quiet),
|
|
410
|
+
}
|
|
411
|
+
return Screen.from_dict(
|
|
412
|
+
self.driver._call("session.waitForIdle", params, timeout=duration + 1)
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
def subscribe(self) -> ScreenSubscription:
|
|
416
|
+
return self._subscribe("session.subscribe", True)
|
|
417
|
+
|
|
418
|
+
def subscribe_events(self) -> EventSubscription:
|
|
419
|
+
return self._subscribe("session.subscribeEvents", False)
|
|
420
|
+
|
|
421
|
+
@overload
|
|
422
|
+
def _subscribe(self, method: str, screens: Literal[True]) -> ScreenSubscription: ...
|
|
423
|
+
|
|
424
|
+
@overload
|
|
425
|
+
def _subscribe(self, method: str, screens: Literal[False]) -> EventSubscription: ...
|
|
426
|
+
|
|
427
|
+
def _subscribe(self, method: str, screens: bool) -> ScreenSubscription | EventSubscription:
|
|
428
|
+
result = self.driver._call(method, {"sessionId": self.id})
|
|
429
|
+
identifier = int(result["subscriptionId"])
|
|
430
|
+
subscription: ScreenSubscription | EventSubscription = (
|
|
431
|
+
ScreenSubscription(self.driver, identifier)
|
|
432
|
+
if screens
|
|
433
|
+
else EventSubscription(self.driver, identifier)
|
|
434
|
+
)
|
|
435
|
+
with self.driver._lock:
|
|
436
|
+
self.driver._subscriptions[identifier] = subscription
|
|
437
|
+
queued = self.driver._queued.pop(identifier, [])
|
|
438
|
+
for notification in queued:
|
|
439
|
+
subscription._deliver(*notification)
|
|
440
|
+
return subscription
|
|
441
|
+
|
|
442
|
+
def close(self) -> None:
|
|
443
|
+
with self._lock:
|
|
444
|
+
if self._closed:
|
|
445
|
+
return
|
|
446
|
+
self.driver._call("session.close", {"sessionId": self.id})
|
|
447
|
+
self._closed = True
|
|
448
|
+
|
|
449
|
+
def __enter__(self) -> Session:
|
|
450
|
+
return self
|
|
451
|
+
|
|
452
|
+
def __exit__(self, *_: object) -> None:
|
|
453
|
+
self.close()
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
T = TypeVar("T")
|
|
457
|
+
_SENTINEL = object()
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
class _Subscription(Generic[T], Iterator[T]):
|
|
461
|
+
def __init__(self, driver: Driver, identifier: int, size: int):
|
|
462
|
+
self.driver, self.id = driver, identifier
|
|
463
|
+
self._queue: queue.Queue[T | object] = queue.Queue(size)
|
|
464
|
+
self._closed, self._lock = False, threading.Lock()
|
|
465
|
+
|
|
466
|
+
def __iter__(self) -> _Subscription[T]:
|
|
467
|
+
return self
|
|
468
|
+
|
|
469
|
+
def __next__(self) -> T:
|
|
470
|
+
value = self.get()
|
|
471
|
+
return value
|
|
472
|
+
|
|
473
|
+
def get(self, timeout: float | None = None) -> T:
|
|
474
|
+
value = self._queue.get(timeout=timeout)
|
|
475
|
+
if value is _SENTINEL:
|
|
476
|
+
raise StopIteration
|
|
477
|
+
return value # type: ignore[return-value]
|
|
478
|
+
|
|
479
|
+
def _finish(self) -> None:
|
|
480
|
+
with self._lock:
|
|
481
|
+
if self._closed:
|
|
482
|
+
return
|
|
483
|
+
self._closed = True
|
|
484
|
+
try:
|
|
485
|
+
self._queue.put_nowait(_SENTINEL)
|
|
486
|
+
except queue.Full:
|
|
487
|
+
try:
|
|
488
|
+
self._queue.get_nowait()
|
|
489
|
+
except queue.Empty:
|
|
490
|
+
pass
|
|
491
|
+
self._queue.put_nowait(_SENTINEL)
|
|
492
|
+
|
|
493
|
+
def _deliver(self, method: str, params: Mapping[str, Any]) -> None:
|
|
494
|
+
raise NotImplementedError
|
|
495
|
+
|
|
496
|
+
def close(self) -> None:
|
|
497
|
+
with self._lock:
|
|
498
|
+
if self._closed:
|
|
499
|
+
return
|
|
500
|
+
self._closed = True
|
|
501
|
+
with self.driver._lock:
|
|
502
|
+
self.driver._subscriptions.pop(self.id, None)
|
|
503
|
+
try:
|
|
504
|
+
self.driver._call("session.unsubscribe", {"subscriptionId": self.id})
|
|
505
|
+
finally:
|
|
506
|
+
try:
|
|
507
|
+
self._queue.put_nowait(_SENTINEL)
|
|
508
|
+
except queue.Full:
|
|
509
|
+
pass
|
|
510
|
+
|
|
511
|
+
def __enter__(self) -> _Subscription[T]:
|
|
512
|
+
return self
|
|
513
|
+
|
|
514
|
+
def __exit__(self, *_: object) -> None:
|
|
515
|
+
self.close()
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
class ScreenSubscription(_Subscription[Screen]):
|
|
519
|
+
def __init__(self, driver: Driver, identifier: int):
|
|
520
|
+
super().__init__(driver, identifier, 1)
|
|
521
|
+
|
|
522
|
+
def _deliver(self, method: str, params: Mapping[str, Any]) -> None:
|
|
523
|
+
if method != "session.screen" or not isinstance(params.get("screen"), dict):
|
|
524
|
+
return
|
|
525
|
+
screen = Screen.from_dict(params["screen"])
|
|
526
|
+
try:
|
|
527
|
+
self._queue.get_nowait()
|
|
528
|
+
except queue.Empty:
|
|
529
|
+
pass
|
|
530
|
+
self._queue.put_nowait(screen)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
class EventSubscription(_Subscription[TerminalEvent]):
|
|
534
|
+
def __init__(self, driver: Driver, identifier: int):
|
|
535
|
+
super().__init__(driver, identifier, 64)
|
|
536
|
+
|
|
537
|
+
def _deliver(self, method: str, params: Mapping[str, Any]) -> None:
|
|
538
|
+
if method != "session.event" or not isinstance(params.get("event"), dict):
|
|
539
|
+
return
|
|
540
|
+
event = params["event"]
|
|
541
|
+
try:
|
|
542
|
+
self._queue.put_nowait(
|
|
543
|
+
TerminalEvent(
|
|
544
|
+
int(event["sequence"]), str(event["type"]), str(event.get("data", ""))
|
|
545
|
+
)
|
|
546
|
+
)
|
|
547
|
+
except queue.Full:
|
|
548
|
+
self._finish()
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _positive(value: float, name: str) -> float:
|
|
552
|
+
if value <= 0:
|
|
553
|
+
raise ValueError(f"{name} must be positive")
|
|
554
|
+
return value
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _milliseconds(seconds: float) -> int:
|
|
558
|
+
return max(1, int(seconds * 1000 + 0.999999))
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _plain(value: Any) -> Any:
|
|
562
|
+
if isinstance(value, Mapping):
|
|
563
|
+
return {key: _plain(item) for key, item in value.items()}
|
|
564
|
+
if isinstance(value, (list, tuple)):
|
|
565
|
+
return [_plain(item) for item in value]
|
|
566
|
+
return value
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import IntEnum, IntFlag, StrEnum
|
|
6
|
+
from types import MappingProxyType
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Color(IntEnum):
|
|
11
|
+
DEFAULT = -1
|
|
12
|
+
BLACK = 0
|
|
13
|
+
RED = 1
|
|
14
|
+
GREEN = 2
|
|
15
|
+
YELLOW = 3
|
|
16
|
+
BLUE = 4
|
|
17
|
+
MAGENTA = 5
|
|
18
|
+
CYAN = 6
|
|
19
|
+
WHITE = 7
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Attributes(IntFlag):
|
|
23
|
+
BOLD = 1
|
|
24
|
+
UNDERLINE = 2
|
|
25
|
+
BLINK = 4
|
|
26
|
+
REVERSE = 8
|
|
27
|
+
CONCEAL = 16
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Terminal(StrEnum):
|
|
31
|
+
VT220 = "vt220"
|
|
32
|
+
XTERM_256COLOR = "xterm-256color"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Key(StrEnum):
|
|
36
|
+
ENTER = "Enter"
|
|
37
|
+
TAB = "Tab"
|
|
38
|
+
BACKSPACE = "Backspace"
|
|
39
|
+
ESCAPE = "Escape"
|
|
40
|
+
ARROW_UP = "ArrowUp"
|
|
41
|
+
ARROW_DOWN = "ArrowDown"
|
|
42
|
+
ARROW_RIGHT = "ArrowRight"
|
|
43
|
+
ARROW_LEFT = "ArrowLeft"
|
|
44
|
+
HOME = "Home"
|
|
45
|
+
END = "End"
|
|
46
|
+
INSERT = "Insert"
|
|
47
|
+
DELETE = "Delete"
|
|
48
|
+
PAGE_UP = "PageUp"
|
|
49
|
+
PAGE_DOWN = "PageDown"
|
|
50
|
+
F1 = "F1"
|
|
51
|
+
F2 = "F2"
|
|
52
|
+
F3 = "F3"
|
|
53
|
+
F4 = "F4"
|
|
54
|
+
F5 = "F5"
|
|
55
|
+
F6 = "F6"
|
|
56
|
+
F7 = "F7"
|
|
57
|
+
F8 = "F8"
|
|
58
|
+
F9 = "F9"
|
|
59
|
+
F10 = "F10"
|
|
60
|
+
F11 = "F11"
|
|
61
|
+
F12 = "F12"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Modifier(StrEnum):
|
|
65
|
+
SHIFT = "Shift"
|
|
66
|
+
CONTROL = "Control"
|
|
67
|
+
ALT = "Alt"
|
|
68
|
+
META = "Meta"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True, slots=True)
|
|
72
|
+
class Cell:
|
|
73
|
+
text: str
|
|
74
|
+
width: int
|
|
75
|
+
foreground: int
|
|
76
|
+
background: int
|
|
77
|
+
attributes: Attributes
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True, slots=True)
|
|
81
|
+
class Cursor:
|
|
82
|
+
column: int
|
|
83
|
+
row: int
|
|
84
|
+
visible: bool
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True, slots=True)
|
|
88
|
+
class Position:
|
|
89
|
+
column: int
|
|
90
|
+
row: int
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True, slots=True)
|
|
94
|
+
class Screen:
|
|
95
|
+
width: int
|
|
96
|
+
height: int
|
|
97
|
+
cells: tuple[Cell, ...]
|
|
98
|
+
cursor: Cursor
|
|
99
|
+
revision: int
|
|
100
|
+
text: str
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def from_dict(cls, value: Mapping[str, Any]) -> Screen:
|
|
104
|
+
return cls(
|
|
105
|
+
width=int(value["width"]),
|
|
106
|
+
height=int(value["height"]),
|
|
107
|
+
cells=tuple(
|
|
108
|
+
Cell(
|
|
109
|
+
str(c["text"]),
|
|
110
|
+
int(c["width"]),
|
|
111
|
+
int(c["foreground"]),
|
|
112
|
+
int(c["background"]),
|
|
113
|
+
Attributes(int(c["attributes"])),
|
|
114
|
+
)
|
|
115
|
+
for c in value["cells"]
|
|
116
|
+
),
|
|
117
|
+
cursor=Cursor(**value["cursor"]),
|
|
118
|
+
revision=int(value["revision"]),
|
|
119
|
+
text=str(value["text"]),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def contains(self, text: str) -> bool:
|
|
123
|
+
return text in self.text
|
|
124
|
+
|
|
125
|
+
def cell_at(self, column: int, row: int) -> Cell | None:
|
|
126
|
+
if not (0 <= column < self.width and 0 <= row < self.height):
|
|
127
|
+
return None
|
|
128
|
+
index = row * self.width + column
|
|
129
|
+
return self.cells[index] if index < len(self.cells) else None
|
|
130
|
+
|
|
131
|
+
def line(self, row: int) -> str:
|
|
132
|
+
if not 0 <= row < self.height:
|
|
133
|
+
return ""
|
|
134
|
+
return "".join(
|
|
135
|
+
c.text
|
|
136
|
+
for column in range(self.width)
|
|
137
|
+
if (c := self.cell_at(column, row)) is not None and c.width != 0
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def find(self, text: str) -> Position | None:
|
|
141
|
+
if text == "" and self.width and self.height:
|
|
142
|
+
return Position(0, 0)
|
|
143
|
+
for row in range(self.height):
|
|
144
|
+
for column in range(self.width):
|
|
145
|
+
cell = self.cell_at(column, row)
|
|
146
|
+
if cell is None or cell.width == 0:
|
|
147
|
+
continue
|
|
148
|
+
candidate = ""
|
|
149
|
+
for current in range(column, self.width):
|
|
150
|
+
nxt = self.cell_at(current, row)
|
|
151
|
+
if nxt is None:
|
|
152
|
+
break
|
|
153
|
+
if nxt.width:
|
|
154
|
+
candidate += nxt.text
|
|
155
|
+
if candidate.startswith(text):
|
|
156
|
+
return Position(column, row)
|
|
157
|
+
if not text.startswith(candidate):
|
|
158
|
+
break
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass(frozen=True, slots=True)
|
|
163
|
+
class TerminalEvent:
|
|
164
|
+
sequence: int
|
|
165
|
+
type: str
|
|
166
|
+
data: str = ""
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
Matcher = Mapping[str, Any]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _frozen(value: dict[str, Any]) -> Matcher:
|
|
173
|
+
return MappingProxyType(value)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def contains(text: str) -> Matcher:
|
|
177
|
+
return _frozen({"contains": text})
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def line_equals(row: int, text: str) -> Matcher:
|
|
181
|
+
if row < 0:
|
|
182
|
+
raise ValueError("row must not be negative")
|
|
183
|
+
return _frozen({"line": {"row": row, "text": text}})
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def cursor_at(column: int, row: int) -> Matcher:
|
|
187
|
+
if column < 0 or row < 0:
|
|
188
|
+
raise ValueError("cursor coordinates must not be negative")
|
|
189
|
+
return _frozen({"cursor": {"column": column, "row": row}})
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def all_of(*matchers: Matcher) -> Matcher:
|
|
193
|
+
if not matchers:
|
|
194
|
+
raise ValueError("all_of requires at least one matcher")
|
|
195
|
+
return _frozen({"all": list(matchers)})
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def any_of(*matchers: Matcher) -> Matcher:
|
|
199
|
+
if not matchers:
|
|
200
|
+
raise ValueError("any_of requires at least one matcher")
|
|
201
|
+
return _frozen({"any": list(matchers)})
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def not_(matcher: Matcher) -> Matcher:
|
|
205
|
+
return _frozen({"not": matcher})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def screen(revision=3):
|
|
9
|
+
return {
|
|
10
|
+
"width": 5,
|
|
11
|
+
"height": 1,
|
|
12
|
+
"revision": revision,
|
|
13
|
+
"text": "READY",
|
|
14
|
+
"cursor": {"column": 0, "row": 0, "visible": True},
|
|
15
|
+
"cells": [
|
|
16
|
+
{"text": c, "width": 1, "foreground": -1, "background": -1, "attributes": 0}
|
|
17
|
+
for c in "READY"
|
|
18
|
+
],
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
lock = threading.Lock()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def send(value):
|
|
26
|
+
with lock:
|
|
27
|
+
print(json.dumps(value), flush=True)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
for line in sys.stdin:
|
|
31
|
+
request = json.loads(line)
|
|
32
|
+
identifier, method = request["id"], request["method"]
|
|
33
|
+
response = {"jsonrpc": "2.0", "id": identifier}
|
|
34
|
+
if method == "driver.ping":
|
|
35
|
+
response["result"] = {"protocolVersion": "1"}
|
|
36
|
+
elif method == "driver.shutdown":
|
|
37
|
+
response["result"] = {"shuttingDown": True}
|
|
38
|
+
send(response)
|
|
39
|
+
break
|
|
40
|
+
elif method == "connection.open":
|
|
41
|
+
response["result"] = {"connectionId": 11}
|
|
42
|
+
elif method == "session.open":
|
|
43
|
+
response["result"] = {"sessionId": 22}
|
|
44
|
+
elif method in ("connection.close", "session.close"):
|
|
45
|
+
response["result"] = {"closed": True}
|
|
46
|
+
elif method == "session.screen":
|
|
47
|
+
response["result"] = screen()
|
|
48
|
+
threading.Thread(
|
|
49
|
+
target=lambda request_id=identifier, reply=response: (
|
|
50
|
+
time.sleep(0.01 if request_id % 2 else 0),
|
|
51
|
+
send(reply),
|
|
52
|
+
)
|
|
53
|
+
).start()
|
|
54
|
+
continue
|
|
55
|
+
elif method in ("session.send", "session.press", "session.resize"):
|
|
56
|
+
response["result"] = {"sent": True}
|
|
57
|
+
elif method == "session.waitForIdle":
|
|
58
|
+
response["result"] = screen()
|
|
59
|
+
elif method == "session.wait":
|
|
60
|
+
if request["params"]["matcher"] == {"contains": "MISSING"}:
|
|
61
|
+
response["error"] = {
|
|
62
|
+
"code": -32000,
|
|
63
|
+
"message": "timed out",
|
|
64
|
+
"data": {"kind": "timeout", "expected": "MISSING", "screen": screen()},
|
|
65
|
+
}
|
|
66
|
+
else:
|
|
67
|
+
response["result"] = screen()
|
|
68
|
+
elif method in ("session.subscribe", "session.subscribeEvents"):
|
|
69
|
+
response["result"] = {"subscriptionId": 33}
|
|
70
|
+
send(response)
|
|
71
|
+
if method == "session.subscribe":
|
|
72
|
+
for rev in range(1, 4):
|
|
73
|
+
send(
|
|
74
|
+
{
|
|
75
|
+
"jsonrpc": "2.0",
|
|
76
|
+
"method": "session.screen",
|
|
77
|
+
"params": {"subscriptionId": 33, "sessionId": 22, "screen": screen(rev)},
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
else:
|
|
81
|
+
for seq, typ in ((1, "bell"), (2, "enquiry")):
|
|
82
|
+
send(
|
|
83
|
+
{
|
|
84
|
+
"jsonrpc": "2.0",
|
|
85
|
+
"method": "session.event",
|
|
86
|
+
"params": {
|
|
87
|
+
"subscriptionId": 33,
|
|
88
|
+
"sessionId": 22,
|
|
89
|
+
"event": {"sequence": seq, "type": typ},
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
continue
|
|
94
|
+
elif method == "session.unsubscribe":
|
|
95
|
+
response["result"] = {"unsubscribed": True}
|
|
96
|
+
elif method == "hang":
|
|
97
|
+
continue
|
|
98
|
+
else:
|
|
99
|
+
response["error"] = {"code": -32601, "message": "missing"}
|
|
100
|
+
send(response)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
import time
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
import tuicast
|
|
10
|
+
|
|
11
|
+
HELPER = Path(__file__).with_name("helper_driver.py")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture
|
|
15
|
+
def driver():
|
|
16
|
+
value = tuicast.Driver.launch(sys.executable, args=(str(HELPER),), default_timeout=1)
|
|
17
|
+
yield value
|
|
18
|
+
value.close()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_lifecycle_concurrency_waits_and_subscriptions(driver):
|
|
22
|
+
connection = driver.connect(tuicast.Telnet("example:23"))
|
|
23
|
+
session = connection.open_session()
|
|
24
|
+
session.type("hello")
|
|
25
|
+
session.send(b"\x00")
|
|
26
|
+
session.press(tuicast.Key.ENTER)
|
|
27
|
+
session.resize(132, 24)
|
|
28
|
+
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
29
|
+
assert all(
|
|
30
|
+
screen.text == "READY" for screen in pool.map(lambda _: session.screen(), range(16))
|
|
31
|
+
)
|
|
32
|
+
assert session.wait_for_text("READY", stable_for=0.01).contains("READY")
|
|
33
|
+
assert session.wait_for_text_gone("LOADING").text == "READY"
|
|
34
|
+
assert session.wait_for_idle(quiet_for=0.01).text == "READY"
|
|
35
|
+
with pytest.raises(tuicast.WaitError) as caught:
|
|
36
|
+
session.wait_for_text("MISSING")
|
|
37
|
+
assert tuicast.is_timeout(caught.value) and caught.value.last_screen.text == "READY"
|
|
38
|
+
screens = session.subscribe()
|
|
39
|
+
time.sleep(0.05)
|
|
40
|
+
assert screens.get(timeout=1).revision == 3
|
|
41
|
+
screens.close()
|
|
42
|
+
screens.close()
|
|
43
|
+
events = session.subscribe_events()
|
|
44
|
+
assert [events.get(timeout=1).type, events.get(timeout=1).type] == ["bell", "enquiry"]
|
|
45
|
+
events.close()
|
|
46
|
+
session.close()
|
|
47
|
+
session.close()
|
|
48
|
+
connection.close()
|
|
49
|
+
connection.close()
|
|
50
|
+
driver.close()
|
|
51
|
+
driver.close()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_configs_validation_matchers_and_timeout(driver):
|
|
55
|
+
with pytest.raises(ValueError):
|
|
56
|
+
tuicast.Telnet("").params(1)
|
|
57
|
+
with pytest.raises(ValueError):
|
|
58
|
+
tuicast.SSH("", "user", password="p", insecure_skip_host_key_check=True).params(1)
|
|
59
|
+
with pytest.raises(ValueError):
|
|
60
|
+
tuicast.SSH("host", "user", insecure_skip_host_key_check=True).params(1)
|
|
61
|
+
with pytest.raises(ValueError):
|
|
62
|
+
tuicast.SSH("host", "user", password="p").params(1)
|
|
63
|
+
with pytest.raises(ValueError):
|
|
64
|
+
tuicast.SSH(
|
|
65
|
+
"host", "user", password="p", known_hosts_file="x", insecure_skip_host_key_check=True
|
|
66
|
+
).params(1)
|
|
67
|
+
params, _ = tuicast.SSH(
|
|
68
|
+
"host", "user", private_key="key", host_key_fingerprint="SHA256:x"
|
|
69
|
+
).params(1)
|
|
70
|
+
assert params["protocol"] == "ssh"
|
|
71
|
+
with pytest.raises(tuicast.RPCTimeoutError):
|
|
72
|
+
driver._call("hang", timeout=0.01)
|
|
73
|
+
with pytest.raises(TypeError):
|
|
74
|
+
driver.connect(object())
|
|
75
|
+
assert tuicast.all_of(tuicast.contains("x"), tuicast.not_(tuicast.cursor_at(1, 2)))["all"]
|
|
76
|
+
with pytest.raises(ValueError):
|
|
77
|
+
tuicast.any_of()
|
|
78
|
+
with pytest.raises(ValueError):
|
|
79
|
+
tuicast.line_equals(-1, "x")
|
|
80
|
+
with pytest.raises(ValueError):
|
|
81
|
+
tuicast.cursor_at(-1, 0)
|
|
82
|
+
assert tuicast.is_timeout(TimeoutError())
|
|
83
|
+
assert not tuicast.is_timeout(ValueError())
|
|
84
|
+
|
|
85
|
+
connection = driver.connect(tuicast.Telnet("example:23"))
|
|
86
|
+
with pytest.raises(ValueError):
|
|
87
|
+
connection.open_session(width=0)
|
|
88
|
+
with pytest.raises(ValueError):
|
|
89
|
+
connection.open_session(answerback="\n")
|
|
90
|
+
session = connection.open_session()
|
|
91
|
+
with pytest.raises(ValueError):
|
|
92
|
+
session.press("not-a-key")
|
|
93
|
+
with pytest.raises(ValueError):
|
|
94
|
+
session.resize(0, 24)
|
|
95
|
+
with pytest.raises(ValueError):
|
|
96
|
+
session.wait_for_text("READY", stable_for=-1)
|
|
97
|
+
with pytest.raises(ValueError):
|
|
98
|
+
session.wait_for_idle(quiet_for=0)
|
|
99
|
+
session.close()
|
|
100
|
+
connection.close()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_shared_screen_query_fixtures():
|
|
104
|
+
fixtures = json.loads(
|
|
105
|
+
(Path(__file__).parents[3] / "schema/testdata/screen-queries.json").read_text()
|
|
106
|
+
)
|
|
107
|
+
for case in fixtures["cases"]:
|
|
108
|
+
screen = tuicast.Screen.from_dict(case["screen"])
|
|
109
|
+
assert screen.contains(case["contains"])
|
|
110
|
+
assert screen.find(case["find"]) == tuicast.Position(**case["position"])
|
|
111
|
+
assert screen.line(case["position"]["row"])
|
|
112
|
+
assert screen.line(-1) == ""
|
|
113
|
+
assert screen.find("MISSING") is None
|
|
114
|
+
assert screen.find("") == tuicast.Position(0, 0)
|
|
115
|
+
assert screen.cell_at(-1, 0) is None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@pytest.mark.parametrize("mode", ["wrong", "malformed"])
|
|
119
|
+
def test_protocol_rejection(tmp_path, mode):
|
|
120
|
+
script = tmp_path / "bad.py"
|
|
121
|
+
output = (
|
|
122
|
+
'{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2"}}'
|
|
123
|
+
if mode == "wrong"
|
|
124
|
+
else "not json"
|
|
125
|
+
)
|
|
126
|
+
script.write_text(f"import sys\nsys.stdin.readline()\nprint({output!r}, flush=True)\n")
|
|
127
|
+
with pytest.raises(tuicast.TUICastError):
|
|
128
|
+
tuicast.Driver.launch(sys.executable, args=(str(script),), default_timeout=0.2)
|