rewloy 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.
rewloy/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """The official Python library for the Rewloy API.
2
+
3
+ from rewloy import Rewloy
4
+
5
+ rewloy = Rewloy(api_key=os.environ["REWLOY_API_KEY"])
6
+ card = rewloy.get_pass("ABCD-EFGH-JKLM")
7
+
8
+ Every operation of the API is a method of ``Rewloy``, named by its operationId in snake_case and typed from the
9
+ OpenAPI document. The types are in ``rewloy.types``.
10
+ """
11
+
12
+ from ._version import __version__
13
+ from .client import DEFAULT_BASE_URL, Rewloy
14
+ from .common import ApiResponse, AuthKind, Deprecation, Headers, HttpMethod, OperationMeta, Page, ResponseKind
15
+ from .errors import RateLimitError, RewloyConnectionError, RewloyError, RewloyTimeoutError
16
+ from .generated.operations import API_VERSION, ERROR_TITLES, METHOD_NAMES, OPERATION_IDS, OPERATIONS
17
+ from .sse import EventStream, ServerSentEvent, SseParser
18
+ from .transport import HttpRequest, HttpResponse, StreamResponse, Transport, TransportError, TransportTimeout, UrllibTransport
19
+ from .webhooks import (
20
+ PassEvent,
21
+ PassEventData,
22
+ WebhookEvent,
23
+ WebhookSignatureError,
24
+ WebhookTestEvent,
25
+ sign_webhook,
26
+ verify_webhook,
27
+ )
28
+
29
+ VERSION = __version__
30
+
31
+ __all__ = [
32
+ "API_VERSION", "ApiResponse", "AuthKind", "DEFAULT_BASE_URL", "Deprecation", "ERROR_TITLES", "EventStream",
33
+ "Headers", "HttpMethod", "HttpRequest", "HttpResponse", "METHOD_NAMES", "OPERATIONS", "OPERATION_IDS",
34
+ "OperationMeta", "Page", "PassEvent", "PassEventData", "RateLimitError", "ResponseKind", "Rewloy",
35
+ "RewloyConnectionError", "RewloyError", "RewloyTimeoutError", "ServerSentEvent", "SseParser", "StreamResponse",
36
+ "Transport", "TransportError", "TransportTimeout", "UrllibTransport", "VERSION", "WebhookEvent",
37
+ "WebhookSignatureError", "WebhookTestEvent", "__version__", "sign_webhook", "verify_webhook",
38
+ ]
rewloy/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """The library's version: one place, read by the build (hatchling) and by the code."""
2
+
3
+ __version__ = "0.1.0"
rewloy/client.py ADDED
@@ -0,0 +1,648 @@
1
+ """The client: credentials, the request (headers, retries, timeouts, errors, deprecation notices), pagination and
2
+ streams. The operations themselves come from the generated ``RewloyMethods``, one method per operationId.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import http
8
+ import json
9
+ import logging
10
+ import math
11
+ import platform
12
+ import random
13
+ import re
14
+ import sys
15
+ import threading
16
+ import time
17
+ import uuid
18
+ import warnings
19
+ from dataclasses import dataclass
20
+ from datetime import date, datetime, timezone
21
+ from email.utils import parsedate_to_datetime
22
+ from enum import Enum
23
+ from types import FrameType
24
+ from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Mapping, Optional, Tuple, cast
25
+ from urllib.parse import quote
26
+
27
+ from ._version import __version__
28
+ from .common import ApiResponse, AuthKind, Headers, OperationMeta, Page
29
+ from .errors import RateLimitError, RewloyConnectionError, RewloyError, RewloyTimeoutError
30
+ from .generated.methods import RewloyMethods
31
+ from .generated.operations import ERROR_TITLES, OPERATION_IDS, OPERATIONS
32
+ from .sse import EventStream
33
+ from .transport import HttpRequest, HttpResponse, StreamResponse, Transport, TransportError, TransportTimeout, UrllibTransport
34
+
35
+ if TYPE_CHECKING: # only annotations name it: the types module is big and importing the client does not load it
36
+ from .generated.types import PageMeta
37
+
38
+ DEFAULT_BASE_URL = "https://app.rewloy.com"
39
+ DEFAULT_TIMEOUT = 60.0
40
+ DEFAULT_MAX_RETRIES = 2
41
+ DEFAULT_IDLE_TIMEOUT = 60.0
42
+ #: Backoff: 0.5 s, 1 s, 2 s… up to 8 s, each with jitter (between half and all of it).
43
+ BACKOFF_BASE = 0.5
44
+ BACKOFF_MAX = 8.0
45
+ #: A ``Retry-After`` longer than this is not waited for: the error goes to the caller.
46
+ MAX_RETRY_AFTER = 60.0
47
+ IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "PUT", "DELETE"})
48
+ #: 502–504 and Cloudflare's 520–524 (the origin unreachable or too slow).
49
+ GATEWAY_STATUSES = frozenset({502, 503, 504, 520, 521, 522, 523, 524})
50
+ #: How much of an error answer is read from a stream before giving up on the rest.
51
+ MAX_ERROR_BODY = 64 * 1024
52
+
53
+ _PREFIXES: Dict[str, Tuple[str, AuthKind]] = {
54
+ "api_key": ("rwk_", "key"), "staff_session": ("rws_", "staff"), "holder_session": ("rwh_", "holder"),
55
+ }
56
+
57
+ _log = logging.getLogger("rewloy")
58
+ #: Operations already warned about: one warning per operation per process, whatever the number of clients.
59
+ _warned: set[str] = set()
60
+ _warned_lock = threading.Lock()
61
+
62
+
63
+ def parse_retry_after(value: Optional[str], now: Optional[float] = None) -> Optional[float]:
64
+ """``Retry-After`` in seconds: delta-seconds or an HTTP date."""
65
+ if not value:
66
+ return None
67
+ v = value.strip()
68
+ if re.fullmatch(r"[0-9]+(\.[0-9]+)?", v):
69
+ return float(v)
70
+ try:
71
+ at = parsedate_to_datetime(v)
72
+ except (TypeError, ValueError):
73
+ return None
74
+ if at.tzinfo is None:
75
+ at = at.replace(tzinfo=timezone.utc)
76
+ return max(0.0, at.timestamp() - (time.time() if now is None else now))
77
+
78
+
79
+ def backoff(attempt: int, rand: Callable[[], float] = random.random) -> float:
80
+ """Exponential backoff with jitter, in seconds, for the retry after attempt ``attempt`` (0-based)."""
81
+ cap = min(BACKOFF_MAX, BACKOFF_BASE * 2.0**attempt)
82
+ return cap / 2 + rand() * (cap / 2)
83
+
84
+
85
+ _LINK = re.compile(r"<([^>]*)>([^,]*)")
86
+ _REL_DEPRECATION = re.compile(r"""\brel\s*=\s*"?[^";]*\bdeprecation\b""", re.IGNORECASE)
87
+
88
+
89
+ def _deprecation_link(link: Optional[str]) -> Optional[str]:
90
+ """The URL a ``Link`` header gives for ``rel="deprecation"`` (else its first)."""
91
+ if not link:
92
+ return None
93
+ first: Optional[str] = None
94
+ for m in _LINK.finditer(link):
95
+ if first is None:
96
+ first = m.group(1)
97
+ if _REL_DEPRECATION.search(m.group(2)):
98
+ return m.group(1)
99
+ return first
100
+
101
+
102
+ def _warn(message: str) -> None:
103
+ """A ``DeprecationWarning`` that points at the caller's own line (the first frame outside this package), so
104
+ that Python's default filter, which shows it for ``__main__`` only, treats it as the caller's."""
105
+ level = 1
106
+ frame: Optional[FrameType] = sys._getframe(0)
107
+ while frame is not None and re.match(r"rewloy(\.|$)", str(frame.f_globals.get("__name__", ""))):
108
+ frame = frame.f_back
109
+ level += 1
110
+ try:
111
+ warnings.warn(message, DeprecationWarning, stacklevel=level)
112
+ except Warning:
113
+ # `-W error` turns the warning into an exception here, after the server has acted: the answer to a call
114
+ # that went through must not be lost for a notice. The notice goes to the log instead.
115
+ _log.warning("DeprecationWarning: %s", message)
116
+
117
+
118
+ def _json_default(value: object) -> Any:
119
+ if isinstance(value, (datetime, date)):
120
+ return value.isoformat()
121
+ if isinstance(value, uuid.UUID):
122
+ return str(value)
123
+ if isinstance(value, Enum):
124
+ return value.value
125
+ raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
126
+
127
+
128
+ def _scalar(value: object, where: str) -> str:
129
+ if isinstance(value, bool):
130
+ return "true" if value else "false"
131
+ if isinstance(value, str):
132
+ return value
133
+ if isinstance(value, Enum):
134
+ return _scalar(value.value, where)
135
+ if isinstance(value, (int, float)):
136
+ if isinstance(value, float) and not math.isfinite(value):
137
+ raise ValueError(f"Rewloy: {where} is not a finite number")
138
+ return str(value)
139
+ if isinstance(value, (datetime, date)):
140
+ return value.isoformat()
141
+ if isinstance(value, uuid.UUID):
142
+ return str(value)
143
+ raise TypeError(f"Rewloy: {where} must be a string, number or boolean, not {type(value).__name__}")
144
+
145
+
146
+ def encode_query(query: Mapping[str, object]) -> str:
147
+ """A query string: RFC 3986 (``%20``), booleans as ``true``/``false``, a list repeats its key, ``None`` leaves a
148
+ parameter out."""
149
+ pairs: List[str] = []
150
+ for key, value in query.items():
151
+ if value is None:
152
+ continue
153
+ for item in value if isinstance(value, (list, tuple, set, frozenset)) else [value]:
154
+ if item is None:
155
+ continue
156
+ pairs.append(f"{quote(str(key), safe='')}={quote(_scalar(item, f'query.{key}'), safe='')}")
157
+ return "&".join(pairs)
158
+
159
+
160
+ @dataclass(frozen=True)
161
+ class _Call:
162
+ """What the caller gave: everything about one call that is not the operation itself."""
163
+
164
+ path: Optional[Mapping[str, object]] = None
165
+ query: Optional[Mapping[str, object]] = None
166
+ body: object = None
167
+ headers: Optional[Mapping[str, object]] = None
168
+ merchant: Optional[str] = None
169
+ idempotency_key: Optional[str] = None
170
+ timeout: Optional[float] = None
171
+ max_retries: Optional[int] = None
172
+ idle_timeout: Optional[float] = None
173
+
174
+
175
+ @dataclass(frozen=True)
176
+ class _Exchange:
177
+ status: int
178
+ headers: Headers
179
+ data: Any
180
+ meta: Optional[PageMeta]
181
+ #: For a stream: the open answer, its body unread.
182
+ stream: Optional[StreamResponse] = None
183
+
184
+
185
+ def _none_if_unlimited(seconds: float) -> Optional[float]:
186
+ """0 or infinity mean no limit."""
187
+ return None if seconds <= 0 or math.isinf(seconds) else seconds
188
+
189
+
190
+ class Rewloy(RewloyMethods):
191
+ """A client of the Rewloy API (``https://app.rewloy.com/v1``).
192
+
193
+ ::
194
+
195
+ rewloy = Rewloy(api_key=os.environ["REWLOY_API_KEY"])
196
+ card = rewloy.get_pass("ABCD-EFGH-JKLM")
197
+
198
+ Every operation of the API is a method named by its operationId in snake_case. Path parameters are arguments;
199
+ ``query``, ``body``, ``merchant``, ``idempotency_key``, ``timeout`` and ``max_retries`` follow as keywords.
200
+
201
+ One credential, or none for the endpoints that need none (sign-in, joining a programme…):
202
+
203
+ - ``api_key="rwk_…"``: an API key (a till, a shop, your own system);
204
+ - ``staff_session="rws_…"``, with ``merchant`` when the person has seats in several businesses: a person's
205
+ business app;
206
+ - ``holder_session="rwh_…"``: a card holder's session (a Rewloy Cüzdan app).
207
+
208
+ ``timeout`` is the seconds one attempt may take (0 or ``math.inf`` for none), ``max_retries`` the retries after
209
+ a failed attempt when retrying is safe, ``transport`` replaces the HTTP layer (``HttpxTransport``, or a fake in
210
+ tests), ``user_agent`` is added to the ``User-Agent`` this client sends (``"KasaPOS/4.2"``), and ``sleep``
211
+ replaces the wait between retries and reconnections (it gets seconds).
212
+
213
+ A client is safe to share between threads. Use it as a context manager, or call ``close()``, to release what its
214
+ transport holds.
215
+ """
216
+
217
+ base_url: str
218
+ timeout: float
219
+ max_retries: int
220
+ #: The kind of credential this client sends, or ``None`` for none.
221
+ credential: Optional[AuthKind]
222
+ #: The default ``Rewloy-Merchant`` of a staff session.
223
+ merchant: Optional[str]
224
+
225
+ def __init__(
226
+ self,
227
+ *,
228
+ api_key: Optional[str] = None,
229
+ staff_session: Optional[str] = None,
230
+ holder_session: Optional[str] = None,
231
+ merchant: Optional[str] = None,
232
+ base_url: str = DEFAULT_BASE_URL,
233
+ timeout: float = DEFAULT_TIMEOUT,
234
+ max_retries: int = DEFAULT_MAX_RETRIES,
235
+ transport: Optional[Transport] = None,
236
+ user_agent: Optional[str] = None,
237
+ sleep: Optional[Callable[[float], None]] = None,
238
+ ) -> None:
239
+ given = [(k, v) for k, v in (("api_key", api_key), ("staff_session", staff_session), ("holder_session", holder_session)) if v is not None]
240
+ if len(given) > 1:
241
+ raise ValueError(f"Rewloy: give one credential, not {' and '.join(k for k, _ in given)}")
242
+ self._token: Optional[str] = None
243
+ self.credential = None
244
+ if given:
245
+ which, token = given[0]
246
+ prefix, kind = _PREFIXES[which]
247
+ if not isinstance(token, str):
248
+ raise TypeError(f"Rewloy: {which} must be a string")
249
+ if not token.startswith(prefix):
250
+ raise ValueError(f'Rewloy: {which} must start with "{prefix}"')
251
+ self._token = token
252
+ self.credential = kind
253
+ if merchant is not None and staff_session is None:
254
+ raise ValueError("Rewloy: `merchant` goes with a staff_session")
255
+ self.merchant = merchant
256
+ if not re.match(r"https?://", base_url, re.IGNORECASE):
257
+ raise ValueError("Rewloy: base_url must start with http:// or https://")
258
+ self.base_url = base_url.rstrip("/")
259
+ if timeout < 0:
260
+ raise ValueError("Rewloy: timeout cannot be negative")
261
+ self.timeout = timeout
262
+ self.max_retries = max(0, max_retries)
263
+ self._owns_transport = transport is None
264
+ self._transport: Transport = transport if transport is not None else UrllibTransport()
265
+ self._user_agent = " ".join(
266
+ x for x in (f"rewloy-python/{__version__}", f"python/{platform.python_version()}", (user_agent or "").strip()) if x
267
+ )
268
+ self._user_sleep = sleep
269
+
270
+ def __repr__(self) -> str:
271
+ return f"Rewloy(base_url={self.base_url!r}, credential={self.credential!r})"
272
+
273
+ def __enter__(self) -> "Rewloy":
274
+ return self
275
+
276
+ def __exit__(self, *exc: object) -> None:
277
+ self.close()
278
+
279
+ def close(self) -> None:
280
+ """Releases what the transport holds. A transport you passed in is yours to close."""
281
+ if self._owns_transport:
282
+ self._transport.close()
283
+
284
+ # -- public calls
285
+
286
+ def request(
287
+ self,
288
+ operation_id: str,
289
+ *,
290
+ path: Optional[Mapping[str, object]] = None,
291
+ query: Optional[Mapping[str, object]] = None,
292
+ body: object = None,
293
+ headers: Optional[Mapping[str, object]] = None,
294
+ merchant: Optional[str] = None,
295
+ idempotency_key: Optional[str] = None,
296
+ timeout: Optional[float] = None,
297
+ max_retries: Optional[int] = None,
298
+ ) -> ApiResponse[Any]:
299
+ """Calls an operation and returns the whole answer: ``data``, ``meta`` on paged lists, the status, headers,
300
+ ``request_id``, ``mode`` and ``replayed``.
301
+
302
+ ``operation_id`` is the operationId (``sendCampaign``) or the method's name (``send_campaign``); ``path``
303
+ holds the path parameters by their names in the API (``{"serial": "ABCD-EFGH-JKLM"}``). ``data`` is not
304
+ typed here: ``typing.cast`` it, or call the method itself.
305
+
306
+ ::
307
+
308
+ res = rewloy.request("sendCampaign", body={"body": "Bu hafta kahveler 2 damga!"})
309
+ res.status, res.replayed, res.mode, res.data["id"]
310
+ """
311
+ op = self._operation(operation_id)
312
+ if op.stream:
313
+ raise ValueError(f"Rewloy: {op.id} is a stream; use stream({op.id!r})")
314
+ ex = self._exchange(op, _Call(path, query, body, headers, merchant, idempotency_key, timeout, max_retries))
315
+ return ApiResponse(
316
+ data=ex.data, meta=ex.meta, status=ex.status, headers=ex.headers,
317
+ request_id=ex.headers.get("x-request-id"), mode=ex.headers.get("rewloy-mode"),
318
+ replayed=ex.headers.get("idempotent-replayed") == "true",
319
+ )
320
+
321
+ def stream(
322
+ self,
323
+ operation_id: str,
324
+ *,
325
+ path: Optional[Mapping[str, object]] = None,
326
+ query: Optional[Mapping[str, object]] = None,
327
+ headers: Optional[Mapping[str, object]] = None,
328
+ merchant: Optional[str] = None,
329
+ timeout: Optional[float] = None,
330
+ max_retries: Optional[int] = None,
331
+ reconnect: bool = True,
332
+ idle_timeout: Optional[float] = None,
333
+ ) -> EventStream:
334
+ """Opens a server-sent event stream (``liveFeed``, ``holderCardEvents``), the same as its method
335
+ (``live_feed``, ``holder_card_events``)."""
336
+ return self._open(
337
+ operation_id, path=path, query=query, headers=headers, merchant=merchant, timeout=timeout,
338
+ max_retries=max_retries, reconnect=reconnect, idle_timeout=idle_timeout,
339
+ )
340
+
341
+ # -- what the generated methods call
342
+
343
+ def _call(
344
+ self,
345
+ operation_id: str,
346
+ *,
347
+ path: Optional[Mapping[str, object]] = None,
348
+ query: Optional[Mapping[str, object]] = None,
349
+ body: object = None,
350
+ headers: Optional[Mapping[str, object]] = None,
351
+ merchant: Optional[str] = None,
352
+ idempotency_key: Optional[str] = None,
353
+ timeout: Optional[float] = None,
354
+ max_retries: Optional[int] = None,
355
+ ) -> object:
356
+ op = self._operation(operation_id)
357
+ ex = self._exchange(op, _Call(path, query, body, headers, merchant, idempotency_key, timeout, max_retries))
358
+ if op.paged:
359
+ assert ex.meta is not None # _read refuses a paged answer without it
360
+ return Page(data=ex.data, meta=ex.meta)
361
+ return ex.data
362
+
363
+ def _open(
364
+ self,
365
+ operation_id: str,
366
+ *,
367
+ path: Optional[Mapping[str, object]] = None,
368
+ query: Optional[Mapping[str, object]] = None,
369
+ headers: Optional[Mapping[str, object]] = None,
370
+ merchant: Optional[str] = None,
371
+ timeout: Optional[float] = None,
372
+ max_retries: Optional[int] = None,
373
+ reconnect: bool = True,
374
+ idle_timeout: Optional[float] = None,
375
+ ) -> EventStream:
376
+ op = self._operation(operation_id)
377
+ if not op.stream:
378
+ raise ValueError(f"Rewloy: {op.id} is not a stream; use request({op.id!r})")
379
+ idle = _none_if_unlimited(DEFAULT_IDLE_TIMEOUT if idle_timeout is None else idle_timeout)
380
+ call = _Call(path, query, None, headers, merchant, None, timeout, max_retries, idle if idle is not None else 0.0)
381
+
382
+ def connect(last_event_id: str) -> StreamResponse:
383
+ stream = self._exchange(op, call, last_event_id=last_event_id).stream
384
+ assert stream is not None
385
+ return stream
386
+
387
+ return EventStream(operation=op.id, connect=connect, reconnect=reconnect, idle_timeout=idle, sleep=self._stream_sleep)
388
+
389
+ def _paginate(
390
+ self,
391
+ operation_id: str,
392
+ *,
393
+ path: Optional[Mapping[str, object]] = None,
394
+ query: Optional[Mapping[str, object]] = None,
395
+ merchant: Optional[str] = None,
396
+ timeout: Optional[float] = None,
397
+ max_retries: Optional[int] = None,
398
+ ) -> Iterator[Any]:
399
+ op = self._operation(operation_id)
400
+ if not op.paged:
401
+ raise ValueError(f"Rewloy: {op.id} is not a paged list")
402
+ base: Dict[str, object] = dict(query or {})
403
+ first = base.get("page", 1)
404
+ start = int(first) if isinstance(first, (int, float, str)) and not isinstance(first, bool) else 1
405
+
406
+ def pages() -> Iterator[Any]:
407
+ page = start
408
+ while True:
409
+ ex = self._exchange(op, _Call(path, {**base, "page": page}, None, None, merchant, None, timeout, max_retries))
410
+ items: List[Any] = ex.data if isinstance(ex.data, list) else []
411
+ yield from items
412
+ meta = ex.meta
413
+ if (
414
+ meta is None or not items or len(items) < meta["pageSize"]
415
+ or meta["page"] * meta["pageSize"] >= meta["total"]
416
+ ):
417
+ return
418
+ page = meta["page"] + 1
419
+
420
+ return pages()
421
+
422
+ # -- the request
423
+
424
+ def _operation(self, operation_id: str) -> OperationMeta:
425
+ op = OPERATIONS.get(operation_id)
426
+ if op is None and operation_id in OPERATION_IDS:
427
+ op = OPERATIONS[OPERATION_IDS[operation_id]]
428
+ if op is None:
429
+ raise ValueError(f'Rewloy: unknown operation "{operation_id}"')
430
+ return op
431
+
432
+ def _url(self, op: OperationMeta, call: _Call) -> str:
433
+ def fill(match: "re.Match[str]") -> str:
434
+ name = match.group(1)
435
+ value = (call.path or {}).get(name)
436
+ if value is None or value == "":
437
+ raise ValueError(f"Rewloy: {op.id} needs path[{name!r}]")
438
+ return quote(_scalar(value, f"path.{name}"), safe="")
439
+
440
+ url = self.base_url + re.sub(r"\{([^}]+)\}", fill, op.path)
441
+ qs = encode_query(call.query or {})
442
+ return f"{url}?{qs}" if qs else url
443
+
444
+ def _headers(self, op: OperationMeta, call: _Call, last_event_id: Optional[str]) -> Dict[str, str]:
445
+ h: Dict[str, str] = {}
446
+ h["Accept"] = (
447
+ "text/event-stream" if op.stream
448
+ else "application/json" if op.response in ("json", "raw-json") else "*/*"
449
+ )
450
+ h["User-Agent"] = self._user_agent
451
+ # An operation that takes no credential of this kind but works without one is called without it: the API
452
+ # refuses a credential an operation does not accept (CREDENTIAL_NOT_ALLOWED).
453
+ if self._token and self.credential and (self.credential in op.auth or "public" not in op.auth):
454
+ h["Authorization"] = f"Bearer {self._token}"
455
+ merchant = call.merchant if call.merchant is not None else (self.merchant if self.credential == "staff" else None)
456
+ if op.merchant and merchant:
457
+ h["Rewloy-Merchant"] = merchant
458
+ if op.idempotency:
459
+ h["Idempotency-Key"] = call.idempotency_key or str(uuid.uuid4())
460
+ if op.body:
461
+ h["Content-Type"] = "application/json"
462
+ if op.stream:
463
+ # As an EventSource asks, and so that no proxy holds events back.
464
+ h["Cache-Control"] = "no-cache"
465
+ h["Accept-Encoding"] = "identity"
466
+ if last_event_id:
467
+ h["Last-Event-ID"] = last_event_id
468
+ for name, value in (call.headers or {}).items():
469
+ if value is not None:
470
+ h[name] = _scalar(value, f"headers.{name}")
471
+ return h
472
+
473
+ def _body(self, op: OperationMeta, call: _Call) -> Optional[bytes]:
474
+ if not op.body:
475
+ return None
476
+ return json.dumps(
477
+ {} if call.body is None else call.body, ensure_ascii=False, separators=(",", ":"), allow_nan=False, default=_json_default
478
+ ).encode("utf-8")
479
+
480
+ def _notice(self, op: OperationMeta, headers: Headers) -> None:
481
+ if "deprecation" not in headers:
482
+ return
483
+ with _warned_lock:
484
+ if op.id in _warned:
485
+ return
486
+ _warned.add(op.id)
487
+ sunset = headers.get("sunset")
488
+ link = _deprecation_link(headers.get("link"))
489
+ _warn(
490
+ f"Rewloy API operation {op.id} ({op.http_method} {op.path}) is deprecated."
491
+ f"{f' Sunset: {sunset}.' if sunset else ''}{f' See {link}' if link else ''}"
492
+ )
493
+
494
+ def _pause(self, seconds: float) -> None:
495
+ (self._user_sleep or time.sleep)(seconds)
496
+
497
+ def _stream_sleep(self, seconds: float, stop: threading.Event) -> None:
498
+ """A wait that ``close()`` ends at once (unless the caller replaced sleeping)."""
499
+ if self._user_sleep is not None:
500
+ self._user_sleep(seconds)
501
+ else:
502
+ stop.wait(seconds)
503
+
504
+ @staticmethod
505
+ def _retry_status(status: int, code: str) -> bool:
506
+ return status == 429 or status in GATEWAY_STATUSES or (status == 409 and code == "IDEMPOTENCY_IN_PROGRESS")
507
+
508
+ def _exchange(self, op: OperationMeta, call: _Call, *, last_event_id: Optional[str] = None) -> _Exchange:
509
+ """One call: attempts until an answer settles it. For a stream it returns once the headers are in, the body
510
+ unread; otherwise with the body read."""
511
+ url = self._url(op, call)
512
+ headers = self._headers(op, call, last_event_id)
513
+ body = self._body(op, call)
514
+ retryable = op.http_method in IDEMPOTENT_METHODS or any(k.lower() == "idempotency-key" for k in headers)
515
+ max_retries = max(0, call.max_retries if call.max_retries is not None else self.max_retries)
516
+ timeout = _none_if_unlimited(self.timeout if call.timeout is None else call.timeout)
517
+ idle = _none_if_unlimited(call.idle_timeout) if call.idle_timeout is not None else None
518
+ request = HttpRequest(op.http_method, url, headers, body, timeout, idle)
519
+
520
+ attempt = 0
521
+ while True:
522
+ transport_failure: Optional[RewloyError] = None
523
+ cause: Optional[BaseException] = None
524
+ answer: Optional[HttpResponse] = None
525
+ opened: Optional[StreamResponse] = None
526
+ try:
527
+ if op.stream:
528
+ opened = self._transport.open_stream(request)
529
+ else:
530
+ answer = self._transport.send(request)
531
+ except TransportTimeout as err:
532
+ transport_failure = RewloyTimeoutError(
533
+ detail=f"no answer within {timeout:g} s" if timeout is not None else str(err), operation=op.id
534
+ )
535
+ cause = err
536
+ except TransportError as err:
537
+ transport_failure = RewloyConnectionError(detail=str(err), operation=op.id)
538
+ cause = err
539
+
540
+ wait: Optional[float] = None
541
+ if transport_failure is not None:
542
+ if not retryable or attempt >= max_retries:
543
+ raise transport_failure from cause
544
+ failure = transport_failure
545
+ else:
546
+ if opened is not None:
547
+ status, reason, response_headers = opened.status, opened.reason, opened.headers
548
+ else:
549
+ assert answer is not None
550
+ status, reason, response_headers = answer.status, answer.reason, answer.headers
551
+ self._notice(op, response_headers)
552
+ if 200 <= status < 300:
553
+ if opened is not None:
554
+ return _Exchange(status, response_headers, None, None, opened)
555
+ assert answer is not None
556
+ data, meta = self._read(op, status, response_headers, answer.body)
557
+ return _Exchange(status, response_headers, data, meta)
558
+ if opened is not None:
559
+ text = self._drain(opened)
560
+ else:
561
+ assert answer is not None
562
+ text = answer.body.decode("utf-8", errors="replace")
563
+ failure = self._failure(op, status, reason, response_headers, text)
564
+ if not retryable or attempt >= max_retries or not self._retry_status(status, failure.code):
565
+ raise failure
566
+ wait = parse_retry_after(response_headers.get("retry-after"))
567
+
568
+ delay = wait if wait is not None else backoff(attempt)
569
+ if delay > MAX_RETRY_AFTER:
570
+ raise failure from cause
571
+ self._pause(delay)
572
+ attempt += 1
573
+
574
+ @staticmethod
575
+ def _drain(opened: StreamResponse) -> str:
576
+ """The (error) body of an answer to a stream, bounded; the connection is closed."""
577
+ parts: List[bytes] = []
578
+ size = 0
579
+ try:
580
+ while size < MAX_ERROR_BODY:
581
+ chunk = opened.read(8192)
582
+ if not chunk:
583
+ break
584
+ parts.append(chunk)
585
+ size += len(chunk)
586
+ except TransportError:
587
+ pass
588
+ finally:
589
+ opened.close()
590
+ return b"".join(parts).decode("utf-8", errors="replace")
591
+
592
+ def _read(self, op: OperationMeta, status: int, headers: Headers, content: bytes) -> Tuple[Any, Optional[PageMeta]]:
593
+ if op.response == "none" or status == 204:
594
+ return None, None
595
+ if op.response == "blob":
596
+ return content, None
597
+ text = content.decode("utf-8", errors="replace")
598
+ try:
599
+ parsed = json.loads(text)
600
+ except ValueError:
601
+ raise self._invalid(op, status, headers, text) from None
602
+ if op.response == "raw-json":
603
+ return parsed, None
604
+ if not isinstance(parsed, dict) or "data" not in parsed:
605
+ raise self._invalid(op, status, headers, parsed)
606
+ meta = parsed.get("meta")
607
+ if op.paged and not isinstance(meta, dict):
608
+ raise self._invalid(op, status, headers, parsed)
609
+ return parsed["data"], cast("Optional[PageMeta]", meta if isinstance(meta, dict) else None)
610
+
611
+ @staticmethod
612
+ def _invalid(op: OperationMeta, status: int, headers: Headers, body: object) -> RewloyError:
613
+ return RewloyError(
614
+ status=status, code="INVALID_RESPONSE",
615
+ detail=f"the answer is not the JSON the API documents ({headers.get('content-type') or 'no content type'})",
616
+ request_id=headers.get("x-request-id"), body=body, headers=headers, operation=op.id,
617
+ )
618
+
619
+ @staticmethod
620
+ def _failure(op: OperationMeta, status: int, reason: str, headers: Headers, text: str) -> RewloyError:
621
+ parsed: Any = text
622
+ try:
623
+ parsed = json.loads(text) if text else None
624
+ except ValueError:
625
+ pass # not JSON: a proxy's page
626
+ e = parsed.get("error") if isinstance(parsed, dict) else None
627
+ e = e if isinstance(e, dict) else None
628
+ code = e["code"] if e is not None and isinstance(e.get("code"), str) else f"HTTP_{status}"
629
+ try:
630
+ phrase = http.HTTPStatus(status).phrase
631
+ except ValueError:
632
+ phrase = f"HTTP {status}"
633
+ fields: Dict[str, Any] = dict(
634
+ status=status, code=code, title=ERROR_TITLES.get(code),
635
+ detail=e["message"] if e is not None and isinstance(e.get("message"), str) else (reason or phrase),
636
+ details=e.get("details") if e is not None else None,
637
+ docs=e["docs"] if e is not None and isinstance(e.get("docs"), str) else None,
638
+ request_id=headers.get("x-request-id") or (e["requestId"] if e is not None and isinstance(e.get("requestId"), str) else None),
639
+ body=parsed, headers=headers, operation=op.id,
640
+ )
641
+ if status == 429:
642
+ header = parse_retry_after(headers.get("retry-after"))
643
+ from_body = fields["details"].get("retryAfterSec") if isinstance(fields["details"], dict) else None
644
+ retry_after = math.ceil(header) if header is not None else (
645
+ int(from_body) if isinstance(from_body, (int, float)) and not isinstance(from_body, bool) else None
646
+ )
647
+ return RateLimitError(retry_after=retry_after, **fields)
648
+ return RewloyError(**fields)