txapi-sdk 1.0.0__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,10 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .mypy_cache/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ .coverage
@@ -0,0 +1,47 @@
1
+ # Changelog
2
+
3
+ All notable changes to the txApi Python SDK (distribution `txapi-sdk`, import `txapi`). The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versions follow
5
+ [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [Unreleased]
8
+
9
+ ### Fixed
10
+
11
+ - `ConsoleStream.send_command()` right after `async with client.console.connect()` raised
12
+ `APIConnectionError("live console is not connected")` against the real API — `async with`
13
+ only starts connecting in the background. It now waits for the (re)connect within
14
+ `timeout`, raises `APITimeoutError` if none comes, and `APIConnectionError` with the close
15
+ code and reason if the stream was refused (1008) or has ended. The README example works as
16
+ written.
17
+
18
+ ## [1.0.0] — 2026-10-14
19
+
20
+ First release, targeting txApi **1.8.0**. Published on PyPI as **`txapi-sdk`** (the name
21
+ `txapi` there is taken); the import name is `txapi`.
22
+
23
+ ### Added
24
+
25
+ - `TxApi` (sync) and `AsyncTxApi` (async) clients with an identical surface, built on `httpx`;
26
+ configuration from arguments or `TXAPI_KEY` / `TXAPI_BASE_URL`, default `server_id`,
27
+ per-call `server_id=` and `timeout=`, custom `http_client=`, `on_request` / `on_response` hooks.
28
+ - One method for each of the 67 endpoints (`account`, `players`, `history`, `server`,
29
+ `server.resources`, `console`, `serverlog`, `events`, `whitelist`, `whitelist.approvals`,
30
+ `admins`), fully type-hinted with `TypedDict` responses (`txapi.types`), docstrings naming
31
+ the required permission.
32
+ - Discord ban/unban flags: `players.ban(..., discord_ban=True)` and
33
+ `history.revoke(..., discord_unban=True)` with the `discord` result.
34
+ - Client-side validation (required fields, maximum lengths) before any request.
35
+ - Error hierarchy (`TxApiError` and one subclass per status, `RateLimitError.quota_exhausted`,
36
+ `APIConnectionError`, `APITimeoutError`, `ValidationError`, `WebhookVerificationError`,
37
+ `CommandError`); the API key never appears in `repr`/`str`.
38
+ - Retries: 429 with `Retry-After` ≤ 60 s for every method; network errors and 502/503/504 for
39
+ GET only; full-jitter backoff; `max_retries=0` disables retries.
40
+ - Rate-limit information (`client.last_rate_limit`, `error.rate_limit`) and raw responses via
41
+ `client.with_raw_response`.
42
+ - Lazy auto-paging iterators (`iter_*`, sync and async) for players, history and events.
43
+ - `verify_webhook` with constant-time HMAC-SHA256 check, timestamp tolerance and secret
44
+ rotation; `WEBHOOK_EVENTS` and the `WebhookEventName` literal type.
45
+ - Live console and server-log streams (`txapi[live]`, `websockets`): async iteration or
46
+ callbacks, `send_command` with FIFO ack correlation and timeout, automatic reconnect on
47
+ 1001/1006/1011/1012 and network loss, no reconnect on 1008/1009 with the close reason surfaced.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Josef Peter
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,537 @@
1
+ Metadata-Version: 2.5
2
+ Name: txapi-sdk
3
+ Version: 1.0.0
4
+ Summary: Official Python SDK for the txApi REST and WebSocket API (txAdmin / FiveM server management)
5
+ Project-URL: Homepage, https://txapi.cloud
6
+ Project-URL: Documentation, https://txapi.cloud/docs/sdks
7
+ Project-URL: Source, https://github.com/josefpeter25/txapi-sdks/tree/main/python
8
+ Project-URL: Issues, https://github.com/josefpeter25/txapi-sdks/issues
9
+ Project-URL: Changelog, https://github.com/josefpeter25/txapi-sdks/blob/main/python/CHANGELOG.md
10
+ Author: Josef Peter
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: api,cfx,fivem,gta,sdk,txadmin,txapi
14
+ Classifier: Development Status :: 5 - Production/Stable
15
+ Classifier: Framework :: AsyncIO
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Programming Language :: Python :: 3.9
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.9
27
+ Requires-Dist: httpx<1,>=0.25
28
+ Provides-Extra: dev
29
+ Requires-Dist: mypy>=1.10; extra == 'dev'
30
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
31
+ Requires-Dist: pytest>=8; extra == 'dev'
32
+ Requires-Dist: ruff>=0.6; extra == 'dev'
33
+ Requires-Dist: websockets>=13; extra == 'dev'
34
+ Provides-Extra: live
35
+ Requires-Dist: websockets>=13; extra == 'live'
36
+ Description-Content-Type: text/markdown
37
+
38
+ # txapi-sdk — official Python SDK for the txApi
39
+
40
+ Typed Python client for the [txApi](https://txapi.cloud) REST and WebSocket API: manage your
41
+ FiveM servers' txAdmin — players, bans, warnings, whitelist, server control, the live console,
42
+ the stored event log and txAdmin admins — from Python.
43
+
44
+ - Sync (`TxApi`) **and** async (`AsyncTxApi`) clients with the identical surface
45
+ - Every one of the 67 endpoints of API **1.8.0**, fully type-hinted (`mypy --strict` clean),
46
+ responses as `TypedDict`s
47
+ - Automatic retries that never repeat a ban or a restart, rate-limit information on every call
48
+ - Auto-paging iterators, webhook signature verification, live console / server log streams
49
+ - Python 3.9+, one runtime dependency (`httpx`); `websockets` only for live streams
50
+
51
+ ```bash
52
+ pip install txapi-sdk # REST client + webhooks
53
+ pip install "txapi-sdk[live]" # + live console / server-log streams (websockets)
54
+ ```
55
+
56
+ The distribution is called **`txapi-sdk`** (the name `txapi` on PyPI belongs to someone else);
57
+ the import stays `import txapi`. Every release is also attached to
58
+ [GitHub Releases](https://github.com/josefpeter25/txapi-sdks/releases) and served from
59
+ `https://txapi.cloud/sdks/` (checksums in `SHA256SUMS`), for setups without PyPI access:
60
+
61
+ ```bash
62
+ pip install "txapi-sdk[live] @ https://txapi.cloud/sdks/txapi_sdk-1.0.0-py3-none-any.whl"
63
+ ```
64
+
65
+ ## Contents
66
+
67
+ - [Quickstart](#quickstart)
68
+ - [Configuration](#configuration)
69
+ - [Calling endpoints](#calling-endpoints)
70
+ - [Errors](#errors)
71
+ - [Retries](#retries)
72
+ - [Rate limits and raw responses](#rate-limits-and-raw-responses)
73
+ - [Pagination](#pagination)
74
+ - [Webhooks](#webhooks)
75
+ - [Live console and server log](#live-console-and-server-log)
76
+ - [Hooks, custom HTTP clients, timeouts](#hooks-custom-http-clients-timeouts)
77
+ - [Endpoint reference](#endpoint-reference)
78
+ - [Development](#development)
79
+
80
+ ## Quickstart
81
+
82
+ Create an API key in the dashboard (**API keys**), then:
83
+
84
+ ```python
85
+ from txapi import TxApi
86
+
87
+ with TxApi(api_key="txp_...", server_id=1) as client:
88
+ page = client.players.search(query="john")
89
+ player = page["players"][0]
90
+ client.players.ban(
91
+ identifier=f"license:{player['license']}",
92
+ reason="Cheating",
93
+ duration_hours=168, # omit (or 0) for a permanent ban
94
+ )
95
+ ```
96
+
97
+ The same with `asyncio`:
98
+
99
+ ```python
100
+ import asyncio
101
+ from txapi import AsyncTxApi
102
+
103
+ async def main() -> None:
104
+ async with AsyncTxApi(server_id=1) as client: # key from TXAPI_KEY
105
+ status = await client.server.status()
106
+ print(status["serverName"], "online" if status.get("online") else "offline")
107
+ await client.server.announcement(message="Restart in 5 minutes")
108
+
109
+ asyncio.run(main())
110
+ ```
111
+
112
+ > **Never log or commit your API key.** It is sent as `Authorization: Bearer <key>`; the
113
+ > clients' and errors' `repr()`/`str()` never contain it. Prefer the `TXAPI_KEY` environment
114
+ > variable over hard-coding it. The request headers passed to `on_request` hooks *do* contain
115
+ > it — don't log them.
116
+
117
+ ## Configuration
118
+
119
+ | Argument | Default | |
120
+ |---|---|---|
121
+ | `api_key` | env `TXAPI_KEY` | required — a missing key raises `ValidationError` at construction |
122
+ | `base_url` | env `TXAPI_BASE_URL`, else `https://txapi.cloud` | trailing slash stripped, `/api/v1` is added by the SDK |
123
+ | `server_id` | — | default server for every server-scoped call; each call can pass `server_id=` |
124
+ | `timeout` | `30` | seconds per attempt; each call can pass `timeout=` |
125
+ | `max_retries` | `2` | retries after the first attempt, `0` disables retries |
126
+ | `user_agent` | — | appended to `txapi-python/1.0.0` |
127
+ | `http_client` | — | your own `httpx.Client` / `httpx.AsyncClient` (not closed by the SDK) |
128
+ | `on_request` / `on_response` | — | hooks for logging/metrics |
129
+
130
+ ```python
131
+ client = TxApi(server_id=1, timeout=10, max_retries=3, user_agent="my-panel/2.1")
132
+ print(client.account.servers()) # account-level: no serverId is sent
133
+ client.server.status(server_id=2) # override the default server for one call
134
+ ```
135
+
136
+ `txapi.__version__` is the SDK version (`1.0.0`), `txapi.API_VERSION` the API version it was
137
+ built for (`1.8.0`).
138
+
139
+ ## Calling endpoints
140
+
141
+ Endpoints are grouped like the API: `client.account`, `players`, `history`, `server`
142
+ (+ `server.resources`), `console`, `serverlog`, `events`, `whitelist` (+ `whitelist.approvals`)
143
+ and `admins`. Path parameters are positional, everything else is keyword-only and
144
+ snake_case (sent as camelCase). `None` arguments are omitted — never sent as `null`.
145
+ Every method's docstring names the permission it needs.
146
+
147
+ ```python
148
+ me = client.account.me()
149
+ print(me["plan"]["code"], me["quota"].get("remaining"), me["plan"]["features"])
150
+
151
+ # Players
152
+ client.players.info(identifier="discord:272800190639898628")
153
+ client.players.warn(identifier="license:abc…", reason="Mic spam")
154
+ client.players.kick(identifier="license:abc…", reason="AFK")
155
+ client.players.dm(identifier="license:abc…", message="Please read the rules")
156
+ client.players.notes(identifier="license:abc…", note="Watch this one")
157
+ client.players.whitelist(identifier="license:abc…", whitelisted=True)
158
+
159
+ # Bans incl. Discord (API 1.8.0): also ban on every Discord server linked via txBot
160
+ result = client.players.ban(identifier="license:abc…", reason="Cheating", discord_ban=True)
161
+ for guild in result.get("discord", {}).get("guilds", []):
162
+ print(guild["guildName"], guild["code"]) # banned, missing_permission, hierarchy, …
163
+ client.history.revoke("BKL5-3SH4", reason="Appeal accepted", discord_unban=True)
164
+
165
+ # Server control
166
+ client.server.restart()
167
+ client.server.schedule_restart(time="+30") # in 30 minutes; or "04:00" (host clock)
168
+ client.server.skip_restart(skip=True)
169
+ client.server.kick_all(reason="Maintenance")
170
+ client.server.resources.restart(name="es_extended")
171
+
172
+ # Console (REST) — see below for the live stream
173
+ client.console.command(command="say Hello")
174
+ print(client.console.buffer()["lines"][-20:])
175
+
176
+ # Stored event log
177
+ board = client.events.leaderboard(since="7d", sort="kills", limit=10)
178
+ stats = client.events.player(player="license:abc…", since="30d")
179
+ deaths = client.events.deaths(pvp=True, since="24h")
180
+ client.events.list(type=["ban", "warn"], since="2026-10-01T00:00:00Z")
181
+
182
+ # Whitelist
183
+ client.whitelist.approve("R1234")
184
+ client.whitelist.approvals.add(identifier="discord:272800190639898628")
185
+ client.whitelist.approvals.remove("discord:272800190639898628") # percent-encoded for you
186
+
187
+ # txAdmin admins
188
+ client.admins.add(name="Moderator Bob", permissions=["players.ban", "console.write"])
189
+ client.admins.edit("Moderator Bob", discord_id="123") # only the fields you pass change
190
+ client.admins.delete("Moderator Bob")
191
+ ```
192
+
193
+ Responses are parsed JSON typed as `TypedDict`s (`txapi.types`). They are plain dicts at
194
+ runtime, so fields the API adds in later minor versions never break anything. txAdmin
195
+ passthrough endpoints (e.g. `players.info`, `server.diagnostics`) return `JsonValue` (`Any`).
196
+ Write endpoints return `{"success": True, "message": "…", "discord"?: …}`.
197
+
198
+ Required arguments are checked before anything is sent: blank required fields and values
199
+ longer than the API allows (ban reason 512, note 2000, resource name 128, console command 1024
200
+ characters) raise `txapi.ValidationError` without a request.
201
+
202
+ ## Errors
203
+
204
+ Every exception derives from `txapi.TxApiError` with `status`, `message` (the API's `error`
205
+ text, else the body, else the reason phrase), parsed `body`, `headers` and `rate_limit`.
206
+
207
+ | Status | Exception | Typical cause |
208
+ |---|---|---|
209
+ | 400, 405, 415 | `BadRequestError` | invalid input |
210
+ | 401 | `AuthenticationError` | key missing, invalid, revoked or expired |
211
+ | 403 | `PermissionDeniedError` | plan feature / key scope missing, IP not allowed, server disabled |
212
+ | 404 | `NotFoundError` | unknown player, action, admin or server |
213
+ | 409 | `ConflictError` | e.g. no txAdmin server configured |
214
+ | 422 | `TxAdminRejectedError` | txAdmin refused the action (e.g. player offline) |
215
+ | 429 | `RateLimitError` | `retry_after` seconds, or `quota_exhausted` (no `Retry-After`) |
216
+ | 502 | `TxAdminUnavailableError` (a `ServerError`) | txAdmin unreachable |
217
+ | other 5xx | `ServerError` | |
218
+ | — | `APIConnectionError`, `APITimeoutError` | network problems, timeouts |
219
+ | — | `ValidationError` (also a `ValueError`) | client-side check, nothing was sent |
220
+ | — | `WebhookVerificationError` | see [Webhooks](#webhooks) |
221
+ | — | `CommandError` | live console answered a command with an error |
222
+
223
+ ```python
224
+ from txapi import NotFoundError, RateLimitError, TxAdminRejectedError, TxApiError
225
+
226
+ try:
227
+ client.players.kick(identifier="license:abc…", reason="AFK")
228
+ except TxAdminRejectedError as exc:
229
+ print("txAdmin refused:", exc.message) # e.g. the player is offline
230
+ except NotFoundError:
231
+ print("No such player")
232
+ except RateLimitError as exc:
233
+ if exc.quota_exhausted:
234
+ print("Monthly quota used up — resets on the 1st, 00:00 UTC")
235
+ else:
236
+ print(f"Too fast, retry in {exc.retry_after} s")
237
+ except TxApiError as exc:
238
+ print(exc.status, exc.message, exc.body)
239
+ ```
240
+
241
+ `APIConnectionError` and `APITimeoutError` are named so to not shadow Python's built-in
242
+ `ConnectionError`/`TimeoutError` (the spec's names).
243
+
244
+ ## Retries
245
+
246
+ - **429 with `Retry-After` ≤ 60 s** is retried for every method after exactly that delay —
247
+ the request was not executed. 429 *without* `Retry-After` means the monthly quota is used
248
+ up and is never retried.
249
+ - **Connection errors, timeouts, 502, 503, 504** are retried for **GET only**. A retried
250
+ POST could ban twice or restart a server twice.
251
+ - Backoff: full jitter, `random() * min(8 s, 0.5 s · 2^attempt)`, unless `Retry-After` says otherwise.
252
+ - `max_retries=0` disables everything. Cancelling an `AsyncTxApi` call (task cancel) stops
253
+ waiting immediately.
254
+
255
+ ## Rate limits and raw responses
256
+
257
+ Every response carries the per-minute limit; the SDK parses it into
258
+ `RateLimit(limit, remaining, reset_seconds)`:
259
+
260
+ ```python
261
+ client.server.status()
262
+ rl = client.last_rate_limit # None before the first response
263
+ print(rl.remaining, "of", rl.limit, "left, resets in", rl.reset_seconds, "s")
264
+ ```
265
+
266
+ Errors carry it too (`exc.rate_limit`). For status code and headers together with the
267
+ parsed data, use `with_raw_response` — the same methods, returning `txapi.ApiResponse`:
268
+
269
+ ```python
270
+ raw = client.with_raw_response.players.ban(identifier="license:abc…", reason="x")
271
+ raw.data # {"success": True, "message": "…"}
272
+ raw.status_code # 200
273
+ raw.headers # httpx.Headers
274
+ raw.rate_limit # RateLimit(...)
275
+ raw.http_response # the httpx.Response
276
+ ```
277
+
278
+ ## Pagination
279
+
280
+ List endpoints return one page; the `iter_*` variants page through everything lazily
281
+ (the next page is fetched only when the current one is used up):
282
+
283
+ ```python
284
+ for player in client.players.iter_search(sort_by="tsLastConnection"):
285
+ print(player["displayName"])
286
+
287
+ for ban in client.history.iter_bans():
288
+ if ban.get("isRevoked"):
289
+ continue
290
+ print(ban["id"], ban["playerName"], ban["reason"])
291
+
292
+ for death in client.events.iter_deaths(since="7d", pvp=True):
293
+ print(death["killer"], "→", death["victim"])
294
+ ```
295
+
296
+ ```python
297
+ async for row in client.events.iter_admin_actions(since="24h"): # AsyncTxApi
298
+ print(row)
299
+ ```
300
+
301
+ | Iterators | Cursor |
302
+ |---|---|
303
+ | `players.iter_search` / `iter_online` / `iter_lookup` | `offset` = last row's sort key (`playTime`, `tsJoined`, `tsLastConnection`; aliases `sessions`, `lastConnection`), `cursor` = its `license` |
304
+ | `history.iter_bans` / `iter_warns` / `iter_search` | `offset` = last row's `timestamp`, `cursor` = its `id` |
305
+ | `events.iter_list` / `iter_deaths` / `iter_kills` / `iter_chat` / `iter_commands` / `iter_admin_actions` / `iter_actions` / `iter_sessions` | `cursor` = `nextCursor` |
306
+
307
+ Iteration stops on `hasReachedEnd`, a missing `nextCursor` or an empty page. To page by
308
+ hand, pass `offset=`/`cursor=` to the non-iterating method.
309
+
310
+ ## Webhooks
311
+
312
+ Webhooks (dashboard → **Webhooks**) are signed with your endpoint secret (`whsec_…`):
313
+ `X-Txapi-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256>`. `txapi.verify_webhook` checks the
314
+ signature (constant time, several `v1` entries allowed during secret rotation) and the
315
+ timestamp (default tolerance 300 s), and returns the event
316
+ `{id, event, createdAt, serverId, data}`:
317
+
318
+ ```python
319
+ from txapi import WebhookVerificationError, verify_webhook
320
+
321
+ event = verify_webhook(raw_body, signature_header, secret)
322
+ ```
323
+
324
+ > **Verify the raw request body — the exact bytes you received.** Parsing the JSON and
325
+ > serialising it again (`json.dumps(request.json())`) changes whitespace and key order and
326
+ > the signature will never match.
327
+
328
+ On failure it raises `WebhookVerificationError` with a `reason`: `missing_timestamp`,
329
+ `missing_signature`, `malformed_header`, `timestamp_out_of_tolerance`, `signature_mismatch`,
330
+ `invalid_body` or `missing_secret`. All event names are in `txapi.WEBHOOK_EVENTS`
331
+ (and the `txapi.WebhookEventName` literal type); header names in `txapi.webhooks`.
332
+
333
+ **Flask**
334
+
335
+ ```python
336
+ from flask import Flask, abort, request
337
+ from txapi import WebhookVerificationError, verify_webhook
338
+
339
+ app = Flask(__name__)
340
+
341
+ @app.post("/txapi/webhook")
342
+ def txapi_webhook():
343
+ try:
344
+ event = verify_webhook(
345
+ request.get_data(), # raw bytes
346
+ request.headers.get("X-Txapi-Signature"),
347
+ WEBHOOK_SECRET,
348
+ )
349
+ except WebhookVerificationError as exc:
350
+ abort(400, exc.reason)
351
+ if event["event"] == "player.banned":
352
+ notify_staff(event["data"])
353
+ return {"ok": True}
354
+ ```
355
+
356
+ **FastAPI** — see [`examples/fastapi_webhook.py`](examples/fastapi_webhook.py):
357
+
358
+ ```python
359
+ @app.post("/txapi/webhook")
360
+ async def txapi_webhook(request: Request):
361
+ raw = await request.body() # raw bytes
362
+ try:
363
+ event = verify_webhook(raw, request.headers.get("X-Txapi-Signature"), WEBHOOK_SECRET)
364
+ except WebhookVerificationError as exc:
365
+ raise HTTPException(400, exc.reason)
366
+ ...
367
+ return {"ok": True}
368
+ ```
369
+
370
+ Answer with a 2xx quickly; failed deliveries are retried by txApi.
371
+
372
+ ## Live console and server log
373
+
374
+ Needs `pip install "txapi-sdk[live]"`. `client.console.connect()` (permission `console.write`)
375
+ and `client.serverlog.connect()` (`logs.view`) return asyncio streams:
376
+
377
+ ```python
378
+ import asyncio
379
+ from txapi import AsyncTxApi
380
+ from txapi.live import CloseEvent, InitEvent, LogEvent
381
+
382
+ async def main() -> None:
383
+ async with AsyncTxApi(server_id=1) as client:
384
+ async with client.console.connect() as console:
385
+ print(await console.send_command("status")) # resolves with txAdmin's ack
386
+ async for event in console:
387
+ if isinstance(event, InitEvent): # buffer after every (re)connect
388
+ print(len(event.items), "buffered lines")
389
+ elif isinstance(event, LogEvent): # one event per console line
390
+ print(event.item)
391
+ elif isinstance(event, CloseEvent) and not event.will_reconnect:
392
+ print("closed:", event.code, event.reason)
393
+
394
+ asyncio.run(main())
395
+ ```
396
+
397
+ Events (`txapi.live`): `OpenEvent(reconnected)`, `InitEvent(items)`, `LogEvent(item)`,
398
+ `ErrorEvent(message)`, `CloseEvent(code, reason, will_reconnect, by_user)`,
399
+ `ReconnectingEvent(attempt, delay, code, reason)` and, with `raw_frames=True`,
400
+ `FrameEvent(frame)`. Instead of iterating you can pass `on_event=` (sync or async callback).
401
+
402
+ - **`send_command(cmd)`** validates the length (≤ 1024), sends `{"type": "command"}` and
403
+ returns the next `ack` — or raises `CommandError` for the next `error` frame. Commands are
404
+ correlated first-in-first-out; timeout 10 s (`command_timeout=` / `timeout=`) raises
405
+ `APITimeoutError`. The command's *output* arrives later as `LogEvent`s. An `error` frame
406
+ that answers no pending command is emitted as `ErrorEvent`.
407
+ - **Reconnects** automatically after close codes 1001, 1006, 1011, 1012 and network loss
408
+ (backoff 1 s → 30 s with jitter; `reconnect=`, `max_reconnect_attempts=`,
409
+ `initial_reconnect_delay=`, `max_reconnect_delay=`). After a reconnect the server sends the
410
+ full buffer again as `InitEvent` — de-duplicate or ignore it.
411
+ - **No reconnect** after 1008 (authentication, permission, quota, too slow — `event.reason`
412
+ and `stream.close_reason` tell you what to fix), 1009, a refused handshake, or `close()`.
413
+ Iteration then ends.
414
+ - Server-log `LogEvent.item`s are txAdmin's structured entries (`dict`) or plain strings.
415
+ - The key travels in the WebSocket URL's query string (that is how the server authenticates
416
+ streams); it is never included in events or error messages.
417
+
418
+ The synchronous `TxApi` hands out the same asyncio streams — use them inside
419
+ `asyncio.run(...)`; there is no blocking stream API. See
420
+ [`examples/live_console.py`](examples/live_console.py).
421
+
422
+ ## Hooks, custom HTTP clients, timeouts
423
+
424
+ ```python
425
+ import httpx, logging
426
+
427
+ def log_request(request: httpx.Request) -> None:
428
+ logging.info("→ %s %s", request.method, request.url.path) # never log request.headers
429
+
430
+ def log_response(response: httpx.Response) -> None:
431
+ logging.info("← %s", response.status_code)
432
+
433
+ client = TxApi(
434
+ http_client=httpx.Client(limits=httpx.Limits(max_connections=20)),
435
+ on_request=log_request, # called before every attempt
436
+ on_response=log_response, # called for every response, also errors and retries
437
+ timeout=15,
438
+ )
439
+ client.server.diagnostics(timeout=60) # per call
440
+ ```
441
+
442
+ Hooks can never break a request — exceptions inside them are logged and ignored. With
443
+ `AsyncTxApi` they may be coroutines. A client you pass in is not closed by `close()`.
444
+
445
+ ## Endpoint reference
446
+
447
+ | Method | HTTP | Permission |
448
+ |---|---|---|
449
+ | `client.account.me()` | `GET /me` | any key |
450
+ | `client.account.servers()` | `GET /servers` | any key |
451
+ | `client.players.search()` · `iter_search()` | `GET /players/search` | `players.read` |
452
+ | `client.players.online()` · `iter_online()` | `GET /players/online` | `players.read` |
453
+ | `client.players.lookup()` · `iter_lookup()` | `GET /players/lookup` | `players.read` |
454
+ | `client.players.info()` | `GET /players/info` | `players.read` |
455
+ | `client.players.stats()` | `GET /players/stats` | `players.read` |
456
+ | `client.players.warn()` | `POST /players/warn` | `players.write` |
457
+ | `client.players.ban()` | `POST /players/ban` | `players.write` |
458
+ | `client.players.kick()` | `POST /players/kick` | `players.write` |
459
+ | `client.players.dm()` | `POST /players/dm` | `players.write` |
460
+ | `client.players.notes()` | `POST /players/notes` | `players.write` |
461
+ | `client.players.whitelist()` | `POST /players/whitelist` | `players.write` |
462
+ | `client.history.bans()` · `iter_bans()` | `GET /history/bans` | `players.read` |
463
+ | `client.history.warns()` · `iter_warns()` | `GET /history/warns` | `players.read` |
464
+ | `client.history.search()` · `iter_search()` | `GET /history/search` | `players.read` |
465
+ | `client.history.ban_templates()` | `GET /txadmin/ban-templates` | `players.read` |
466
+ | `client.history.action()` | `GET /history/action/{actionId}` | `players.read` |
467
+ | `client.history.stats()` | `GET /history/stats` | `players.read` |
468
+ | `client.history.revoke()` | `POST /history/revoke/{actionId}` | `players.write` |
469
+ | `client.server.status()` | `GET /server/status` | `server.control` |
470
+ | `client.server.diagnostics()` | `GET /server/diagnostics` | `server.control` |
471
+ | `client.server.drops()` | `GET /server/drops` | `server.control` |
472
+ | `client.server.performance()` | `GET /server/performance` | `server.control` |
473
+ | `client.server.restart()` | `POST /server/restart` | `server.control` |
474
+ | `client.server.start()` | `POST /server/start` | `server.control` |
475
+ | `client.server.stop()` | `POST /server/stop` | `server.control` |
476
+ | `client.server.schedule_restart()` | `POST /server/schedule-restart` | `server.control` |
477
+ | `client.server.skip_restart()` | `POST /server/skip-restart` | `server.control` |
478
+ | `client.server.kick_all()` | `POST /server/kickall` | `server.control` |
479
+ | `client.server.announcement()` | `POST /server/announcement` | `server.control` |
480
+ | `client.server.resources.start()` | `POST /server/resource/start` | `server.control` |
481
+ | `client.server.resources.stop()` | `POST /server/resource/stop` | `server.control` |
482
+ | `client.server.resources.restart()` | `POST /server/resource/restart` | `server.control` |
483
+ | `client.server.resources.ensure()` | `POST /server/resource/ensure` | `server.control` |
484
+ | `client.server.resources.refresh()` | `POST /server/resource/refresh` | `server.control` |
485
+ | `client.console.buffer()` | `GET /console` | `console.write` |
486
+ | `client.console.command()` | `POST /console/command` | `console.write` |
487
+ | `client.serverlog.buffer()` | `GET /server/log` | `logs.view` |
488
+ | `client.serverlog.history()` | `GET /server/log/history` | `logs.view` |
489
+ | `client.events.list()` · `iter_list()` | `GET /events` | `logs.view` |
490
+ | `client.events.deaths()` · `iter_deaths()` | `GET /events/deaths` | `logs.view` |
491
+ | `client.events.kills()` · `iter_kills()` | `GET /events/kills` | `logs.view` |
492
+ | `client.events.leaderboard()` | `GET /events/leaderboard` | `logs.view` |
493
+ | `client.events.player()` | `GET /events/player` | `logs.view` |
494
+ | `client.events.summary()` | `GET /events/summary` | `logs.view` |
495
+ | `client.events.chat()` · `iter_chat()` | `GET /events/chat` | `logs.view` |
496
+ | `client.events.commands()` · `iter_commands()` | `GET /events/commands` | `logs.view` |
497
+ | `client.events.admin_actions()` · `iter_admin_actions()` | `GET /events/admin-actions` | `logs.view` |
498
+ | `client.events.actions()` · `iter_actions()` | `GET /events/actions` | `logs.view` |
499
+ | `client.events.sessions()` · `iter_sessions()` | `GET /events/sessions` | `logs.view` |
500
+ | `client.events.playtime()` | `GET /events/playtime` | `logs.view` |
501
+ | `client.events.activity()` | `GET /events/activity` | `logs.view` |
502
+ | `client.events.action_log()` | `GET /txadmin/action-log` | `logs.view` |
503
+ | `client.whitelist.requests()` | `GET /whitelist/requests` | `players.read` |
504
+ | `client.whitelist.approve()` | `POST /whitelist/requests/{requestId}/approve` | `players.write` |
505
+ | `client.whitelist.deny()` | `POST /whitelist/requests/{requestId}/deny` | `players.write` |
506
+ | `client.whitelist.deny_all()` | `POST /whitelist/requests/deny-all` | `players.write` |
507
+ | `client.whitelist.approvals.list()` | `GET /whitelist/approvals` | `players.read` |
508
+ | `client.whitelist.approvals.add()` | `POST /whitelist/approvals` | `players.write` |
509
+ | `client.whitelist.approvals.remove()` | `DELETE /whitelist/approvals/{identifier}` | `players.write` |
510
+ | `client.admins.list()` | `GET /txadmin/admins` | `admin.manage` |
511
+ | `client.admins.get()` | `GET /txadmin/admins/{name}` | `admin.manage` |
512
+ | `client.admins.permissions()` | `GET /txadmin/admins/available-permissions` | `admin.manage` |
513
+ | `client.admins.add()` | `POST /txadmin/admins` | `admin.manage` |
514
+ | `client.admins.edit()` | `PUT /txadmin/admins/{name}` | `admin.manage` |
515
+ | `client.admins.delete()` | `DELETE /txadmin/admins/{name}` | `admin.manage` |
516
+
517
+ Plus `client.console.connect()` (`WS /ws/console`, `console.write`) and
518
+ `client.serverlog.connect()` (`WS /ws/serverlog`, `logs.view`).
519
+
520
+ ## Development
521
+
522
+ ```bash
523
+ python -m venv .venv && .venv/bin/pip install -e ".[live,dev]"
524
+ .venv/bin/pytest # offline: mock HTTP transport + local WebSocket server
525
+ .venv/bin/mypy --strict src
526
+ .venv/bin/ruff check src scripts tests examples && .venv/bin/ruff format --check src scripts tests examples
527
+ python scripts/generate.py # regenerate src/txapi/_endpoints.py + _generated.py
528
+ python scripts/generate.py --check # CI: fails if the generated code is stale
529
+ ```
530
+
531
+ The endpoint methods are generated from `../spec/endpoints.json` (itself generated from the
532
+ API catalog). When the API changes, regenerate; the coverage test fails until every endpoint
533
+ has a method.
534
+
535
+ ## License
536
+
537
+ MIT — see [LICENSE](LICENSE).