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 ADDED
@@ -0,0 +1,75 @@
1
+ """Official Python SDK for the txApi REST and WebSocket API (txAdmin / FiveM).
2
+
3
+ Quickstart::
4
+
5
+ from txapi import TxApi
6
+
7
+ client = TxApi(server_id=1) # key from the TXAPI_KEY environment variable
8
+ page = client.players.search(query="john")
9
+ client.players.ban(identifier="license:" + page["players"][0]["license"],
10
+ reason="Cheating", duration_hours=168)
11
+
12
+ See https://txapi.cloud/docs for the API reference.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from . import types
18
+ from ._client import AsyncTxApi, RequestHook, ResponseHook, TxApi
19
+ from ._core import ApiResponse
20
+ from ._errors import (
21
+ APIConnectionError,
22
+ APITimeoutError,
23
+ AuthenticationError,
24
+ BadRequestError,
25
+ CommandError,
26
+ ConflictError,
27
+ NotFoundError,
28
+ PermissionDeniedError,
29
+ RateLimitError,
30
+ ServerError,
31
+ TxAdminRejectedError,
32
+ TxAdminUnavailableError,
33
+ TxApiError,
34
+ ValidationError,
35
+ WebhookVerificationError,
36
+ )
37
+ from ._ratelimit import RateLimit
38
+ from ._version import API_VERSION, __version__
39
+ from .types import WebhookEvent, WebhookEventName
40
+ from .webhooks import WEBHOOK_EVENTS, verify_webhook
41
+
42
+ VERSION = __version__
43
+ """Alias of ``__version__`` (the SDK version)."""
44
+
45
+ __all__ = [
46
+ "API_VERSION",
47
+ "VERSION",
48
+ "WEBHOOK_EVENTS",
49
+ "APIConnectionError",
50
+ "APITimeoutError",
51
+ "ApiResponse",
52
+ "AsyncTxApi",
53
+ "AuthenticationError",
54
+ "BadRequestError",
55
+ "CommandError",
56
+ "ConflictError",
57
+ "NotFoundError",
58
+ "PermissionDeniedError",
59
+ "RateLimit",
60
+ "RateLimitError",
61
+ "RequestHook",
62
+ "ResponseHook",
63
+ "ServerError",
64
+ "TxAdminRejectedError",
65
+ "TxAdminUnavailableError",
66
+ "TxApi",
67
+ "TxApiError",
68
+ "ValidationError",
69
+ "WebhookEvent",
70
+ "WebhookEventName",
71
+ "WebhookVerificationError",
72
+ "__version__",
73
+ "types",
74
+ "verify_webhook",
75
+ ]
txapi/_client.py ADDED
@@ -0,0 +1,364 @@
1
+ """The synchronous :class:`TxApi` and asynchronous :class:`AsyncTxApi` clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ import logging
8
+ import time
9
+ from collections.abc import Callable, Mapping
10
+ from types import TracebackType
11
+ from typing import Any
12
+
13
+ import httpx
14
+
15
+ from . import _core
16
+ from ._core import ApiResponse, ClientConfig, EndpointSpec, PreparedRequest
17
+ from ._errors import APIConnectionError, APITimeoutError, TxApiError
18
+ from ._generated import AsyncGroups, AsyncTxApiWithRawResponse, SyncGroups, TxApiWithRawResponse
19
+ from ._ratelimit import RateLimit, parse_rate_limit
20
+ from .live import LiveConfig
21
+
22
+ __all__ = ["AsyncTxApi", "TxApi"]
23
+
24
+ logger = logging.getLogger("txapi")
25
+
26
+ RequestHook = Callable[[httpx.Request], Any]
27
+ """Called before every attempt. Never log ``request.headers`` — they contain the key."""
28
+ ResponseHook = Callable[[httpx.Response], Any]
29
+ """Called after every attempt that produced a response (also for errors and retries)."""
30
+
31
+ # Module-level indirections so tests can replace the sleeps.
32
+ _sleep: Callable[[float], None] = time.sleep
33
+
34
+
35
+ async def _async_sleep(seconds: float) -> None:
36
+ await asyncio.sleep(seconds)
37
+
38
+
39
+ class _ClientBase:
40
+ _config: ClientConfig
41
+ _on_request: RequestHook | None
42
+ _on_response: ResponseHook | None
43
+ last_rate_limit: RateLimit | None
44
+ """Rate-limit state of the most recent response (SPEC §6), ``None`` before the first."""
45
+
46
+ def _init_base(
47
+ self,
48
+ api_key: str | None,
49
+ base_url: str | None,
50
+ server_id: int | str | None,
51
+ timeout: float | None,
52
+ max_retries: int | None,
53
+ user_agent: str | None,
54
+ on_request: RequestHook | None,
55
+ on_response: ResponseHook | None,
56
+ ) -> None:
57
+ self._config = _core.resolve_config(
58
+ api_key, base_url, server_id, timeout, max_retries, user_agent
59
+ )
60
+ self._on_request = on_request
61
+ self._on_response = on_response
62
+ self.last_rate_limit = None
63
+
64
+ @property
65
+ def base_url(self) -> str:
66
+ """Base URL without ``/api/v1`` (no trailing slash)."""
67
+ return self._config.base_url
68
+
69
+ @property
70
+ def server_id(self) -> int | str | None:
71
+ """Default server for server-scoped calls."""
72
+ return self._config.server_id
73
+
74
+ @property
75
+ def timeout(self) -> float:
76
+ """Default per-attempt timeout in seconds."""
77
+ return self._config.timeout
78
+
79
+ @property
80
+ def max_retries(self) -> int:
81
+ """Retries after the first attempt (SPEC §5)."""
82
+ return self._config.max_retries
83
+
84
+ def __repr__(self) -> str:
85
+ return (
86
+ f"{type(self).__name__}(base_url={self._config.base_url!r}, "
87
+ f"server_id={self._config.server_id!r}, api_key=<redacted>)"
88
+ )
89
+
90
+ def _live_config(self, server_id: int | str | None) -> LiveConfig:
91
+ return LiveConfig(
92
+ base_url=self._config.base_url,
93
+ api_key=self._config.api_key,
94
+ server_id=server_id if server_id is not None else self._config.server_id,
95
+ user_agent=self._config.user_agent,
96
+ )
97
+
98
+ def _prepare(
99
+ self,
100
+ endpoint: EndpointSpec,
101
+ path: Mapping[str, object],
102
+ query: Mapping[str, object],
103
+ body: Mapping[str, object] | None,
104
+ server_id: int | str | None,
105
+ ) -> PreparedRequest:
106
+ return _core.build_request(
107
+ endpoint,
108
+ path=path,
109
+ query=query,
110
+ body=body,
111
+ server_id=server_id,
112
+ default_server_id=self._config.server_id,
113
+ )
114
+
115
+ def _http_request(
116
+ self,
117
+ http: httpx.Client | httpx.AsyncClient,
118
+ prepared: PreparedRequest,
119
+ timeout: float | None,
120
+ ) -> httpx.Request:
121
+ headers = _core.default_headers(self._config)
122
+ if prepared.body is not None:
123
+ headers["Content-Type"] = "application/json"
124
+ return http.build_request(
125
+ prepared.method,
126
+ self._config.base_url + prepared.path,
127
+ params=tuple(prepared.params),
128
+ content=prepared.body,
129
+ headers=headers,
130
+ timeout=httpx.Timeout(self._config.timeout if timeout is None else timeout),
131
+ )
132
+
133
+ def _record(self, response: httpx.Response) -> None:
134
+ rate = parse_rate_limit(response.headers)
135
+ if rate is not None:
136
+ self.last_rate_limit = rate
137
+
138
+ def _transport_error(self, exc: httpx.TransportError) -> TxApiError:
139
+ name = type(exc).__name__
140
+ if isinstance(exc, httpx.TimeoutException):
141
+ return APITimeoutError(f"request timed out ({name})")
142
+ return APIConnectionError(f"connection failed ({name}: {exc})")
143
+
144
+
145
+ def _call_hook(hook: Callable[[Any], Any] | None, value: Any) -> Any:
146
+ if hook is None:
147
+ return None
148
+ try:
149
+ return hook(value)
150
+ except Exception:
151
+ logger.exception("txapi: hook raised; ignored")
152
+ return None
153
+
154
+
155
+ class TxApi(_ClientBase, SyncGroups):
156
+ """Synchronous txApi client.
157
+
158
+ Example::
159
+
160
+ from txapi import TxApi
161
+
162
+ with TxApi(server_id=1) as client: # key from TXAPI_KEY
163
+ page = client.players.search(query="john")
164
+ client.players.ban(identifier="license:" + page["players"][0]["license"],
165
+ reason="Cheating", duration_hours=168)
166
+
167
+ Args:
168
+ api_key: API key; defaults to the ``TXAPI_KEY`` environment variable.
169
+ base_url: Defaults to ``TXAPI_BASE_URL`` or ``https://txapi.cloud`` (``/api/v1`` is added).
170
+ server_id: Default server for every server-scoped call (each call can override it).
171
+ timeout: Per-attempt timeout in seconds (default 30).
172
+ max_retries: Retries after the first attempt (default 2, ``0`` disables retries).
173
+ user_agent: Appended to ``txapi-python/<version>``.
174
+ http_client: Your own ``httpx.Client`` (proxies, transports …). It is not closed
175
+ by :meth:`close`.
176
+ on_request: Hook called before every attempt (logging/metrics). Exceptions are ignored.
177
+ The request headers contain your key — never log them.
178
+ on_response: Hook called with every response. Exceptions are ignored.
179
+ """
180
+
181
+ with_raw_response: TxApiWithRawResponse
182
+ """The same methods, returning :class:`~txapi.ApiResponse` (data + status, headers,
183
+ rate limit)."""
184
+
185
+ def __init__(
186
+ self,
187
+ api_key: str | None = None,
188
+ *,
189
+ base_url: str | None = None,
190
+ server_id: int | str | None = None,
191
+ timeout: float | None = None,
192
+ max_retries: int | None = None,
193
+ user_agent: str | None = None,
194
+ http_client: httpx.Client | None = None,
195
+ on_request: RequestHook | None = None,
196
+ on_response: ResponseHook | None = None,
197
+ ) -> None:
198
+ self._init_base(
199
+ api_key, base_url, server_id, timeout, max_retries, user_agent, on_request, on_response
200
+ )
201
+ self._owns_http = http_client is None
202
+ self._http = http_client if http_client is not None else httpx.Client()
203
+ SyncGroups.__init__(self, self)
204
+ self.with_raw_response = TxApiWithRawResponse(self)
205
+
206
+ def close(self) -> None:
207
+ """Close the underlying HTTP client (unless it was passed in)."""
208
+ if self._owns_http:
209
+ self._http.close()
210
+
211
+ def __enter__(self) -> TxApi:
212
+ return self
213
+
214
+ def __exit__(
215
+ self,
216
+ exc_type: type[BaseException] | None,
217
+ exc: BaseException | None,
218
+ tb: TracebackType | None,
219
+ ) -> None:
220
+ self.close()
221
+
222
+ def _request(
223
+ self,
224
+ endpoint: EndpointSpec,
225
+ *,
226
+ path: Mapping[str, object],
227
+ query: Mapping[str, object],
228
+ body: Mapping[str, object] | None,
229
+ server_id: int | str | None,
230
+ timeout: float | None,
231
+ ) -> ApiResponse[Any]:
232
+ prepared = self._prepare(endpoint, path, query, body, server_id)
233
+ attempt = 0
234
+ while True:
235
+ request = self._http_request(self._http, prepared, timeout)
236
+ _call_hook(self._on_request, request)
237
+ try:
238
+ response = self._http.send(request)
239
+ except httpx.TransportError as exc:
240
+ error = self._transport_error(exc)
241
+ delay = _core.retry_delay_for_transport_error(prepared.method, attempt)
242
+ if delay is not None and attempt < self._config.max_retries:
243
+ attempt += 1
244
+ _sleep(delay)
245
+ continue
246
+ raise error from exc
247
+ _call_hook(self._on_response, response)
248
+ self._record(response)
249
+ if response.is_success:
250
+ return _core.make_api_response(response, _core.parse_body(response))
251
+ delay = _core.retry_delay_for_response(prepared.method, response, attempt)
252
+ if delay is not None and attempt < self._config.max_retries:
253
+ attempt += 1
254
+ response.close()
255
+ _sleep(delay)
256
+ continue
257
+ raise _core.error_from_response(response, _core.parse_body(response))
258
+
259
+
260
+ class AsyncTxApi(_ClientBase, AsyncGroups):
261
+ """Asynchronous txApi client — same surface as :class:`TxApi`, every call is awaited.
262
+
263
+ Example::
264
+
265
+ import asyncio
266
+ from txapi import AsyncTxApi
267
+
268
+ async def main() -> None:
269
+ async with AsyncTxApi(server_id=1) as client:
270
+ status = await client.server.status()
271
+ async for row in client.history.iter_bans():
272
+ print(row["id"], row["reason"])
273
+
274
+ asyncio.run(main())
275
+
276
+ Takes the same arguments as :class:`TxApi`; ``http_client`` is an ``httpx.AsyncClient``.
277
+ Hooks may be plain functions or coroutines.
278
+ """
279
+
280
+ with_raw_response: AsyncTxApiWithRawResponse
281
+ """The same methods, returning :class:`~txapi.ApiResponse` (data + status, headers,
282
+ rate limit)."""
283
+
284
+ def __init__(
285
+ self,
286
+ api_key: str | None = None,
287
+ *,
288
+ base_url: str | None = None,
289
+ server_id: int | str | None = None,
290
+ timeout: float | None = None,
291
+ max_retries: int | None = None,
292
+ user_agent: str | None = None,
293
+ http_client: httpx.AsyncClient | None = None,
294
+ on_request: RequestHook | None = None,
295
+ on_response: ResponseHook | None = None,
296
+ ) -> None:
297
+ self._init_base(
298
+ api_key, base_url, server_id, timeout, max_retries, user_agent, on_request, on_response
299
+ )
300
+ self._owns_http = http_client is None
301
+ self._http = http_client if http_client is not None else httpx.AsyncClient()
302
+ AsyncGroups.__init__(self, self)
303
+ self.with_raw_response = AsyncTxApiWithRawResponse(self)
304
+
305
+ async def close(self) -> None:
306
+ """Close the underlying HTTP client (unless it was passed in)."""
307
+ if self._owns_http:
308
+ await self._http.aclose()
309
+
310
+ async def __aenter__(self) -> AsyncTxApi:
311
+ return self
312
+
313
+ async def __aexit__(
314
+ self,
315
+ exc_type: type[BaseException] | None,
316
+ exc: BaseException | None,
317
+ tb: TracebackType | None,
318
+ ) -> None:
319
+ await self.close()
320
+
321
+ async def _hook(self, hook: Callable[[Any], Any] | None, value: Any) -> None:
322
+ result = _call_hook(hook, value)
323
+ if inspect.isawaitable(result):
324
+ try:
325
+ await result
326
+ except Exception:
327
+ logger.exception("txapi: hook raised; ignored")
328
+
329
+ async def _request(
330
+ self,
331
+ endpoint: EndpointSpec,
332
+ *,
333
+ path: Mapping[str, object],
334
+ query: Mapping[str, object],
335
+ body: Mapping[str, object] | None,
336
+ server_id: int | str | None,
337
+ timeout: float | None,
338
+ ) -> ApiResponse[Any]:
339
+ prepared = self._prepare(endpoint, path, query, body, server_id)
340
+ attempt = 0
341
+ while True:
342
+ request = self._http_request(self._http, prepared, timeout)
343
+ await self._hook(self._on_request, request)
344
+ try:
345
+ response = await self._http.send(request)
346
+ except httpx.TransportError as exc:
347
+ error = self._transport_error(exc)
348
+ delay = _core.retry_delay_for_transport_error(prepared.method, attempt)
349
+ if delay is not None and attempt < self._config.max_retries:
350
+ attempt += 1
351
+ await _async_sleep(delay)
352
+ continue
353
+ raise error from exc
354
+ await self._hook(self._on_response, response)
355
+ self._record(response)
356
+ if response.is_success:
357
+ return _core.make_api_response(response, _core.parse_body(response))
358
+ delay = _core.retry_delay_for_response(prepared.method, response, attempt)
359
+ if delay is not None and attempt < self._config.max_retries:
360
+ attempt += 1
361
+ await response.aclose()
362
+ await _async_sleep(delay)
363
+ continue
364
+ raise _core.error_from_response(response, _core.parse_body(response))