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/_core.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Transport-independent core: endpoint specs, request building (SPEC §2),
|
|
2
|
+
response/error mapping (SPEC §4), retry decisions (SPEC §5) and configuration (SPEC §1)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import random
|
|
9
|
+
from collections.abc import Callable, Mapping
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Generic, TypeVar
|
|
12
|
+
from urllib.parse import quote
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from ._errors import RateLimitError, TxApiError, ValidationError, error_class_for_status
|
|
17
|
+
from ._ratelimit import RateLimit, parse_rate_limit, parse_retry_after
|
|
18
|
+
from ._version import __version__
|
|
19
|
+
|
|
20
|
+
__all__ = ["ApiResponse"]
|
|
21
|
+
|
|
22
|
+
T = TypeVar("T")
|
|
23
|
+
|
|
24
|
+
DEFAULT_BASE_URL = "https://txapi.cloud"
|
|
25
|
+
DEFAULT_TIMEOUT = 30.0
|
|
26
|
+
DEFAULT_MAX_RETRIES = 2
|
|
27
|
+
MAX_RETRY_AFTER = 60.0
|
|
28
|
+
"""A ``Retry-After`` above this is not waited for (SPEC §5)."""
|
|
29
|
+
RETRYABLE_GET_STATUSES = frozenset({502, 503, 504})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class FieldSpec:
|
|
34
|
+
"""A query parameter, path parameter or JSON body field of an endpoint."""
|
|
35
|
+
|
|
36
|
+
name: str
|
|
37
|
+
"""Name on the wire (camelCase)."""
|
|
38
|
+
py_name: str
|
|
39
|
+
"""Python keyword argument name (snake_case), used in validation messages."""
|
|
40
|
+
required: bool = False
|
|
41
|
+
type: str = "string"
|
|
42
|
+
max_length: int | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class EndpointSpec:
|
|
47
|
+
"""One endpoint of ``spec/endpoints.json``."""
|
|
48
|
+
|
|
49
|
+
id: str
|
|
50
|
+
method: str
|
|
51
|
+
path: str
|
|
52
|
+
permission: str | None
|
|
53
|
+
account_level: bool
|
|
54
|
+
path_params: tuple[FieldSpec, ...] = ()
|
|
55
|
+
query: tuple[FieldSpec, ...] = ()
|
|
56
|
+
body: tuple[FieldSpec, ...] = ()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class ApiResponse(Generic[T]):
|
|
61
|
+
"""Parsed data together with the raw HTTP response (SPEC §6).
|
|
62
|
+
|
|
63
|
+
Returned by every method of ``client.with_raw_response``.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
data: T
|
|
67
|
+
status_code: int
|
|
68
|
+
headers: httpx.Headers
|
|
69
|
+
rate_limit: RateLimit | None
|
|
70
|
+
http_response: httpx.Response = field(repr=False)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class PreparedRequest:
|
|
75
|
+
method: str
|
|
76
|
+
path: str
|
|
77
|
+
params: list[tuple[str, str]]
|
|
78
|
+
body: bytes | None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ─────────────────────────────────────────────────────────────── configuration
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class ClientConfig:
|
|
86
|
+
api_key: str = field(repr=False)
|
|
87
|
+
base_url: str
|
|
88
|
+
server_id: int | str | None
|
|
89
|
+
timeout: float
|
|
90
|
+
max_retries: int
|
|
91
|
+
user_agent: str
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def resolve_config(
|
|
95
|
+
api_key: str | None,
|
|
96
|
+
base_url: str | None,
|
|
97
|
+
server_id: int | str | None,
|
|
98
|
+
timeout: float | None,
|
|
99
|
+
max_retries: int | None,
|
|
100
|
+
user_agent: str | None,
|
|
101
|
+
) -> ClientConfig:
|
|
102
|
+
key = api_key if api_key is not None else os.environ.get("TXAPI_KEY")
|
|
103
|
+
if key is None or not key.strip():
|
|
104
|
+
raise ValidationError(
|
|
105
|
+
"No API key: pass api_key=... or set the TXAPI_KEY environment variable."
|
|
106
|
+
)
|
|
107
|
+
url = base_url or os.environ.get("TXAPI_BASE_URL") or DEFAULT_BASE_URL
|
|
108
|
+
url = url.strip().rstrip("/")
|
|
109
|
+
if not url.startswith(("http://", "https://")):
|
|
110
|
+
raise ValidationError("base_url must start with http:// or https://")
|
|
111
|
+
retries = DEFAULT_MAX_RETRIES if max_retries is None else max_retries
|
|
112
|
+
if retries < 0:
|
|
113
|
+
raise ValidationError("max_retries must be >= 0")
|
|
114
|
+
agent = f"txapi-python/{__version__}"
|
|
115
|
+
if user_agent:
|
|
116
|
+
agent = f"{agent} {user_agent}"
|
|
117
|
+
return ClientConfig(
|
|
118
|
+
api_key=key.strip(),
|
|
119
|
+
base_url=url,
|
|
120
|
+
server_id=server_id,
|
|
121
|
+
timeout=DEFAULT_TIMEOUT if timeout is None else float(timeout),
|
|
122
|
+
max_retries=retries,
|
|
123
|
+
user_agent=agent,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def default_headers(config: ClientConfig) -> dict[str, str]:
|
|
128
|
+
return {
|
|
129
|
+
"Authorization": f"Bearer {config.api_key}",
|
|
130
|
+
"Accept": "application/json",
|
|
131
|
+
"User-Agent": config.user_agent,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ───────────────────────────────────────────────────────────── request building
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _is_blank(value: object) -> bool:
|
|
139
|
+
if isinstance(value, str):
|
|
140
|
+
return not value.strip()
|
|
141
|
+
if isinstance(value, (list, tuple)):
|
|
142
|
+
return len(value) == 0
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _fail(endpoint: EndpointSpec, message: str) -> ValidationError:
|
|
147
|
+
return ValidationError(f"{endpoint.id}: {message}")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _query_value(value: object) -> str:
|
|
151
|
+
if isinstance(value, bool):
|
|
152
|
+
return "true" if value else "false"
|
|
153
|
+
if isinstance(value, (list, tuple)):
|
|
154
|
+
return ",".join(_query_value(v) for v in value)
|
|
155
|
+
return str(value)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _check_length(endpoint: EndpointSpec, spec: FieldSpec, value: object) -> None:
|
|
159
|
+
if spec.max_length is not None and isinstance(value, str) and len(value) > spec.max_length:
|
|
160
|
+
raise _fail(
|
|
161
|
+
endpoint,
|
|
162
|
+
f"'{spec.py_name}' is {len(value)} characters long, "
|
|
163
|
+
f"the API accepts at most {spec.max_length}",
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def build_request(
|
|
168
|
+
endpoint: EndpointSpec,
|
|
169
|
+
*,
|
|
170
|
+
path: Mapping[str, object],
|
|
171
|
+
query: Mapping[str, object],
|
|
172
|
+
body: Mapping[str, object] | None,
|
|
173
|
+
server_id: int | str | None,
|
|
174
|
+
default_server_id: int | str | None,
|
|
175
|
+
) -> PreparedRequest:
|
|
176
|
+
"""Validate and assemble a request (SPEC §2). Raises :class:`ValidationError`."""
|
|
177
|
+
rendered = endpoint.path
|
|
178
|
+
for spec in endpoint.path_params:
|
|
179
|
+
value = path.get(spec.name)
|
|
180
|
+
if value is None or _is_blank(value):
|
|
181
|
+
raise _fail(endpoint, f"'{spec.py_name}' is required")
|
|
182
|
+
rendered = rendered.replace("{" + spec.name + "}", quote(str(value), safe=""))
|
|
183
|
+
|
|
184
|
+
params: list[tuple[str, str]] = []
|
|
185
|
+
for spec in endpoint.query:
|
|
186
|
+
value = query.get(spec.name)
|
|
187
|
+
if value is None:
|
|
188
|
+
if spec.required:
|
|
189
|
+
raise _fail(endpoint, f"'{spec.py_name}' is required")
|
|
190
|
+
continue
|
|
191
|
+
if spec.required and _is_blank(value):
|
|
192
|
+
raise _fail(endpoint, f"'{spec.py_name}' must not be blank")
|
|
193
|
+
params.append((spec.name, _query_value(value)))
|
|
194
|
+
|
|
195
|
+
if not endpoint.account_level:
|
|
196
|
+
sid = server_id if server_id is not None else default_server_id
|
|
197
|
+
if sid is not None:
|
|
198
|
+
params.append(("serverId", str(sid)))
|
|
199
|
+
|
|
200
|
+
payload: bytes | None = None
|
|
201
|
+
if endpoint.body:
|
|
202
|
+
document: dict[str, object] = {}
|
|
203
|
+
for spec in endpoint.body:
|
|
204
|
+
value = (body or {}).get(spec.name)
|
|
205
|
+
if value is None:
|
|
206
|
+
if spec.required:
|
|
207
|
+
raise _fail(endpoint, f"'{spec.py_name}' is required")
|
|
208
|
+
continue
|
|
209
|
+
if spec.required and _is_blank(value):
|
|
210
|
+
raise _fail(endpoint, f"'{spec.py_name}' must not be blank")
|
|
211
|
+
_check_length(endpoint, spec, value)
|
|
212
|
+
document[spec.name] = list(value) if isinstance(value, tuple) else value
|
|
213
|
+
payload = json.dumps(document, separators=(",", ":")).encode("utf-8")
|
|
214
|
+
|
|
215
|
+
return PreparedRequest(method=endpoint.method, path=rendered, params=params, body=payload)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ─────────────────────────────────────────────────────────────── responses
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def parse_body(response: httpx.Response) -> Any:
|
|
222
|
+
"""JSON when possible, otherwise the text (``None`` for an empty body)."""
|
|
223
|
+
content = response.content
|
|
224
|
+
if not content or not content.strip():
|
|
225
|
+
return None
|
|
226
|
+
try:
|
|
227
|
+
return json.loads(content)
|
|
228
|
+
except ValueError:
|
|
229
|
+
return response.text
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def error_message(response: httpx.Response, body: Any) -> str:
|
|
233
|
+
if isinstance(body, dict):
|
|
234
|
+
for key in ("error", "message"):
|
|
235
|
+
value = body.get(key)
|
|
236
|
+
if isinstance(value, str) and value.strip():
|
|
237
|
+
return value
|
|
238
|
+
if isinstance(body, str) and body.strip():
|
|
239
|
+
text = body.strip()
|
|
240
|
+
return text if len(text) <= 500 else text[:500] + "…"
|
|
241
|
+
if body is not None and not isinstance(body, str):
|
|
242
|
+
return json.dumps(body)[:500]
|
|
243
|
+
return response.reason_phrase or f"HTTP {response.status_code}"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def error_from_response(response: httpx.Response, body: Any) -> TxApiError:
|
|
247
|
+
"""Map an error response to the matching exception class (SPEC §4)."""
|
|
248
|
+
status = response.status_code
|
|
249
|
+
message = error_message(response, body)
|
|
250
|
+
headers = response.headers
|
|
251
|
+
rate_limit = parse_rate_limit(headers)
|
|
252
|
+
cls = error_class_for_status(status)
|
|
253
|
+
if cls is RateLimitError:
|
|
254
|
+
return RateLimitError(
|
|
255
|
+
message,
|
|
256
|
+
retry_after=parse_retry_after(headers),
|
|
257
|
+
status=status,
|
|
258
|
+
body=body,
|
|
259
|
+
headers=headers,
|
|
260
|
+
rate_limit=rate_limit,
|
|
261
|
+
)
|
|
262
|
+
return cls(message, status=status, body=body, headers=headers, rate_limit=rate_limit)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ─────────────────────────────────────────────────────────────── retries
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def backoff_delay(attempt: int, rand: Callable[[], float] = random.random) -> float:
|
|
269
|
+
"""Full jitter: ``uniform(0, min(8, 0.5 * 2**attempt))`` (SPEC §5)."""
|
|
270
|
+
return rand() * min(8.0, 0.5 * (2.0**attempt))
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def retry_delay_for_response(method: str, response: httpx.Response, attempt: int) -> float | None:
|
|
274
|
+
"""Seconds to wait before retrying this response, or ``None`` if it must not be retried."""
|
|
275
|
+
status = response.status_code
|
|
276
|
+
retry_after = parse_retry_after(response.headers)
|
|
277
|
+
if status == 429:
|
|
278
|
+
# Without Retry-After the monthly quota is exhausted: never retry.
|
|
279
|
+
if retry_after is None or retry_after > MAX_RETRY_AFTER:
|
|
280
|
+
return None
|
|
281
|
+
return retry_after
|
|
282
|
+
if status in RETRYABLE_GET_STATUSES and method == "GET":
|
|
283
|
+
if retry_after is not None and retry_after <= MAX_RETRY_AFTER:
|
|
284
|
+
return retry_after
|
|
285
|
+
return backoff_delay(attempt)
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def retry_delay_for_transport_error(method: str, attempt: int) -> float | None:
|
|
290
|
+
if method != "GET":
|
|
291
|
+
return None
|
|
292
|
+
return backoff_delay(attempt)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def make_api_response(response: httpx.Response, data: T) -> ApiResponse[T]:
|
|
296
|
+
return ApiResponse(
|
|
297
|
+
data=data,
|
|
298
|
+
status_code=response.status_code,
|
|
299
|
+
headers=response.headers,
|
|
300
|
+
rate_limit=parse_rate_limit(response.headers),
|
|
301
|
+
http_response=response,
|
|
302
|
+
)
|