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/__init__.py +75 -0
- txapi/_client.py +364 -0
- txapi/_core.py +302 -0
- txapi/_endpoints.py +813 -0
- txapi/_errors.py +190 -0
- txapi/_generated.py +10599 -0
- txapi/_pagination.py +140 -0
- txapi/_protocols.py +41 -0
- txapi/_ratelimit.py +81 -0
- txapi/_version.py +7 -0
- txapi/live.py +632 -0
- txapi/py.typed +0 -0
- txapi/types.py +532 -0
- txapi/webhooks.py +137 -0
- txapi_sdk-1.0.0.dist-info/METADATA +537 -0
- txapi_sdk-1.0.0.dist-info/RECORD +18 -0
- txapi_sdk-1.0.0.dist-info/WHEEL +4 -0
- txapi_sdk-1.0.0.dist-info/licenses/LICENSE +21 -0
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
|