tgbp-kit 0.1.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.
tgbp_kit/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """tGBP, the tokenised British pound, for Python: typed clients for the mint, redeem and swap API."""
2
+
3
+ from ._http import HttpRequest, HttpResponse, Transport, generate_idempotency_key
4
+ from ._version import __version__
5
+ from .client import API_BASE_URLS, Created, PendingApproval, TgbpClient, TgbpEnvironment
6
+ from .errors import TgbpErrorCode, TgbpKitError
7
+ from .swap import SWAP_PRODUCTION_URL, SwapCreateResult, TgbpSwapClient
8
+
9
+ __all__ = [
10
+ "API_BASE_URLS",
11
+ "SWAP_PRODUCTION_URL",
12
+ "Created",
13
+ "HttpRequest",
14
+ "HttpResponse",
15
+ "PendingApproval",
16
+ "SwapCreateResult",
17
+ "TgbpClient",
18
+ "TgbpEnvironment",
19
+ "TgbpErrorCode",
20
+ "TgbpKitError",
21
+ "TgbpSwapClient",
22
+ "Transport",
23
+ "__version__",
24
+ "generate_idempotency_key",
25
+ ]
tgbp_kit/_base.py ADDED
@@ -0,0 +1,35 @@
1
+ """The base class every generated model extends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, ConfigDict, model_validator
8
+
9
+
10
+ def reject_floats(value: Any, where: str = "value") -> None:
11
+ """Raises TypeError if a float appears anywhere in `value`.
12
+
13
+ A float cannot hold most decimal amounts exactly, so 0.1 + 0.2 would reach
14
+ the API as 0.30000000000000004. Pass a `Decimal`, a string or an int.
15
+ """
16
+ if isinstance(value, float):
17
+ raise TypeError(f"{where} is a float ({value!r}); pass a Decimal, a str or an int")
18
+ if isinstance(value, dict):
19
+ for key, item in value.items():
20
+ reject_floats(item, f"{where}.{key}")
21
+ elif isinstance(value, (list, tuple)):
22
+ for index, item in enumerate(value):
23
+ reject_floats(item, f"{where}[{index}]")
24
+
25
+
26
+ class Model(BaseModel):
27
+ # Fields the pinned spec does not know yet are kept, not dropped.
28
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
29
+
30
+ # A before-validator on the whole input also covers the extra fields.
31
+ @model_validator(mode="before")
32
+ @classmethod
33
+ def _no_floats(cls, data: Any) -> Any:
34
+ reject_floats(data, cls.__name__)
35
+ return data
tgbp_kit/_http.py ADDED
@@ -0,0 +1,398 @@
1
+ """The one place the API clients talk HTTP.
2
+
3
+ It sets the headers every request carries, sends the idempotency key, and owns
4
+ the retry rule: retry only a network error or a 5xx, and only when the call is
5
+ safe to repeat. A call that creates a record is safe to repeat only because it
6
+ resends the same idempotency key, so a retry returns the first record instead
7
+ of a second mint or redemption.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import http.client
13
+ import json
14
+ import math
15
+ import random
16
+ import re
17
+ import secrets
18
+ import time
19
+ import urllib.error
20
+ import urllib.parse
21
+ import urllib.request
22
+ from collections.abc import Callable, Mapping
23
+ from dataclasses import dataclass, field
24
+ from datetime import date, datetime
25
+ from decimal import Decimal
26
+ from typing import Any
27
+
28
+ from ._version import __version__
29
+ from .errors import TgbpKitError
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class HttpRequest:
34
+ method: str
35
+ url: str
36
+ # The headers carry the API key, so they stay out of reprs and error reporters' locals.
37
+ headers: Mapping[str, str] = field(repr=False)
38
+ body: bytes | None
39
+ timeout: float
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class HttpResponse:
44
+ status: int
45
+ reason: str
46
+ headers: Mapping[str, str]
47
+ """Header names in lower case."""
48
+ body: bytes
49
+
50
+
51
+ Transport = Callable[[HttpRequest], HttpResponse]
52
+ """Sends one request. Raise OSError on a network failure; return any HTTP status as a response."""
53
+
54
+
55
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
56
+ # A redirect would carry X-API-Key to wherever it points, so a 3xx is returned as is.
57
+ def redirect_request(self, *args: Any, **kwargs: Any) -> None:
58
+ return None
59
+
60
+
61
+ _opener = urllib.request.build_opener(_NoRedirect)
62
+
63
+
64
+ def urllib_transport(req: HttpRequest) -> HttpResponse:
65
+ """The default transport, on the standard library."""
66
+ request = urllib.request.Request(req.url, data=req.body, headers=dict(req.headers), method=req.method)
67
+ try:
68
+ with _opener.open(request, timeout=req.timeout) as res:
69
+ return HttpResponse(res.status, res.reason, _lower(res.headers.items()), res.read())
70
+ except urllib.error.HTTPError as err:
71
+ with err:
72
+ return HttpResponse(err.code, err.reason, _lower(err.headers.items()), err.read())
73
+
74
+
75
+ def _lower(items: Any) -> dict[str, str]:
76
+ return {name.lower(): value for name, value in items}
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class HttpConfig:
81
+ api_key: str = field(repr=False)
82
+ base_url: str
83
+ transport: Transport = urllib_transport
84
+ timeout: float = 30.0
85
+ """Socket timeout in seconds, for the connect and for each read. A timeout counts as a network error."""
86
+ max_retries: int = 3
87
+ """Retries after the first attempt."""
88
+ base_delay: float = 0.5
89
+ """First backoff delay in seconds; each retry doubles it, with jitter."""
90
+
91
+ def __post_init__(self) -> None:
92
+ # Fails at construction, so a bad option never turns into an endless loop or an instant timeout.
93
+ # A stray newline or space would fail inside http.client, whose error quotes the key.
94
+ if not _API_KEY.fullmatch(self.api_key):
95
+ raise ValueError("api_key must be non-empty printable ASCII with no spaces or newlines")
96
+ if isinstance(self.max_retries, bool) or not (
97
+ isinstance(self.max_retries, int) and self.max_retries >= 0
98
+ ):
99
+ raise ValueError(f"max_retries must be a non-negative integer: {self.max_retries}")
100
+ if not (_finite(self.base_delay) and self.base_delay >= 0):
101
+ raise ValueError(f"base_delay must be a finite number >= 0: {self.base_delay}")
102
+ if not (_finite(self.timeout) and self.timeout > 0):
103
+ raise ValueError(f"timeout must be a finite number > 0: {self.timeout}")
104
+
105
+
106
+ def _finite(value: object) -> bool:
107
+ return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
108
+
109
+
110
+ _API_KEY = re.compile(r"[\x21-\x7e]+")
111
+ IDEMPOTENCY_KEY = re.compile(r"[A-Za-z0-9_-]{1,18}")
112
+ _KEY_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-"
113
+
114
+
115
+ def generate_idempotency_key() -> str:
116
+ """A random 18-character key (108 bits).
117
+
118
+ Store it with your order before you call, so a retry after a crash can reuse it.
119
+ """
120
+ return "".join(secrets.choice(_KEY_ALPHABET) for _ in range(18))
121
+
122
+
123
+ def assert_idempotency_key(key: str) -> None:
124
+ if not IDEMPOTENCY_KEY.fullmatch(key):
125
+ raise ValueError(f'Invalid idempotency key "{key}": use 1 to 18 letters, digits, "_" or "-"')
126
+
127
+
128
+ def normalise_base_url(base_url: str) -> str:
129
+ """Refuses plain HTTP, which would send the API key in cleartext, except to this machine."""
130
+ url = urllib.parse.urlsplit(base_url)
131
+ local = url.hostname in ("localhost", "127.0.0.1", "::1")
132
+ if url.scheme != "https" and not (url.scheme == "http" and local):
133
+ raise ValueError(f"base_url must use https: {base_url}")
134
+ if url.query or url.fragment or url.username is not None or url.password is not None:
135
+ # Not echoed: it may carry credentials.
136
+ raise ValueError("base_url must not carry a query, fragment or credentials")
137
+ if not url.netloc:
138
+ raise ValueError(f"base_url has no host: {base_url}")
139
+ return base_url.rstrip("/")
140
+
141
+
142
+ def encode_json(value: Any) -> str:
143
+ """JSON with every Decimal written as a number, digit for digit."""
144
+ if isinstance(value, Decimal):
145
+ if not value.is_finite():
146
+ raise ValueError(f"{value} is not a JSON number")
147
+ return format(value, "f")
148
+ if isinstance(value, float):
149
+ raise TypeError(f"{value!r} is a float; pass a Decimal, a str or an int")
150
+ if isinstance(value, (datetime, date)):
151
+ return json.dumps(value.isoformat())
152
+ if isinstance(value, Mapping):
153
+ items = (f"{json.dumps(str(key))}:{encode_json(item)}" for key, item in value.items())
154
+ return "{" + ",".join(items) + "}"
155
+ if isinstance(value, (list, tuple)):
156
+ return "[" + ",".join(encode_json(item) for item in value) + "]"
157
+ if value is None or isinstance(value, (str, int, bool)):
158
+ return json.dumps(value)
159
+ raise TypeError(f"cannot send {type(value).__name__} as JSON")
160
+
161
+
162
+ def query_value(value: Any) -> str:
163
+ if isinstance(value, bool):
164
+ return "true" if value else "false"
165
+ if isinstance(value, Decimal):
166
+ return format(value, "f")
167
+ if isinstance(value, float):
168
+ raise TypeError(f"{value!r} is a float; pass a Decimal, a str or an int")
169
+ if isinstance(value, (datetime, date)):
170
+ return value.isoformat()
171
+ return str(value)
172
+
173
+
174
+ @dataclass
175
+ class _Context:
176
+ method: str
177
+ path: str
178
+ idempotency_key: str | None = None
179
+ dedupe_key: tuple[str, str] | None = None
180
+ """A key the API dedupes on that travels in the body, such as a swap intent's id."""
181
+
182
+
183
+ _NOT_JSON = object()
184
+
185
+
186
+ def request(
187
+ config: HttpConfig,
188
+ method: str,
189
+ path: str,
190
+ *,
191
+ retry: bool,
192
+ path_params: Mapping[str, str] | None = None,
193
+ query: Mapping[str, Any] | None = None,
194
+ body: Any = None,
195
+ idempotency_key: str | None = None,
196
+ dedupe_key: tuple[str, str] | None = None,
197
+ ) -> tuple[int, Any]:
198
+ """Sends a request and returns the status and the parsed JSON body of a 2xx.
199
+
200
+ `retry` is True only when repeating the call cannot create or move anything twice.
201
+ JSON numbers with a fraction parse as Decimal, never float.
202
+ """
203
+ max_retries = config.max_retries if retry else 0
204
+ url = _build_url(config.base_url, path, path_params, query)
205
+ headers = {
206
+ "accept": "application/json",
207
+ "user-agent": f"tgbp-kit-python/{__version__}",
208
+ "x-api-key": config.api_key,
209
+ }
210
+ data = None
211
+ if body is not None:
212
+ headers["content-type"] = "application/json"
213
+ data = encode_json(body).encode()
214
+ if idempotency_key is not None:
215
+ headers["idempotency-key"] = idempotency_key
216
+ context = _Context(method, path, idempotency_key, dedupe_key)
217
+ outgoing = HttpRequest(method, url, headers, data, config.timeout)
218
+
219
+ attempt = 0
220
+ while True:
221
+ if attempt > 0:
222
+ time.sleep(_backoff(config.base_delay, attempt))
223
+ # A failure anywhere before the whole body arrives, timeout included, is a network error.
224
+ try:
225
+ response = config.transport(outgoing)
226
+ except (OSError, http.client.HTTPException) as cause:
227
+ if attempt < max_retries:
228
+ attempt += 1
229
+ continue
230
+ raise TgbpKitError(
231
+ "API_UNREACHABLE",
232
+ f"{method} {path} got no response ({_describe(cause)}){_retry_hint(context)}",
233
+ {
234
+ **_context_details(context),
235
+ "attempts": attempt + 1,
236
+ "mayHaveTakenEffect": method != "GET",
237
+ },
238
+ ) from cause
239
+ if response.status >= 500 and attempt < max_retries:
240
+ attempt += 1
241
+ continue
242
+ break
243
+
244
+ attempts = attempt + 1
245
+ text = response.body.decode("utf-8", errors="replace")
246
+ parsed = _parse_json(text)
247
+ # Keep a proxy's HTML or plain-text error, capped, so the caller can see what answered.
248
+ error_body = text[:2000] if parsed is _NOT_JSON else parsed
249
+ if not 200 <= response.status < 300:
250
+ raise api_error(response, error_body, context, attempts)
251
+ if parsed is _NOT_JSON:
252
+ raise api_error(response, error_body, context, attempts, "the response was not JSON")
253
+ # Every success the API documents carries a body, so an empty one is a broken answer.
254
+ if parsed is None:
255
+ raise api_error(response, None, context, attempts, "the response body was empty")
256
+ return response.status, parsed
257
+
258
+
259
+ def _describe(cause: BaseException) -> str:
260
+ """Names what went wrong without a response: a timeout, a reset, a DNS failure."""
261
+ if isinstance(cause, urllib.error.URLError):
262
+ cause = cause.reason if isinstance(cause.reason, BaseException) else cause
263
+ if isinstance(cause, TimeoutError):
264
+ return "timed out"
265
+ return f"{type(cause).__name__}: {cause}"
266
+
267
+
268
+ def _build_url(
269
+ base_url: str,
270
+ path: str,
271
+ path_params: Mapping[str, str] | None,
272
+ query: Mapping[str, Any] | None,
273
+ ) -> str:
274
+ def substitute(match: re.Match[str]) -> str:
275
+ name = match.group(1)
276
+ value = (path_params or {}).get(name)
277
+ if value is None:
278
+ raise ValueError(f"Missing path parameter {name}")
279
+ # These would resolve as dot segments and call a different endpoint.
280
+ if value in ("", ".", ".."):
281
+ raise ValueError(f'Invalid path parameter {name}: "{value}"')
282
+ return urllib.parse.quote(value, safe="")
283
+
284
+ url = base_url + re.sub(r"\{(\w+)\}", substitute, path)
285
+ pairs: list[tuple[str, str]] = []
286
+ for name, value in (query or {}).items():
287
+ if value is None:
288
+ continue
289
+ for item in value if isinstance(value, (list, tuple)) else [value]:
290
+ pairs.append((name, query_value(item)))
291
+ return f"{url}?{urllib.parse.urlencode(pairs)}" if pairs else url
292
+
293
+
294
+ def _backoff(base_delay: float, attempt: int) -> float:
295
+ """Exponential backoff with jitter: 0.5x to 1x of base * 2^(attempt - 1)."""
296
+ return float(base_delay * 2 ** (attempt - 1) * (0.5 + random.random() / 2))
297
+
298
+
299
+ def _parse_json(text: str) -> Any:
300
+ if text == "":
301
+ return None
302
+ try:
303
+ return json.loads(text, parse_float=Decimal, parse_constant=_reject_constant)
304
+ except (ValueError, RecursionError):
305
+ return _NOT_JSON
306
+
307
+
308
+ def _reject_constant(name: str) -> Any:
309
+ """NaN and Infinity are not JSON, and would parse as float."""
310
+ raise ValueError(f"{name} is not a JSON number")
311
+
312
+
313
+ def _retry_key(context: _Context) -> tuple[str, str] | None:
314
+ """The key a retry must resend: the idempotency key, or a dedupe key carried in the body."""
315
+ if context.idempotency_key is not None:
316
+ return ("idempotency key", context.idempotency_key)
317
+ return context.dedupe_key
318
+
319
+
320
+ def _retry_hint(context: _Context) -> str:
321
+ key = _retry_key(context)
322
+ if key is not None:
323
+ return f"; it may have taken effect, so retry only with the same {key[0]} ({key[1]})"
324
+ return "" if context.method == "GET" else "; it may have taken effect, so read the record first"
325
+
326
+
327
+ def _context_details(context: _Context) -> dict[str, Any]:
328
+ """The context as error details, with a dedupe key under its own name."""
329
+ details: dict[str, Any] = {"method": context.method, "path": context.path}
330
+ if context.idempotency_key is not None:
331
+ details["idempotencyKey"] = context.idempotency_key
332
+ if context.dedupe_key is not None:
333
+ details[context.dedupe_key[0]] = context.dedupe_key[1]
334
+ return details
335
+
336
+
337
+ def api_error(
338
+ response: HttpResponse,
339
+ body: Any,
340
+ context: _Context,
341
+ attempts: int,
342
+ reason: str | None = None,
343
+ ) -> TgbpKitError:
344
+ error = body.get("error") if isinstance(body, dict) else None
345
+ error = error if isinstance(error, dict) else {}
346
+ details = error.get("details")
347
+ details = details if isinstance(details, dict) else {}
348
+ api_code = error.get("code") if isinstance(error.get("code"), str) else None
349
+ request_id = details.get("request_id")
350
+ if not isinstance(request_id, str):
351
+ request_id = response.headers.get("x-request-id")
352
+ message = reason or (error["message"] if isinstance(error.get("message"), str) else response.reason)
353
+ ok = 200 <= response.status < 300
354
+ # A 409 duplicate_request on a keyed create means an earlier request with that
355
+ # key is still creating the record; resending under a new key would create a
356
+ # second one. Other 409s, such as duplicate_active_mint, created nothing.
357
+ keyed_409 = response.status == 409 and api_code == "duplicate_request" and _retry_key(context) is not None
358
+ hint = _retry_hint(context) if ok or response.status >= 500 or keyed_409 else ""
359
+ code = f" {api_code}" if api_code else ""
360
+ return TgbpKitError(
361
+ "API_ERROR",
362
+ f"{context.method} {context.path} failed with {response.status}{code}: {message}{hint}",
363
+ {
364
+ **_context_details(context),
365
+ "attempts": attempts,
366
+ "status": response.status,
367
+ "apiCode": api_code,
368
+ "requestId": request_id,
369
+ "contentType": response.headers.get("content-type"),
370
+ "mayHaveTakenEffect": hint != "",
371
+ "body": body,
372
+ },
373
+ )
374
+
375
+
376
+ def unexpected_body(
377
+ method: str,
378
+ path: str,
379
+ status: int,
380
+ body: Any,
381
+ expected: str,
382
+ *,
383
+ idempotency_key: str | None = None,
384
+ dedupe_key: tuple[str, str] | None = None,
385
+ ) -> TgbpKitError:
386
+ """A 2xx without the body it promised. A write may still have taken effect."""
387
+ context = _Context(method, path, idempotency_key, dedupe_key)
388
+ hint = _retry_hint(context)
389
+ return TgbpKitError(
390
+ "API_ERROR",
391
+ f"{method} {path} returned {status} without {expected}{hint}",
392
+ {**_context_details(context), "status": status, "mayHaveTakenEffect": hint != "", "body": body},
393
+ )
394
+
395
+
396
+ def incomplete_create(path: str, status: int, body: Any, idempotency_key: str, missing: str) -> TgbpKitError:
397
+ """A create answered 2xx without the record it promised; the error carries the key to retry with."""
398
+ return unexpected_body("POST", path, status, body, missing, idempotency_key=idempotency_key)
tgbp_kit/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """The published package version, stamped from the release tag by python/scripts/stamp_version.py."""
2
+
3
+ __version__ = "0.1.0"