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/_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
+ )