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.
@@ -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.
@@ -0,0 +1,4 @@
1
+ # TUICast Python SDK
2
+
3
+ An idiomatic synchronous Python 3.12+ client for `tuicast-driver`. See
4
+ [`design/python-sdk.md`](../../design/python-sdk.md) for setup and commands.
@@ -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)