txapi-sdk 1.0.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.
txapi/live.py ADDED
@@ -0,0 +1,632 @@
1
+ """Live console and server-log streams over WebSocket (SPEC §9).
2
+
3
+ Requires the optional ``websockets`` dependency: ``pip install "txapi-sdk[live]"``.
4
+
5
+ Streams are asyncio-based. Both :class:`txapi.TxApi` and :class:`txapi.AsyncTxApi`
6
+ hand them out (``client.console.connect()``), but they must be used inside a
7
+ running event loop::
8
+
9
+ async with client.console.connect() as console:
10
+ print(await console.send_command("status"))
11
+ async for event in console:
12
+ if isinstance(event, LogEvent):
13
+ print(event.item)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import collections
20
+ import inspect
21
+ import json
22
+ import logging
23
+ import random
24
+ from collections.abc import AsyncIterator, Callable
25
+ from dataclasses import dataclass, field
26
+ from types import TracebackType
27
+ from typing import Any, ClassVar, Generic, Literal, TypeVar, Union
28
+ from urllib.parse import quote
29
+
30
+ from ._errors import APIConnectionError, APITimeoutError, CommandError, ValidationError
31
+ from .types import ServerLogEntry
32
+
33
+ __all__ = [
34
+ "MAX_COMMAND_LENGTH",
35
+ "RECONNECT_CLOSE_CODES",
36
+ "CloseEvent",
37
+ "ConsoleEvent",
38
+ "ConsoleStream",
39
+ "ErrorEvent",
40
+ "FrameEvent",
41
+ "InitEvent",
42
+ "LiveEvent",
43
+ "LogEvent",
44
+ "OpenEvent",
45
+ "ReconnectingEvent",
46
+ "ServerLogEvent",
47
+ "ServerLogStream",
48
+ ]
49
+
50
+ logger = logging.getLogger("txapi.live")
51
+
52
+ T = TypeVar("T")
53
+
54
+ MAX_COMMAND_LENGTH = 1024
55
+ """Longest console command the API accepts."""
56
+
57
+ RECONNECT_CLOSE_CODES = frozenset({1001, 1006, 1011, 1012})
58
+ """Close codes after which a stream reconnects automatically. Never 1008/1009."""
59
+
60
+ COMMAND_TIMEOUT = 10.0
61
+ _MAX_FRAME_BYTES = 32 * 1024 * 1024
62
+
63
+
64
+ # ─────────────────────────────────────────────────────────────────── events
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class OpenEvent:
69
+ """The WebSocket is connected. ``reconnected`` is ``True`` after an automatic reconnect."""
70
+
71
+ reconnected: bool
72
+ type: Literal["open"] = "open"
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class InitEvent(Generic[T]):
77
+ """The server's current buffer, sent right after (re)connecting.
78
+
79
+ After a reconnect this is the *full* buffer again — de-duplicate or ignore it.
80
+ Not sent when the buffer is empty.
81
+ """
82
+
83
+ items: list[T]
84
+ type: Literal["init"] = "init"
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class LogEvent(Generic[T]):
89
+ """One new console line (``str``) or server-log entry (``dict`` or ``str``)."""
90
+
91
+ item: T
92
+ type: Literal["log"] = "log"
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class ErrorEvent:
97
+ """An ``error`` frame not answering a pending :meth:`ConsoleStream.send_command`."""
98
+
99
+ message: str
100
+ type: Literal["error"] = "error"
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class CloseEvent:
105
+ """The connection closed.
106
+
107
+ Attributes:
108
+ code: WebSocket close code (``None`` when no connection was established).
109
+ reason: Close reason sent by the server (for 1008 it tells you what to fix).
110
+ will_reconnect: ``True`` if the stream reconnects automatically.
111
+ by_user: ``True`` after :meth:`close`.
112
+ """
113
+
114
+ code: int | None
115
+ reason: str
116
+ will_reconnect: bool
117
+ by_user: bool = False
118
+ type: Literal["close"] = "close"
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class ReconnectingEvent:
123
+ """The stream will reconnect after ``delay`` seconds (``attempt`` starts at 1)."""
124
+
125
+ attempt: int
126
+ delay: float
127
+ code: int | None
128
+ reason: str
129
+ type: Literal["reconnecting"] = "reconnecting"
130
+
131
+
132
+ @dataclass(frozen=True)
133
+ class FrameEvent:
134
+ """A raw server frame (only emitted with ``raw_frames=True``)."""
135
+
136
+ frame: dict[str, Any]
137
+ type: Literal["frame"] = "frame"
138
+
139
+
140
+ LiveEvent = Union[
141
+ OpenEvent, InitEvent[T], LogEvent[T], ErrorEvent, CloseEvent, ReconnectingEvent, FrameEvent
142
+ ]
143
+ ConsoleEvent = Union[
144
+ OpenEvent,
145
+ InitEvent[str],
146
+ LogEvent[str],
147
+ ErrorEvent,
148
+ CloseEvent,
149
+ ReconnectingEvent,
150
+ FrameEvent,
151
+ ]
152
+ ServerLogEvent = Union[
153
+ OpenEvent,
154
+ InitEvent[ServerLogEntry],
155
+ LogEvent[ServerLogEntry],
156
+ ErrorEvent,
157
+ CloseEvent,
158
+ ReconnectingEvent,
159
+ FrameEvent,
160
+ ]
161
+
162
+ EventCallback = Callable[[Any], Any]
163
+
164
+
165
+ @dataclass(frozen=True)
166
+ class LiveConfig:
167
+ base_url: str
168
+ api_key: str = field(repr=False)
169
+ server_id: int | str | None
170
+ user_agent: str
171
+
172
+
173
+ class _End:
174
+ pass
175
+
176
+
177
+ _END = _End()
178
+
179
+
180
+ # ─────────────────────────────────────────────────────────────────── stream
181
+
182
+
183
+ class _LiveStream(Generic[T]):
184
+ _path: ClassVar[str]
185
+ _items_key: ClassVar[str]
186
+
187
+ def __init__(
188
+ self,
189
+ config: LiveConfig,
190
+ *,
191
+ raw_frames: bool = False,
192
+ on_event: EventCallback | None = None,
193
+ reconnect: bool = True,
194
+ max_reconnect_attempts: int | None = None,
195
+ initial_reconnect_delay: float = 1.0,
196
+ max_reconnect_delay: float = 30.0,
197
+ open_timeout: float = 10.0,
198
+ max_queued_events: int = 10_000,
199
+ ) -> None:
200
+ self._config = config
201
+ self._raw_frames = raw_frames
202
+ self._on_event = on_event
203
+ self._reconnect = reconnect
204
+ self._max_attempts = max_reconnect_attempts
205
+ self._initial_delay = initial_reconnect_delay
206
+ self._max_delay = max_reconnect_delay
207
+ self._open_timeout = open_timeout
208
+ self._queue: collections.deque[LiveEvent[T] | _End] = collections.deque(
209
+ maxlen=max_queued_events
210
+ )
211
+ self._wakeup: asyncio.Event | None = None
212
+ # Set on every queued event (open, close, reconnect, end) — what a caller waiting
213
+ # for the connection watches. Separate from _wakeup, which the readers clear.
214
+ self._changed: asyncio.Event | None = None
215
+ self._task: asyncio.Task[None] | None = None
216
+ self._ws: Any = None
217
+ self._closing = False
218
+ self._ended = False
219
+ self._final_emitted = False
220
+ self.close_code: int | None = None
221
+ """Code of the last close (``None`` before the first disconnect)."""
222
+ self.close_reason: str = ""
223
+ """Reason of the last close, e.g. why the server refused the connection (1008)."""
224
+ self.dropped_events = 0
225
+ """Events dropped because more than ``max_queued_events`` were waiting to be read."""
226
+
227
+ # ── public API
228
+
229
+ @property
230
+ def connected(self) -> bool:
231
+ """``True`` while a WebSocket connection is open."""
232
+ return self._ws is not None
233
+
234
+ def __repr__(self) -> str:
235
+ return (
236
+ f"{type(self).__name__}(path={self._path!r}, server_id={self._config.server_id!r},"
237
+ f" connected={self.connected})"
238
+ )
239
+
240
+ async def start(self) -> None:
241
+ """Start connecting in the background (called by ``async with`` and iteration)."""
242
+ self._ensure_started()
243
+
244
+ async def close(self) -> None:
245
+ """Close the stream; no reconnect afterwards and iteration ends."""
246
+ if self._closing:
247
+ if self._task is not None and not self._task.done():
248
+ await asyncio.gather(self._task, return_exceptions=True)
249
+ return
250
+ self._closing = True
251
+ ws = self._ws
252
+ if ws is not None:
253
+ try:
254
+ await ws.close(1000, "closed by client")
255
+ except Exception: # pragma: no cover - best effort
256
+ pass
257
+ if self._task is not None and not self._task.done():
258
+ self._task.cancel()
259
+ await asyncio.gather(self._task, return_exceptions=True)
260
+ self._ws = None
261
+ self._fail_pending(APIConnectionError("stream closed"))
262
+ if not self._final_emitted:
263
+ self._final_emitted = True
264
+ self.close_code = 1000
265
+ self.close_reason = "closed by client"
266
+ await self._emit(CloseEvent(1000, "closed by client", False, by_user=True))
267
+ self._finish()
268
+
269
+ async def __aenter__(self: _S) -> _S:
270
+ await self.start()
271
+ return self
272
+
273
+ async def __aexit__(
274
+ self,
275
+ exc_type: type[BaseException] | None,
276
+ exc: BaseException | None,
277
+ tb: TracebackType | None,
278
+ ) -> None:
279
+ await self.close()
280
+
281
+ def __aiter__(self) -> AsyncIterator[LiveEvent[T]]:
282
+ self._ensure_started()
283
+ return self._events()
284
+
285
+ # ── internals
286
+
287
+ def _ensure_started(self) -> None:
288
+ if self._wakeup is None:
289
+ self._wakeup = asyncio.Event()
290
+ if self._changed is None:
291
+ self._changed = asyncio.Event()
292
+ if self._task is None and not self._closing:
293
+ self._task = asyncio.get_running_loop().create_task(self._run())
294
+
295
+ async def _events(self) -> AsyncIterator[LiveEvent[T]]:
296
+ assert self._wakeup is not None
297
+ while True:
298
+ if self._queue:
299
+ item = self._queue.popleft()
300
+ if isinstance(item, _End):
301
+ self._queue.appendleft(item) # keep the end marker for other readers
302
+ return
303
+ yield item
304
+ continue
305
+ self._wakeup.clear()
306
+ await self._wakeup.wait()
307
+
308
+ def _push(self, item: LiveEvent[T] | _End) -> None:
309
+ if self._queue.maxlen is not None and len(self._queue) == self._queue.maxlen:
310
+ self.dropped_events += 1
311
+ self._queue.append(item)
312
+ if self._wakeup is not None:
313
+ self._wakeup.set()
314
+ if self._changed is not None:
315
+ self._changed.set()
316
+
317
+ async def _await_connection(self, timeout: float) -> Any:
318
+ """The open WebSocket, waiting up to ``timeout`` for the (re)connect.
319
+
320
+ ``async with client.console.connect()`` only starts connecting in the background,
321
+ so a command sent right away found no socket (live-tested against the API). The
322
+ server authenticates inside the handshake and handles frames only afterwards, so
323
+ an open socket is enough — waiting for ``init`` would hang: the server sends none
324
+ while the console buffer is empty.
325
+ """
326
+ if self._task is None or self._changed is None:
327
+ # Never started (no ``async with``, no iteration): nobody would close it.
328
+ raise APIConnectionError("live console is not connected — use `async with` first")
329
+ loop = asyncio.get_running_loop()
330
+ deadline = loop.time() + timeout
331
+ while self._ws is None:
332
+ if self._closing or self._ended or self._final_emitted:
333
+ detail = ""
334
+ if self.close_code:
335
+ detail = f" (closed {self.close_code}: {self.close_reason})"
336
+ raise APIConnectionError(f"live console is not connected{detail}")
337
+ remaining = deadline - loop.time()
338
+ if remaining <= 0:
339
+ raise APITimeoutError(f"live console did not connect within {timeout:g} s")
340
+ self._changed.clear()
341
+ try:
342
+ await asyncio.wait_for(self._changed.wait(), remaining)
343
+ except asyncio.TimeoutError:
344
+ pass
345
+ return self._ws
346
+
347
+ async def _emit(self, event: LiveEvent[T]) -> None:
348
+ self._push(event)
349
+ if self._on_event is not None:
350
+ try:
351
+ result = self._on_event(event)
352
+ if inspect.isawaitable(result):
353
+ await result
354
+ except Exception:
355
+ logger.exception("txapi: on_event callback raised")
356
+
357
+ def _finish(self) -> None:
358
+ if not self._ended:
359
+ self._ended = True
360
+ self._push(_END)
361
+
362
+ def _url(self) -> str:
363
+ base = self._config.base_url
364
+ if base.startswith("https://"):
365
+ base = "wss://" + base[len("https://") :]
366
+ elif base.startswith("http://"):
367
+ base = "ws://" + base[len("http://") :]
368
+ url = f"{base}{self._path}?apiKey={quote(self._config.api_key, safe='')}"
369
+ if self._config.server_id is not None:
370
+ url += f"&serverId={quote(str(self._config.server_id), safe='')}"
371
+ return url
372
+
373
+ def _redact(self, text: str) -> str:
374
+ key = self._config.api_key
375
+ for variant in {key, quote(key, safe="")}:
376
+ if variant:
377
+ text = text.replace(variant, "***")
378
+ return text
379
+
380
+ def _delay(self, attempt: int) -> float:
381
+ base = min(self._max_delay, self._initial_delay * (2.0 ** (attempt - 1)))
382
+ return base * (0.5 + random.random() / 2)
383
+
384
+ def _fail_pending(self, error: Exception) -> None:
385
+ """Hook for streams with pending requests (console commands)."""
386
+
387
+ async def _handle_frame(self, frame: dict[str, Any]) -> None:
388
+ kind = frame.get("type")
389
+ if kind == "init":
390
+ items = frame.get(self._items_key)
391
+ await self._emit(InitEvent(list(items) if isinstance(items, list) else []))
392
+ elif kind == "log":
393
+ items = frame.get(self._items_key)
394
+ for item in items if isinstance(items, list) else []:
395
+ await self._emit(LogEvent(item))
396
+ elif kind == "error":
397
+ await self._on_error_frame(str(frame.get("message", "")))
398
+ elif kind == "ack":
399
+ self._on_ack(str(frame.get("message", "")))
400
+
401
+ async def _on_error_frame(self, message: str) -> None:
402
+ await self._emit(ErrorEvent(message))
403
+
404
+ def _on_ack(self, message: str) -> None:
405
+ """Hook for streams that send commands."""
406
+
407
+ async def _run(self) -> None:
408
+ try:
409
+ await self._loop()
410
+ except asyncio.CancelledError:
411
+ pass
412
+ except Exception as exc: # pragma: no cover - defensive
413
+ logger.exception("txapi: live stream failed")
414
+ if not self._final_emitted:
415
+ self._final_emitted = True
416
+ await self._emit(CloseEvent(None, self._redact(str(exc)), False))
417
+ finally:
418
+ self._ws = None
419
+ self._fail_pending(APIConnectionError("live connection closed"))
420
+ if not self._closing:
421
+ self._finish()
422
+
423
+ async def _loop(self) -> None:
424
+ # Imported lazily so the REST client works without the optional dependency.
425
+ try:
426
+ from websockets.asyncio.client import connect
427
+ from websockets.exceptions import ConnectionClosed, InvalidStatus
428
+ except ImportError as exc: # pragma: no cover - depends on environment
429
+ raise ImportError(
430
+ 'Live streams need the websockets package: pip install "txapi-sdk[live]"'
431
+ ) from exc
432
+
433
+ attempt = 0
434
+ reconnected = False
435
+ while not self._closing:
436
+ code: int | None = None
437
+ reason = ""
438
+ try:
439
+ ws = await connect(
440
+ self._url(),
441
+ additional_headers={"User-Agent": self._config.user_agent},
442
+ open_timeout=self._open_timeout,
443
+ max_size=_MAX_FRAME_BYTES,
444
+ )
445
+ except InvalidStatus as exc:
446
+ status = exc.response.status_code
447
+ reason = f"HTTP {status} during WebSocket handshake"
448
+ if 400 <= status < 500 and status != 429:
449
+ await self._final(None, reason)
450
+ return
451
+ except asyncio.CancelledError:
452
+ raise
453
+ except Exception as exc:
454
+ reason = self._redact(f"{type(exc).__name__}: {exc}")
455
+ else:
456
+ if self._closing:
457
+ await ws.close(1000, "closed by client")
458
+ return
459
+ self._ws = ws
460
+ attempt = 0
461
+ await self._emit(OpenEvent(reconnected))
462
+ reconnected = True
463
+ try:
464
+ async for raw in ws:
465
+ frame = _decode(raw)
466
+ if frame is None:
467
+ continue
468
+ if self._raw_frames:
469
+ await self._emit(FrameEvent(frame))
470
+ await self._handle_frame(frame)
471
+ except ConnectionClosed:
472
+ pass
473
+ code = ws.close_code if ws.close_code is not None else 1006
474
+ reason = ws.close_reason or ""
475
+ self._ws = None
476
+ self._fail_pending(
477
+ APIConnectionError(f"live connection closed ({code}) before the ack")
478
+ )
479
+ if self._closing:
480
+ return
481
+ self.close_code, self.close_reason = code, reason
482
+ will = self._should_reconnect(code, attempt + 1)
483
+ await self._emit(CloseEvent(code, reason, will))
484
+ if not will:
485
+ self._final_emitted = True
486
+ return
487
+
488
+ attempt += 1
489
+ if not self._should_reconnect(code, attempt):
490
+ if code is None:
491
+ await self._final(None, reason)
492
+ return
493
+ delay = self._delay(attempt)
494
+ await self._emit(ReconnectingEvent(attempt, delay, code, reason))
495
+ await asyncio.sleep(delay)
496
+
497
+ def _should_reconnect(self, code: int | None, attempt: int) -> bool:
498
+ if self._closing or not self._reconnect:
499
+ return False
500
+ if code is not None and code not in RECONNECT_CLOSE_CODES:
501
+ return False
502
+ return self._max_attempts is None or attempt <= self._max_attempts
503
+
504
+ async def _final(self, code: int | None, reason: str) -> None:
505
+ self.close_code, self.close_reason = code, reason
506
+ self._final_emitted = True
507
+ await self._emit(CloseEvent(code, reason, False))
508
+
509
+
510
+ _S = TypeVar("_S", bound="_LiveStream[Any]")
511
+
512
+
513
+ def _decode(raw: str | bytes) -> dict[str, Any] | None:
514
+ try:
515
+ frame = json.loads(raw)
516
+ except ValueError:
517
+ logger.warning("txapi: ignoring non-JSON live frame")
518
+ return None
519
+ return frame if isinstance(frame, dict) else None
520
+
521
+
522
+ class ConsoleStream(_LiveStream[str]):
523
+ """Live console of a server (``/ws/console``, permission ``console.write``).
524
+
525
+ Yields :data:`ConsoleEvent` values: :class:`OpenEvent`, :class:`InitEvent` (buffer),
526
+ one :class:`LogEvent` per console line, :class:`ErrorEvent`, :class:`CloseEvent`,
527
+ :class:`ReconnectingEvent` and (with ``raw_frames=True``) :class:`FrameEvent`.
528
+ """
529
+
530
+ _path = "/ws/console"
531
+ _items_key = "lines"
532
+
533
+ def __init__(
534
+ self, config: LiveConfig, *, command_timeout: float = COMMAND_TIMEOUT, **options: Any
535
+ ) -> None:
536
+ super().__init__(config, **options)
537
+ self._command_timeout = command_timeout
538
+ self._pending: collections.deque[asyncio.Future[str]] = collections.deque()
539
+ # Created lazily: on Python 3.9 asyncio primitives bind to the loop at creation.
540
+ self._send_lock: asyncio.Lock | None = None
541
+
542
+ async def send_command(self, command: str, *, timeout: float | None = None) -> str:
543
+ """Run a console command and wait for txAdmin's ``ack``.
544
+
545
+ The command's *output* arrives later as normal :class:`LogEvent` lines; the
546
+ ack only means txAdmin accepted it. Commands are correlated FIFO.
547
+
548
+ Right after ``connect()`` (or during a reconnect) it first waits for the
549
+ connection; ``timeout`` covers that wait and the ack separately.
550
+
551
+ Returns:
552
+ The ack message.
553
+
554
+ Raises:
555
+ ValidationError: Empty command or longer than 1024 characters.
556
+ CommandError: The server answered with an ``error`` frame
557
+ (rate limit, quota, txAdmin unreachable …).
558
+ APITimeoutError: Not connected or no answer within ``timeout`` (default 10 s).
559
+ APIConnectionError: The stream has ended (closed, or refused with 1008),
560
+ or the connection dropped before the ack.
561
+ """
562
+ if not isinstance(command, str) or not command.strip():
563
+ raise ValidationError("console command must not be empty")
564
+ if len(command) > MAX_COMMAND_LENGTH:
565
+ raise ValidationError(
566
+ f"console command is {len(command)} characters long, "
567
+ f"the API accepts at most {MAX_COMMAND_LENGTH}"
568
+ )
569
+ wait = self._command_timeout if timeout is None else timeout
570
+ ws = self._ws
571
+ if ws is None:
572
+ ws = await self._await_connection(wait)
573
+ future: asyncio.Future[str] = asyncio.get_running_loop().create_future()
574
+ if self._send_lock is None:
575
+ self._send_lock = asyncio.Lock()
576
+ async with self._send_lock:
577
+ self._pending.append(future)
578
+ try:
579
+ await ws.send(json.dumps({"type": "command", "command": command}))
580
+ except Exception as exc:
581
+ if future in self._pending:
582
+ self._pending.remove(future)
583
+ raise APIConnectionError(
584
+ f"could not send the command: {type(exc).__name__}"
585
+ ) from exc
586
+ try:
587
+ return await asyncio.wait_for(asyncio.shield(future), wait)
588
+ except asyncio.TimeoutError:
589
+ # Keep the slot: a late ack still belongs to this command (FIFO).
590
+ future.cancel()
591
+ raise APITimeoutError(f"no answer to the console command within {wait:g} s") from None
592
+
593
+ def _next_pending(self) -> asyncio.Future[str] | None:
594
+ while self._pending:
595
+ future = self._pending.popleft()
596
+ if not future.done():
597
+ return future
598
+ # Expired (timed out): this answer belonged to it — consume and stop.
599
+ return None
600
+ return None
601
+
602
+ def _on_ack(self, message: str) -> None:
603
+ if not self._pending:
604
+ return
605
+ future = self._next_pending()
606
+ if future is not None:
607
+ future.set_result(message)
608
+
609
+ async def _on_error_frame(self, message: str) -> None:
610
+ if self._pending:
611
+ future = self._next_pending()
612
+ if future is not None:
613
+ future.set_exception(CommandError(message or "command failed"))
614
+ return
615
+ await self._emit(ErrorEvent(message))
616
+
617
+ def _fail_pending(self, error: Exception) -> None:
618
+ while self._pending:
619
+ future = self._pending.popleft()
620
+ if not future.done():
621
+ future.set_exception(error)
622
+
623
+
624
+ class ServerLogStream(_LiveStream[ServerLogEntry]):
625
+ """Live server log (``/ws/serverlog``, permission ``logs.view``). Read-only.
626
+
627
+ Yields :data:`ServerLogEvent` values; :class:`LogEvent` items are txAdmin's
628
+ structured entries (``dict``) or plain strings.
629
+ """
630
+
631
+ _path = "/ws/serverlog"
632
+ _items_key = "entries"
txapi/py.typed ADDED
File without changes