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 +38 -0
- rewloy/_version.py +3 -0
- rewloy/client.py +648 -0
- rewloy/common.py +116 -0
- rewloy/errors.py +115 -0
- rewloy/generated/__init__.py +4 -0
- rewloy/generated/methods.py +7461 -0
- rewloy/generated/operations.py +385 -0
- rewloy/generated/types.py +10296 -0
- rewloy/httpx_transport.py +126 -0
- rewloy/py.typed +0 -0
- rewloy/sse.py +322 -0
- rewloy/transport.py +215 -0
- rewloy/types.py +9 -0
- rewloy/webhooks.py +192 -0
- rewloy-0.1.0.dist-info/METADATA +581 -0
- rewloy-0.1.0.dist-info/RECORD +19 -0
- rewloy-0.1.0.dist-info/WHEEL +4 -0
- rewloy-0.1.0.dist-info/licenses/LICENSE +21 -0
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
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)
|