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/_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)
txapi/_version.py ADDED
@@ -0,0 +1,7 @@
1
+ """Version constants of the txApi Python SDK."""
2
+
3
+ __version__ = "1.0.0"
4
+ """Version of this SDK."""
5
+
6
+ API_VERSION = "1.8.0"
7
+ """txApi API version this SDK targets (``spec/endpoints.json -> apiVersion``)."""