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/_pagination.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Auto-paging iterators (SPEC §7).
|
|
2
|
+
|
|
3
|
+
Pages are fetched lazily: nothing is requested until the iterator is consumed,
|
|
4
|
+
and each further page only when the previous one is exhausted.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
from .types import Event, EventPage, HistoryPage, HistoryRow, PlayerPage, PlayerRow
|
|
13
|
+
|
|
14
|
+
__all__ = ["player_sort_key"]
|
|
15
|
+
|
|
16
|
+
_SORT_ALIASES = {
|
|
17
|
+
"playTime": "playTime",
|
|
18
|
+
"tsJoined": "tsJoined",
|
|
19
|
+
"tsLastConnection": "tsLastConnection",
|
|
20
|
+
"sessions": "tsJoined",
|
|
21
|
+
"lastConnection": "tsLastConnection",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def player_sort_key(sort_by: str | None) -> str:
|
|
26
|
+
"""Row field holding the sort-key value for ``sort_by`` (default ``playTime``)."""
|
|
27
|
+
if sort_by is None or not sort_by.strip():
|
|
28
|
+
return "playTime"
|
|
29
|
+
# Unknown values are rejected by the API (400) on the first page.
|
|
30
|
+
return _SORT_ALIASES.get(sort_by.strip(), sort_by.strip())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
PlayerFetch = Callable[[Optional[int], Optional[str]], PlayerPage]
|
|
34
|
+
HistoryFetch = Callable[[Optional[int], Optional[str]], HistoryPage]
|
|
35
|
+
EventFetch = Callable[[Optional[str]], EventPage]
|
|
36
|
+
AsyncPlayerFetch = Callable[[Optional[int], Optional[str]], Awaitable[PlayerPage]]
|
|
37
|
+
AsyncHistoryFetch = Callable[[Optional[int], Optional[str]], Awaitable[HistoryPage]]
|
|
38
|
+
AsyncEventFetch = Callable[[Optional[str]], Awaitable[EventPage]]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _next_player_cursor(page: PlayerPage, sort_key: str) -> tuple[int, str] | None:
|
|
42
|
+
rows = page.get("players") or []
|
|
43
|
+
if not rows or page.get("hasReachedEnd", True):
|
|
44
|
+
return None
|
|
45
|
+
last: Any = rows[-1]
|
|
46
|
+
offset = last.get(sort_key)
|
|
47
|
+
license_ = last.get("license")
|
|
48
|
+
if offset is None or not license_:
|
|
49
|
+
return None
|
|
50
|
+
return int(offset), str(license_)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _next_history_cursor(page: HistoryPage) -> tuple[int, str] | None:
|
|
54
|
+
rows = page.get("history") or []
|
|
55
|
+
if not rows or page.get("hasReachedEnd", True):
|
|
56
|
+
return None
|
|
57
|
+
last: Any = rows[-1]
|
|
58
|
+
offset = last.get("timestamp")
|
|
59
|
+
action_id = last.get("id")
|
|
60
|
+
if offset is None or not action_id:
|
|
61
|
+
return None
|
|
62
|
+
return int(offset), str(action_id)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _next_event_cursor(page: EventPage) -> str | None:
|
|
66
|
+
if not page.get("events"):
|
|
67
|
+
return None
|
|
68
|
+
cursor = page.get("nextCursor")
|
|
69
|
+
return cursor if isinstance(cursor, str) and cursor else None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def iter_players(fetch: PlayerFetch, sort_key: str) -> Iterator[PlayerRow]:
|
|
73
|
+
offset: int | None = None
|
|
74
|
+
cursor: str | None = None
|
|
75
|
+
while True:
|
|
76
|
+
page = fetch(offset, cursor)
|
|
77
|
+
yield from page.get("players") or []
|
|
78
|
+
following = _next_player_cursor(page, sort_key)
|
|
79
|
+
if following is None:
|
|
80
|
+
return
|
|
81
|
+
offset, cursor = following
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def iter_history(fetch: HistoryFetch) -> Iterator[HistoryRow]:
|
|
85
|
+
offset: int | None = None
|
|
86
|
+
cursor: str | None = None
|
|
87
|
+
while True:
|
|
88
|
+
page = fetch(offset, cursor)
|
|
89
|
+
yield from page.get("history") or []
|
|
90
|
+
following = _next_history_cursor(page)
|
|
91
|
+
if following is None:
|
|
92
|
+
return
|
|
93
|
+
offset, cursor = following
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def iter_events(fetch: EventFetch) -> Iterator[Event]:
|
|
97
|
+
cursor: str | None = None
|
|
98
|
+
while True:
|
|
99
|
+
page = fetch(cursor)
|
|
100
|
+
yield from page.get("events") or []
|
|
101
|
+
cursor = _next_event_cursor(page)
|
|
102
|
+
if cursor is None:
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def aiter_players(fetch: AsyncPlayerFetch, sort_key: str) -> AsyncIterator[PlayerRow]:
|
|
107
|
+
offset: int | None = None
|
|
108
|
+
cursor: str | None = None
|
|
109
|
+
while True:
|
|
110
|
+
page = await fetch(offset, cursor)
|
|
111
|
+
for row in page.get("players") or []:
|
|
112
|
+
yield row
|
|
113
|
+
following = _next_player_cursor(page, sort_key)
|
|
114
|
+
if following is None:
|
|
115
|
+
return
|
|
116
|
+
offset, cursor = following
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def aiter_history(fetch: AsyncHistoryFetch) -> AsyncIterator[HistoryRow]:
|
|
120
|
+
offset: int | None = None
|
|
121
|
+
cursor: str | None = None
|
|
122
|
+
while True:
|
|
123
|
+
page = await fetch(offset, cursor)
|
|
124
|
+
for row in page.get("history") or []:
|
|
125
|
+
yield row
|
|
126
|
+
following = _next_history_cursor(page)
|
|
127
|
+
if following is None:
|
|
128
|
+
return
|
|
129
|
+
offset, cursor = following
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def aiter_events(fetch: AsyncEventFetch) -> AsyncIterator[Event]:
|
|
133
|
+
cursor: str | None = None
|
|
134
|
+
while True:
|
|
135
|
+
page = await fetch(cursor)
|
|
136
|
+
for row in page.get("events") or []:
|
|
137
|
+
yield row
|
|
138
|
+
cursor = _next_event_cursor(page)
|
|
139
|
+
if cursor is None:
|
|
140
|
+
return
|
txapi/_protocols.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Structural types the generated resource classes depend on."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any, Protocol
|
|
7
|
+
|
|
8
|
+
from ._core import ApiResponse, EndpointSpec
|
|
9
|
+
from .live import LiveConfig
|
|
10
|
+
|
|
11
|
+
__all__ = ["AsyncRequester", "SyncRequester"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SyncRequester(Protocol):
|
|
15
|
+
def _request(
|
|
16
|
+
self,
|
|
17
|
+
endpoint: EndpointSpec,
|
|
18
|
+
*,
|
|
19
|
+
path: Mapping[str, object],
|
|
20
|
+
query: Mapping[str, object],
|
|
21
|
+
body: Mapping[str, object] | None,
|
|
22
|
+
server_id: int | str | None,
|
|
23
|
+
timeout: float | None,
|
|
24
|
+
) -> ApiResponse[Any]: ...
|
|
25
|
+
|
|
26
|
+
def _live_config(self, server_id: int | str | None) -> LiveConfig: ...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AsyncRequester(Protocol):
|
|
30
|
+
async def _request(
|
|
31
|
+
self,
|
|
32
|
+
endpoint: EndpointSpec,
|
|
33
|
+
*,
|
|
34
|
+
path: Mapping[str, object],
|
|
35
|
+
query: Mapping[str, object],
|
|
36
|
+
body: Mapping[str, object] | None,
|
|
37
|
+
server_id: int | str | None,
|
|
38
|
+
timeout: float | None,
|
|
39
|
+
) -> ApiResponse[Any]: ...
|
|
40
|
+
|
|
41
|
+
def _live_config(self, server_id: int | str | None) -> LiveConfig: ...
|
txapi/_ratelimit.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Rate-limit headers (``X-RateLimit-*``) and ``Retry-After`` parsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import email.utils
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
__all__ = ["RateLimit", "parse_rate_limit", "parse_retry_after"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class RateLimit:
|
|
15
|
+
"""Per-minute rate-limit state reported by the API on every response.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
limit: Requests allowed per minute (``X-RateLimit-Limit``), ``None`` if not sent.
|
|
19
|
+
remaining: Requests left in the current window (``X-RateLimit-Remaining``).
|
|
20
|
+
reset_seconds: Seconds until the window resets (``X-RateLimit-Reset``).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
limit: int | None
|
|
24
|
+
remaining: int | None
|
|
25
|
+
reset_seconds: int | None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _header(headers: Mapping[str, str], name: str) -> str | None:
|
|
29
|
+
value = headers.get(name)
|
|
30
|
+
if value is None:
|
|
31
|
+
# Plain dicts are case sensitive; httpx.Headers is not.
|
|
32
|
+
lower = name.lower()
|
|
33
|
+
for key, candidate in headers.items():
|
|
34
|
+
if key.lower() == lower:
|
|
35
|
+
return candidate
|
|
36
|
+
return value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _int(value: str | None) -> int | None:
|
|
40
|
+
if value is None:
|
|
41
|
+
return None
|
|
42
|
+
try:
|
|
43
|
+
return int(value.strip())
|
|
44
|
+
except ValueError:
|
|
45
|
+
try:
|
|
46
|
+
return int(float(value.strip()))
|
|
47
|
+
except ValueError:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse_rate_limit(headers: Mapping[str, str]) -> RateLimit | None:
|
|
52
|
+
"""Parse ``X-RateLimit-Limit/-Remaining/-Reset``; ``None`` if none of them is sent."""
|
|
53
|
+
limit = _int(_header(headers, "X-RateLimit-Limit"))
|
|
54
|
+
remaining = _int(_header(headers, "X-RateLimit-Remaining"))
|
|
55
|
+
reset = _int(_header(headers, "X-RateLimit-Reset"))
|
|
56
|
+
if limit is None and remaining is None and reset is None:
|
|
57
|
+
return None
|
|
58
|
+
return RateLimit(limit=limit, remaining=remaining, reset_seconds=reset)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parse_retry_after(headers: Mapping[str, str], now: float | None = None) -> float | None:
|
|
62
|
+
"""Parse ``Retry-After`` (seconds or HTTP date) to seconds; ``None`` if absent/invalid."""
|
|
63
|
+
raw = _header(headers, "Retry-After")
|
|
64
|
+
if raw is None:
|
|
65
|
+
return None
|
|
66
|
+
raw = raw.strip()
|
|
67
|
+
if not raw:
|
|
68
|
+
return None
|
|
69
|
+
try:
|
|
70
|
+
seconds = float(raw)
|
|
71
|
+
except ValueError:
|
|
72
|
+
try:
|
|
73
|
+
parsed = email.utils.parsedate_to_datetime(raw)
|
|
74
|
+
except (TypeError, ValueError, IndexError):
|
|
75
|
+
return None
|
|
76
|
+
if parsed is None:
|
|
77
|
+
return None
|
|
78
|
+
seconds = parsed.timestamp() - (time.time() if now is None else now)
|
|
79
|
+
if seconds != seconds: # NaN
|
|
80
|
+
return None
|
|
81
|
+
return max(0.0, seconds)
|