repsapi 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.
- repsapi/__init__.py +39 -0
- repsapi/_client.py +769 -0
- repsapi/_errors.py +117 -0
- repsapi/_version.py +1 -0
- repsapi/py.typed +0 -0
- repsapi/types.py +509 -0
- repsapi-0.1.0.dist-info/METADATA +162 -0
- repsapi-0.1.0.dist-info/RECORD +10 -0
- repsapi-0.1.0.dist-info/WHEEL +4 -0
- repsapi-0.1.0.dist-info/licenses/LICENSE +21 -0
repsapi/_client.py
ADDED
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
import random
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from email.utils import parsedate_to_datetime
|
|
13
|
+
from typing import Any, Callable, Dict, List, Literal, Mapping, Optional, Sequence, Tuple, Union, cast, overload
|
|
14
|
+
from urllib.parse import urlsplit
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from ._errors import (
|
|
19
|
+
APIConnectionError,
|
|
20
|
+
APIError,
|
|
21
|
+
APITimeoutError,
|
|
22
|
+
AuthenticationError,
|
|
23
|
+
BadRequestError,
|
|
24
|
+
InternalServerError,
|
|
25
|
+
NotFoundError,
|
|
26
|
+
PermissionDeniedError,
|
|
27
|
+
RateLimitError,
|
|
28
|
+
RepsApiError,
|
|
29
|
+
UnprocessableEntityError,
|
|
30
|
+
)
|
|
31
|
+
from ._version import __version__
|
|
32
|
+
from .types import (
|
|
33
|
+
Agent,
|
|
34
|
+
ChangelogResponse,
|
|
35
|
+
ConvertResponse,
|
|
36
|
+
Dimensions,
|
|
37
|
+
HaulItemInput,
|
|
38
|
+
HaulResponse,
|
|
39
|
+
HealthResponse,
|
|
40
|
+
LocalizedChangelogResponse,
|
|
41
|
+
LookupResponse,
|
|
42
|
+
Platform,
|
|
43
|
+
QcResponse,
|
|
44
|
+
RatesResponse,
|
|
45
|
+
ShippingCountriesResponse,
|
|
46
|
+
ShippingEstimateResponse,
|
|
47
|
+
TrackingResponse,
|
|
48
|
+
UsageResponse,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
__all__ = ["RepsApi", "AsyncRepsApi", "RateLimitInfo", "AGENTS", "DEFAULT_BASE_URL"]
|
|
52
|
+
|
|
53
|
+
DEFAULT_BASE_URL = "https://repsapi.pl/v1"
|
|
54
|
+
DEFAULT_TIMEOUT = 60.0
|
|
55
|
+
# hauls and link-based shipping estimates look up every item first
|
|
56
|
+
SLOW_TIMEOUT = 180.0
|
|
57
|
+
DEFAULT_MAX_RETRIES = 2
|
|
58
|
+
# a 429 asking us to wait longer than this (e.g. the daily quota) raises at once
|
|
59
|
+
DEFAULT_MAX_RETRY_WAIT = 60.0
|
|
60
|
+
|
|
61
|
+
#: Every agent ``convert()`` builds links for, in the API's order.
|
|
62
|
+
AGENTS: Tuple[Agent, ...] = (
|
|
63
|
+
"kakobuy", "cnfans", "acbuy", "mulebuy", "hoobuy", "oopbuy", "cnshopper", "ootdbuy",
|
|
64
|
+
"ponybuy", "superbuy", "sugargoo", "loongbuy", "cssbuy", "usfans", "litbuy",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
_USER_AGENT = f"repsapi-python/{__version__}"
|
|
68
|
+
_KEY_CHARS = re.compile(r"[\x21-\x7e]+")
|
|
69
|
+
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
|
70
|
+
_RETRY_STATUSES = {500, 502, 503, 504}
|
|
71
|
+
_NOT_JSON = object()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class RateLimitInfo:
|
|
76
|
+
"""Per-minute limit from the last response that carried one."""
|
|
77
|
+
|
|
78
|
+
limit: int
|
|
79
|
+
remaining: int
|
|
80
|
+
reset: datetime
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class _Call:
|
|
85
|
+
method: str
|
|
86
|
+
path: str
|
|
87
|
+
query: Optional[Dict[str, Any]] = None
|
|
88
|
+
body: Optional[Dict[str, Any]] = None
|
|
89
|
+
# send the API key (public endpoints never get it)
|
|
90
|
+
auth: bool = True
|
|
91
|
+
default_timeout: Optional[float] = None
|
|
92
|
+
# error answers to hand back anyway (health while the API is down)
|
|
93
|
+
accept: Optional[Callable[[int, Any], bool]] = None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# --------------------------------------------------------------------------
|
|
97
|
+
# validation helpers
|
|
98
|
+
# --------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
def _clean_key(key: Any) -> Optional[str]:
|
|
101
|
+
if key is None:
|
|
102
|
+
return None
|
|
103
|
+
if not isinstance(key, str):
|
|
104
|
+
raise RepsApiError("api_key must be a string.")
|
|
105
|
+
cleaned: str = key.strip()
|
|
106
|
+
if not cleaned:
|
|
107
|
+
return None
|
|
108
|
+
if not _KEY_CHARS.fullmatch(cleaned):
|
|
109
|
+
raise RepsApiError("api_key contains spaces, line breaks or other invalid characters. Copy it again from the dashboard.")
|
|
110
|
+
return cleaned
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _clean_base_url(raw: Any) -> str:
|
|
114
|
+
if not isinstance(raw, str):
|
|
115
|
+
raise RepsApiError("base_url must be a string.")
|
|
116
|
+
try:
|
|
117
|
+
parts = urlsplit(raw.strip())
|
|
118
|
+
host = parts.hostname or ""
|
|
119
|
+
_ = parts.port # raises on a malformed port
|
|
120
|
+
except ValueError:
|
|
121
|
+
raise RepsApiError(f"base_url is not a valid URL: {raw}") from None
|
|
122
|
+
if not parts.netloc:
|
|
123
|
+
raise RepsApiError(f"base_url is not a valid URL: {raw}")
|
|
124
|
+
if not (parts.scheme == "https" or (parts.scheme == "http" and host in _LOCAL_HOSTS)):
|
|
125
|
+
raise RepsApiError(
|
|
126
|
+
"base_url must start with https:// so your API key is never sent unencrypted "
|
|
127
|
+
"(http:// is only allowed for localhost)."
|
|
128
|
+
)
|
|
129
|
+
if parts.username or parts.password or parts.query or parts.fragment:
|
|
130
|
+
raise RepsApiError("base_url must not contain credentials, a query string or a fragment.")
|
|
131
|
+
return f"{parts.scheme}://{parts.netloc}{parts.path.rstrip('/')}"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _non_negative(value: Any, name: str) -> float:
|
|
135
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value < 0:
|
|
136
|
+
raise RepsApiError(f"{name} must be a non-negative number.")
|
|
137
|
+
return float(value)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _timeout(value: Any) -> float:
|
|
141
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value <= 0:
|
|
142
|
+
raise RepsApiError("timeout must be a positive number of seconds.")
|
|
143
|
+
return float(value)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _text(value: Any, what: str) -> str:
|
|
147
|
+
if not isinstance(value, str) or not value.strip():
|
|
148
|
+
raise RepsApiError(f"{what} must be a non-empty string.")
|
|
149
|
+
return value.strip()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _item_query(url: Optional[str], platform: Optional[str], id: Optional[Union[str, int]], method: str) -> Dict[str, Any]:
|
|
153
|
+
if url is not None:
|
|
154
|
+
if platform is not None or id is not None:
|
|
155
|
+
raise RepsApiError(f"{method}(): pass either a product link or platform and id, not both.")
|
|
156
|
+
return {"url": _text(url, f"{method}(): the product link")}
|
|
157
|
+
if platform is None or id is None:
|
|
158
|
+
raise RepsApiError(f"{method}(): pass a product link, or platform and id.")
|
|
159
|
+
return {"platform": _text(platform, f"{method}(): platform"), "id": _text(str(id), f"{method}(): id")}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _flags(body: Dict[str, Any], **flags: Any) -> None:
|
|
163
|
+
for name, value in flags.items():
|
|
164
|
+
if value is not None:
|
|
165
|
+
body[name] = value
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _retry_after(value: Optional[str]) -> Optional[float]:
|
|
169
|
+
if not value:
|
|
170
|
+
return None
|
|
171
|
+
try:
|
|
172
|
+
seconds = float(value)
|
|
173
|
+
return seconds if math.isfinite(seconds) and seconds >= 0 else None
|
|
174
|
+
except ValueError:
|
|
175
|
+
pass
|
|
176
|
+
try:
|
|
177
|
+
at = parsedate_to_datetime(value)
|
|
178
|
+
except (TypeError, ValueError, IndexError):
|
|
179
|
+
return None
|
|
180
|
+
if at.tzinfo is None:
|
|
181
|
+
at = at.replace(tzinfo=timezone.utc)
|
|
182
|
+
return max(0.0, (at - datetime.now(timezone.utc)).total_seconds())
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _backoff(attempt: int) -> float:
|
|
186
|
+
"""0.5 s, 1 s, 2 s ... (max 8 s) with jitter"""
|
|
187
|
+
return min(8.0, 0.5 * 2.0 ** attempt) * (0.75 + random.random() * 0.5)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _to_error(status: int, data: Any, text: str, headers: httpx.Headers) -> APIError:
|
|
191
|
+
type_ = "server_error" if status >= 500 else "error"
|
|
192
|
+
message = f"The API answered {status}. Try again in a moment." if status >= 500 else f"HTTP {status}"
|
|
193
|
+
details: Dict[str, Any] = {}
|
|
194
|
+
if isinstance(data, dict):
|
|
195
|
+
# fields next to "error" too, e.g. the resolved items of a failed shipping estimate
|
|
196
|
+
siblings = {k: v for k, v in data.items() if k != "error"}
|
|
197
|
+
err = data.get("error")
|
|
198
|
+
if isinstance(err, dict):
|
|
199
|
+
if isinstance(err.get("type"), str):
|
|
200
|
+
type_ = err["type"]
|
|
201
|
+
if isinstance(err.get("message"), str):
|
|
202
|
+
message = err["message"]
|
|
203
|
+
details = {**siblings, **{k: v for k, v in err.items() if k not in ("type", "message")}}
|
|
204
|
+
elif isinstance(err, str):
|
|
205
|
+
# request-body validation: {"error": "Invalid request body.", "fields": {...}}
|
|
206
|
+
message = err
|
|
207
|
+
type_ = {413: "payload_too_large", 415: "unsupported_media_type"}.get(status, "bad_request")
|
|
208
|
+
details = siblings
|
|
209
|
+
body = text[:2000] if data is _NOT_JSON else data
|
|
210
|
+
|
|
211
|
+
if status == 429:
|
|
212
|
+
return RateLimitError(status, type_, message, details, body, _retry_after(headers.get("retry-after")))
|
|
213
|
+
cls = {
|
|
214
|
+
401: AuthenticationError,
|
|
215
|
+
403: PermissionDeniedError,
|
|
216
|
+
404: NotFoundError,
|
|
217
|
+
422: UnprocessableEntityError,
|
|
218
|
+
}.get(status, InternalServerError if status >= 500 else BadRequestError)
|
|
219
|
+
return cls(status, type_, message, details, body)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# --------------------------------------------------------------------------
|
|
223
|
+
# request builders, shared by the sync and async clients
|
|
224
|
+
# --------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
def _tracking(tracking_number: str) -> _Call:
|
|
227
|
+
return _Call("GET", "/tracking", {"num": _text(tracking_number, "tracking(): the tracking number")})
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _qc(url: Optional[str], platform: Optional[str], id: Optional[Union[str, int]]) -> _Call:
|
|
231
|
+
return _Call("GET", "/qc", _item_query(url, platform, id, "qc"))
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _lookup(url: Optional[str], platform: Optional[str], id: Optional[Union[str, int]], currency: Optional[str], agent: Optional[str]) -> _Call:
|
|
235
|
+
return _Call("GET", "/lookup", {**_item_query(url, platform, id, "lookup"), "currency": currency, "agent": agent})
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _convert(url: str, agents: Optional[Union[str, Sequence[str]]]) -> _Call:
|
|
239
|
+
query: Dict[str, Any] = {"url": _text(url, "convert(): the product link")}
|
|
240
|
+
if agents is not None:
|
|
241
|
+
names = [a.strip() for a in agents.split(",")] if isinstance(agents, str) else list(agents)
|
|
242
|
+
if not names or not all(isinstance(a, str) and a for a in names):
|
|
243
|
+
raise RepsApiError("convert(): agents must be a non-empty list of agent names, or None for all agents.")
|
|
244
|
+
query["agents"] = ",".join(names)
|
|
245
|
+
return _Call("GET", "/convert", query)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _shipping_estimate(
|
|
249
|
+
country: str,
|
|
250
|
+
weight: Optional[float],
|
|
251
|
+
dimensions: Optional[Mapping[str, object]],
|
|
252
|
+
urls: Optional[Union[str, Sequence[str]]],
|
|
253
|
+
currency: Optional[str],
|
|
254
|
+
battery: Optional[bool],
|
|
255
|
+
brands: Optional[bool],
|
|
256
|
+
liquids: Optional[bool],
|
|
257
|
+
) -> _Call:
|
|
258
|
+
body: Dict[str, Any] = {"country": _text(country, "shipping_estimate(): country")}
|
|
259
|
+
if weight is not None:
|
|
260
|
+
body["weight"] = weight
|
|
261
|
+
if dimensions is not None:
|
|
262
|
+
if not isinstance(dimensions, Mapping):
|
|
263
|
+
raise RepsApiError('shipping_estimate(): dimensions must be {"length": ..., "width": ..., "height": ...} in cm.')
|
|
264
|
+
for side in ("length", "width", "height"):
|
|
265
|
+
body[side] = dimensions.get(side)
|
|
266
|
+
link_list = [urls] if isinstance(urls, str) else list(urls or [])
|
|
267
|
+
if link_list:
|
|
268
|
+
body["urls"] = link_list
|
|
269
|
+
_flags(body, currency=currency, battery=battery, brands=brands, liquids=liquids)
|
|
270
|
+
return _Call("POST", "/shipping/estimate", body=body, default_timeout=SLOW_TIMEOUT if link_list else None)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _haul(
|
|
274
|
+
items: Sequence[HaulItemInput],
|
|
275
|
+
country: str,
|
|
276
|
+
currency: Optional[str],
|
|
277
|
+
battery: Optional[bool],
|
|
278
|
+
brands: Optional[bool],
|
|
279
|
+
liquids: Optional[bool],
|
|
280
|
+
) -> _Call:
|
|
281
|
+
if isinstance(items, (str, bytes)) or not isinstance(items, Sequence) or not items:
|
|
282
|
+
raise RepsApiError('haul() expects items=[{"url": ..., "quantity": ...}, ...].')
|
|
283
|
+
clean: List[Dict[str, Any]] = []
|
|
284
|
+
for i, item in enumerate(items):
|
|
285
|
+
if not isinstance(item, Mapping):
|
|
286
|
+
raise RepsApiError(f'haul(): items[{i}] must be {{"url": ..., "quantity": ...}}.')
|
|
287
|
+
entry: Dict[str, Any] = {"url": _text(item.get("url"), f"haul(): items[{i}]['url']")}
|
|
288
|
+
if item.get("quantity") is not None:
|
|
289
|
+
entry["quantity"] = item["quantity"]
|
|
290
|
+
clean.append(entry)
|
|
291
|
+
body: Dict[str, Any] = {"items": clean, "country": _text(country, "haul(): country")}
|
|
292
|
+
_flags(body, currency=currency, battery=battery, brands=brands, liquids=liquids)
|
|
293
|
+
return _Call("POST", "/haul", body=body, default_timeout=SLOW_TIMEOUT)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _health(days: Optional[int]) -> _Call:
|
|
297
|
+
return _Call(
|
|
298
|
+
"GET",
|
|
299
|
+
"/health",
|
|
300
|
+
{"days": days},
|
|
301
|
+
auth=False,
|
|
302
|
+
accept=lambda status, data: status == 503 and isinstance(data, dict) and isinstance(data.get("services"), dict),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# --------------------------------------------------------------------------
|
|
307
|
+
# shared client core
|
|
308
|
+
# --------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
class _BaseClient:
|
|
311
|
+
def __init__(
|
|
312
|
+
self,
|
|
313
|
+
api_key: Optional[str],
|
|
314
|
+
base_url: str,
|
|
315
|
+
timeout: Optional[float],
|
|
316
|
+
max_retries: int,
|
|
317
|
+
max_retry_wait: float,
|
|
318
|
+
) -> None:
|
|
319
|
+
self._api_key = _clean_key(api_key if api_key is not None else os.environ.get("REPSAPI_KEY"))
|
|
320
|
+
self.base_url = _clean_base_url(base_url)
|
|
321
|
+
self.timeout = None if timeout is None else _timeout(timeout)
|
|
322
|
+
self.max_retries = int(_non_negative(max_retries, "max_retries"))
|
|
323
|
+
self.max_retry_wait = _non_negative(max_retry_wait, "max_retry_wait")
|
|
324
|
+
self._rate_limit: Optional[RateLimitInfo] = None
|
|
325
|
+
|
|
326
|
+
def __repr__(self) -> str:
|
|
327
|
+
# never print the key
|
|
328
|
+
return f"{type(self).__name__}(base_url={self.base_url!r}, api_key={'***' if self._api_key else None})"
|
|
329
|
+
|
|
330
|
+
@property
|
|
331
|
+
def rate_limit(self) -> Optional[RateLimitInfo]:
|
|
332
|
+
"""Per-minute limit from the last response that carried one (None on plans without it)."""
|
|
333
|
+
return self._rate_limit
|
|
334
|
+
|
|
335
|
+
def _prepare(self, call: _Call, timeout: Optional[float], max_retries: Optional[int]) -> Dict[str, Any]:
|
|
336
|
+
if call.auth and not self._api_key:
|
|
337
|
+
raise RepsApiError("Missing API key. Pass it to RepsApi(api_key=...) or set the REPSAPI_KEY environment variable.")
|
|
338
|
+
headers = {"Accept": "application/json", "User-Agent": _USER_AGENT}
|
|
339
|
+
if call.auth:
|
|
340
|
+
headers["Authorization"] = f"Bearer {self._api_key}"
|
|
341
|
+
content: Optional[bytes] = None
|
|
342
|
+
if call.body is not None:
|
|
343
|
+
headers["Content-Type"] = "application/json"
|
|
344
|
+
try:
|
|
345
|
+
content = json.dumps(call.body, allow_nan=False, ensure_ascii=False).encode("utf-8")
|
|
346
|
+
except (TypeError, ValueError) as exc:
|
|
347
|
+
raise RepsApiError(f"The request can't be sent as JSON: {exc}") from None
|
|
348
|
+
params = {k: str(v) for k, v in (call.query or {}).items() if v is not None and v != ""}
|
|
349
|
+
seconds = _timeout(timeout) if timeout is not None else (self.timeout or call.default_timeout or DEFAULT_TIMEOUT)
|
|
350
|
+
retries = self.max_retries if max_retries is None else int(_non_negative(max_retries, "max_retries"))
|
|
351
|
+
return {
|
|
352
|
+
"request": {
|
|
353
|
+
"method": call.method,
|
|
354
|
+
"url": self.base_url + call.path,
|
|
355
|
+
"params": params,
|
|
356
|
+
"headers": headers,
|
|
357
|
+
"content": content,
|
|
358
|
+
"timeout": httpx.Timeout(seconds),
|
|
359
|
+
# never follow redirects: the Authorization header must not travel to another host
|
|
360
|
+
"follow_redirects": False,
|
|
361
|
+
},
|
|
362
|
+
"seconds": seconds,
|
|
363
|
+
"retries": retries,
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
def _handle(self, response: httpx.Response, call: _Call) -> Tuple[Optional[Dict[str, Any]], Optional[APIError]]:
|
|
367
|
+
self._remember_rate_limit(response.headers)
|
|
368
|
+
status = response.status_code
|
|
369
|
+
if 300 <= status < 400:
|
|
370
|
+
return None, APIError(
|
|
371
|
+
status,
|
|
372
|
+
"unexpected_redirect",
|
|
373
|
+
"The API answered with a redirect, which this client never follows so your key stays on repsapi.pl. Check base_url.",
|
|
374
|
+
)
|
|
375
|
+
text = response.text
|
|
376
|
+
try:
|
|
377
|
+
data: Any = json.loads(text) if text else _NOT_JSON
|
|
378
|
+
except ValueError:
|
|
379
|
+
data = _NOT_JSON
|
|
380
|
+
if 200 <= status < 300 or (call.accept is not None and call.accept(status, data)):
|
|
381
|
+
if not isinstance(data, dict):
|
|
382
|
+
return None, APIError(status, "invalid_response", "The API sent a response that isn't JSON.", {}, text[:2000])
|
|
383
|
+
return data, None
|
|
384
|
+
return None, _to_error(status, data, text, response.headers)
|
|
385
|
+
|
|
386
|
+
def _retry_wait(self, error: APIError, attempt: int) -> Optional[float]:
|
|
387
|
+
if isinstance(error, RateLimitError):
|
|
388
|
+
if error.retry_after is None:
|
|
389
|
+
return _backoff(attempt)
|
|
390
|
+
return error.retry_after + random.random() * 0.25 if error.retry_after <= self.max_retry_wait else None
|
|
391
|
+
if error.status in _RETRY_STATUSES:
|
|
392
|
+
return _backoff(attempt)
|
|
393
|
+
return None
|
|
394
|
+
|
|
395
|
+
def _remember_rate_limit(self, headers: httpx.Headers) -> None:
|
|
396
|
+
try:
|
|
397
|
+
limit = int(headers["x-ratelimit-limit"])
|
|
398
|
+
remaining = int(headers["x-ratelimit-remaining"])
|
|
399
|
+
reset = datetime.fromtimestamp(int(headers["x-ratelimit-reset"]), tz=timezone.utc)
|
|
400
|
+
except (KeyError, ValueError, OverflowError, OSError):
|
|
401
|
+
return
|
|
402
|
+
self._rate_limit = RateLimitInfo(limit, remaining, reset)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
# --------------------------------------------------------------------------
|
|
406
|
+
# sync client
|
|
407
|
+
# --------------------------------------------------------------------------
|
|
408
|
+
|
|
409
|
+
class RepsApi(_BaseClient):
|
|
410
|
+
"""RepsApi.pl client.
|
|
411
|
+
|
|
412
|
+
>>> api = RepsApi() # reads REPSAPI_KEY
|
|
413
|
+
>>> parcel = api.tracking("YT2530998211074512")
|
|
414
|
+
"""
|
|
415
|
+
|
|
416
|
+
def __init__(
|
|
417
|
+
self,
|
|
418
|
+
api_key: Optional[str] = None,
|
|
419
|
+
*,
|
|
420
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
421
|
+
timeout: Optional[float] = None,
|
|
422
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
423
|
+
max_retry_wait: float = DEFAULT_MAX_RETRY_WAIT,
|
|
424
|
+
http_client: Optional[httpx.Client] = None,
|
|
425
|
+
) -> None:
|
|
426
|
+
"""
|
|
427
|
+
:param api_key: your key (rk_live_...); defaults to the REPSAPI_KEY environment variable
|
|
428
|
+
:param base_url: defaults to https://repsapi.pl/v1
|
|
429
|
+
:param timeout: seconds per attempt (default 60; hauls 180)
|
|
430
|
+
:param max_retries: retries after a network error, a 5xx or a short 429 (default 2)
|
|
431
|
+
:param max_retry_wait: longest Retry-After (seconds) a 429 may ask for before we raise instead of waiting
|
|
432
|
+
:param http_client: your own httpx.Client (proxies, custom TLS); you close it yourself
|
|
433
|
+
"""
|
|
434
|
+
super().__init__(api_key, base_url, timeout, max_retries, max_retry_wait)
|
|
435
|
+
self._owns_client = http_client is None
|
|
436
|
+
self._client = http_client if http_client is not None else httpx.Client()
|
|
437
|
+
|
|
438
|
+
def close(self) -> None:
|
|
439
|
+
if self._owns_client:
|
|
440
|
+
self._client.close()
|
|
441
|
+
|
|
442
|
+
def __enter__(self) -> "RepsApi":
|
|
443
|
+
return self
|
|
444
|
+
|
|
445
|
+
def __exit__(self, *exc: Any) -> None:
|
|
446
|
+
self.close()
|
|
447
|
+
|
|
448
|
+
def _request(self, call: _Call, timeout: Optional[float], max_retries: Optional[int]) -> Dict[str, Any]:
|
|
449
|
+
prepared = self._prepare(call, timeout, max_retries)
|
|
450
|
+
attempt = 0
|
|
451
|
+
while True:
|
|
452
|
+
try:
|
|
453
|
+
response = self._client.request(**prepared["request"])
|
|
454
|
+
except httpx.TimeoutException as exc:
|
|
455
|
+
if attempt >= prepared["retries"]:
|
|
456
|
+
raise APITimeoutError(f"No response from the API within {prepared['seconds']:g} s.") from exc
|
|
457
|
+
except httpx.TransportError as exc:
|
|
458
|
+
if attempt >= prepared["retries"]:
|
|
459
|
+
raise APIConnectionError("Could not reach the API. Check your connection.") from exc
|
|
460
|
+
else:
|
|
461
|
+
data, error = self._handle(response, call)
|
|
462
|
+
if error is None:
|
|
463
|
+
return cast(Dict[str, Any], data)
|
|
464
|
+
wait = self._retry_wait(error, attempt) if attempt < prepared["retries"] else None
|
|
465
|
+
if wait is None:
|
|
466
|
+
raise error
|
|
467
|
+
time.sleep(wait)
|
|
468
|
+
attempt += 1
|
|
469
|
+
continue
|
|
470
|
+
time.sleep(_backoff(attempt))
|
|
471
|
+
attempt += 1
|
|
472
|
+
|
|
473
|
+
# ------------------------------ endpoints ------------------------------
|
|
474
|
+
|
|
475
|
+
def tracking(self, tracking_number: str, *, timeout: Optional[float] = None, max_retries: Optional[int] = None) -> TrackingResponse:
|
|
476
|
+
"""Where is my parcel? Merged events from forwarders and last-mile carriers."""
|
|
477
|
+
return cast(TrackingResponse, self._request(_tracking(tracking_number), timeout, max_retries))
|
|
478
|
+
|
|
479
|
+
def qc(
|
|
480
|
+
self,
|
|
481
|
+
url: Optional[str] = None,
|
|
482
|
+
*,
|
|
483
|
+
platform: Optional[Platform] = None,
|
|
484
|
+
id: Optional[Union[str, int]] = None,
|
|
485
|
+
timeout: Optional[float] = None,
|
|
486
|
+
max_retries: Optional[int] = None,
|
|
487
|
+
) -> QcResponse:
|
|
488
|
+
"""Warehouse QC photos for a product: a link, or platform + id."""
|
|
489
|
+
return cast(QcResponse, self._request(_qc(url, platform, id), timeout, max_retries))
|
|
490
|
+
|
|
491
|
+
def lookup(
|
|
492
|
+
self,
|
|
493
|
+
url: Optional[str] = None,
|
|
494
|
+
*,
|
|
495
|
+
platform: Optional[Platform] = None,
|
|
496
|
+
id: Optional[Union[str, int]] = None,
|
|
497
|
+
currency: Optional[str] = None,
|
|
498
|
+
agent: Optional[Agent] = None,
|
|
499
|
+
timeout: Optional[float] = None,
|
|
500
|
+
max_retries: Optional[int] = None,
|
|
501
|
+
) -> LookupResponse:
|
|
502
|
+
"""Everything about a product. ``currency`` adds prices in e.g. PLN, EUR or USD."""
|
|
503
|
+
return cast(LookupResponse, self._request(_lookup(url, platform, id, currency, agent), timeout, max_retries))
|
|
504
|
+
|
|
505
|
+
def convert(
|
|
506
|
+
self,
|
|
507
|
+
url: str,
|
|
508
|
+
*,
|
|
509
|
+
agents: Optional[Sequence[Agent]] = None,
|
|
510
|
+
timeout: Optional[float] = None,
|
|
511
|
+
max_retries: Optional[int] = None,
|
|
512
|
+
) -> ConvertResponse:
|
|
513
|
+
"""The same product as a link on every agent (or only ``agents``)."""
|
|
514
|
+
return cast(ConvertResponse, self._request(_convert(url, agents), timeout, max_retries))
|
|
515
|
+
|
|
516
|
+
def shipping_estimate(
|
|
517
|
+
self,
|
|
518
|
+
country: str,
|
|
519
|
+
*,
|
|
520
|
+
weight: Optional[float] = None,
|
|
521
|
+
dimensions: Optional[Dimensions] = None,
|
|
522
|
+
urls: Optional[Sequence[str]] = None,
|
|
523
|
+
currency: Optional[str] = None,
|
|
524
|
+
battery: Optional[bool] = None,
|
|
525
|
+
brands: Optional[bool] = None,
|
|
526
|
+
liquids: Optional[bool] = None,
|
|
527
|
+
timeout: Optional[float] = None,
|
|
528
|
+
max_retries: Optional[int] = None,
|
|
529
|
+
) -> ShippingEstimateResponse:
|
|
530
|
+
"""Shipping lines for a parcel: ``weight`` in grams, or up to 10 product ``urls``."""
|
|
531
|
+
call = _shipping_estimate(country, weight, dimensions, urls, currency, battery, brands, liquids)
|
|
532
|
+
return cast(ShippingEstimateResponse, self._request(call, timeout, max_retries))
|
|
533
|
+
|
|
534
|
+
def shipping_countries(self, *, timeout: Optional[float] = None, max_retries: Optional[int] = None) -> ShippingCountriesResponse:
|
|
535
|
+
"""Destination countries for shipping estimates and hauls (no key needed)."""
|
|
536
|
+
call = _Call("GET", "/shipping/countries", auth=False)
|
|
537
|
+
return cast(ShippingCountriesResponse, self._request(call, timeout, max_retries))
|
|
538
|
+
|
|
539
|
+
def haul(
|
|
540
|
+
self,
|
|
541
|
+
items: Sequence[HaulItemInput],
|
|
542
|
+
country: str,
|
|
543
|
+
*,
|
|
544
|
+
currency: Optional[str] = None,
|
|
545
|
+
battery: Optional[bool] = None,
|
|
546
|
+
brands: Optional[bool] = None,
|
|
547
|
+
liquids: Optional[bool] = None,
|
|
548
|
+
timeout: Optional[float] = None,
|
|
549
|
+
max_retries: Optional[int] = None,
|
|
550
|
+
) -> HaulResponse:
|
|
551
|
+
"""A whole haul: per-item prices and weights, totals and every shipping line."""
|
|
552
|
+
call = _haul(items, country, currency, battery, brands, liquids)
|
|
553
|
+
return cast(HaulResponse, self._request(call, timeout, max_retries))
|
|
554
|
+
|
|
555
|
+
def usage(self, *, timeout: Optional[float] = None, max_retries: Optional[int] = None) -> UsageResponse:
|
|
556
|
+
"""Your plan, today's usage and the remaining quota (doesn't count toward it)."""
|
|
557
|
+
return cast(UsageResponse, self._request(_Call("GET", "/usage"), timeout, max_retries))
|
|
558
|
+
|
|
559
|
+
def rates(self, base: str = "CNY", *, timeout: Optional[float] = None, max_retries: Optional[int] = None) -> RatesResponse:
|
|
560
|
+
"""Exchange rates (no key needed)."""
|
|
561
|
+
call = _Call("GET", "/rates", {"base": _text(base, "rates(): base")}, auth=False)
|
|
562
|
+
return cast(RatesResponse, self._request(call, timeout, max_retries))
|
|
563
|
+
|
|
564
|
+
def health(self, *, days: Optional[int] = None, timeout: Optional[float] = None, max_retries: Optional[int] = None) -> HealthResponse:
|
|
565
|
+
"""API status (no key needed). Also returned, not raised, while the API is down."""
|
|
566
|
+
return cast(HealthResponse, self._request(_health(days), timeout, max_retries))
|
|
567
|
+
|
|
568
|
+
@overload
|
|
569
|
+
def changelog(
|
|
570
|
+
self, *, limit: Optional[int] = None, lang: None = None, timeout: Optional[float] = None, max_retries: Optional[int] = None
|
|
571
|
+
) -> ChangelogResponse: ...
|
|
572
|
+
|
|
573
|
+
@overload
|
|
574
|
+
def changelog(
|
|
575
|
+
self, *, limit: Optional[int] = None, lang: Literal["en", "pl"], timeout: Optional[float] = None, max_retries: Optional[int] = None
|
|
576
|
+
) -> LocalizedChangelogResponse: ...
|
|
577
|
+
|
|
578
|
+
def changelog(
|
|
579
|
+
self,
|
|
580
|
+
*,
|
|
581
|
+
limit: Optional[int] = None,
|
|
582
|
+
lang: Optional[Literal["en", "pl"]] = None,
|
|
583
|
+
timeout: Optional[float] = None,
|
|
584
|
+
max_retries: Optional[int] = None,
|
|
585
|
+
) -> Union[ChangelogResponse, LocalizedChangelogResponse]:
|
|
586
|
+
"""Release notes (no key needed)."""
|
|
587
|
+
call = _Call("GET", "/changelog", {"limit": limit, "lang": lang}, auth=False)
|
|
588
|
+
return cast(ChangelogResponse, self._request(call, timeout, max_retries))
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
# --------------------------------------------------------------------------
|
|
592
|
+
# async client
|
|
593
|
+
# --------------------------------------------------------------------------
|
|
594
|
+
|
|
595
|
+
class AsyncRepsApi(_BaseClient):
|
|
596
|
+
"""Async RepsApi.pl client (for discord.py, FastAPI, aiohttp apps ...).
|
|
597
|
+
|
|
598
|
+
>>> async with AsyncRepsApi() as api:
|
|
599
|
+
... parcel = await api.tracking("YT2530998211074512")
|
|
600
|
+
"""
|
|
601
|
+
|
|
602
|
+
def __init__(
|
|
603
|
+
self,
|
|
604
|
+
api_key: Optional[str] = None,
|
|
605
|
+
*,
|
|
606
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
607
|
+
timeout: Optional[float] = None,
|
|
608
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
609
|
+
max_retry_wait: float = DEFAULT_MAX_RETRY_WAIT,
|
|
610
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
611
|
+
) -> None:
|
|
612
|
+
"""Same options as :class:`RepsApi`; ``http_client`` is an httpx.AsyncClient."""
|
|
613
|
+
super().__init__(api_key, base_url, timeout, max_retries, max_retry_wait)
|
|
614
|
+
self._owns_client = http_client is None
|
|
615
|
+
self._client = http_client if http_client is not None else httpx.AsyncClient()
|
|
616
|
+
|
|
617
|
+
async def close(self) -> None:
|
|
618
|
+
if self._owns_client:
|
|
619
|
+
await self._client.aclose()
|
|
620
|
+
|
|
621
|
+
async def __aenter__(self) -> "AsyncRepsApi":
|
|
622
|
+
return self
|
|
623
|
+
|
|
624
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
625
|
+
await self.close()
|
|
626
|
+
|
|
627
|
+
async def _request(self, call: _Call, timeout: Optional[float], max_retries: Optional[int]) -> Dict[str, Any]:
|
|
628
|
+
prepared = self._prepare(call, timeout, max_retries)
|
|
629
|
+
attempt = 0
|
|
630
|
+
while True:
|
|
631
|
+
try:
|
|
632
|
+
response = await self._client.request(**prepared["request"])
|
|
633
|
+
except httpx.TimeoutException as exc:
|
|
634
|
+
if attempt >= prepared["retries"]:
|
|
635
|
+
raise APITimeoutError(f"No response from the API within {prepared['seconds']:g} s.") from exc
|
|
636
|
+
except httpx.TransportError as exc:
|
|
637
|
+
if attempt >= prepared["retries"]:
|
|
638
|
+
raise APIConnectionError("Could not reach the API. Check your connection.") from exc
|
|
639
|
+
else:
|
|
640
|
+
data, error = self._handle(response, call)
|
|
641
|
+
if error is None:
|
|
642
|
+
return cast(Dict[str, Any], data)
|
|
643
|
+
wait = self._retry_wait(error, attempt) if attempt < prepared["retries"] else None
|
|
644
|
+
if wait is None:
|
|
645
|
+
raise error
|
|
646
|
+
await asyncio.sleep(wait)
|
|
647
|
+
attempt += 1
|
|
648
|
+
continue
|
|
649
|
+
await asyncio.sleep(_backoff(attempt))
|
|
650
|
+
attempt += 1
|
|
651
|
+
|
|
652
|
+
# ------------------------------ endpoints ------------------------------
|
|
653
|
+
|
|
654
|
+
async def tracking(self, tracking_number: str, *, timeout: Optional[float] = None, max_retries: Optional[int] = None) -> TrackingResponse:
|
|
655
|
+
"""Where is my parcel? Merged events from forwarders and last-mile carriers."""
|
|
656
|
+
return cast(TrackingResponse, await self._request(_tracking(tracking_number), timeout, max_retries))
|
|
657
|
+
|
|
658
|
+
async def qc(
|
|
659
|
+
self,
|
|
660
|
+
url: Optional[str] = None,
|
|
661
|
+
*,
|
|
662
|
+
platform: Optional[Platform] = None,
|
|
663
|
+
id: Optional[Union[str, int]] = None,
|
|
664
|
+
timeout: Optional[float] = None,
|
|
665
|
+
max_retries: Optional[int] = None,
|
|
666
|
+
) -> QcResponse:
|
|
667
|
+
"""Warehouse QC photos for a product: a link, or platform + id."""
|
|
668
|
+
return cast(QcResponse, await self._request(_qc(url, platform, id), timeout, max_retries))
|
|
669
|
+
|
|
670
|
+
async def lookup(
|
|
671
|
+
self,
|
|
672
|
+
url: Optional[str] = None,
|
|
673
|
+
*,
|
|
674
|
+
platform: Optional[Platform] = None,
|
|
675
|
+
id: Optional[Union[str, int]] = None,
|
|
676
|
+
currency: Optional[str] = None,
|
|
677
|
+
agent: Optional[Agent] = None,
|
|
678
|
+
timeout: Optional[float] = None,
|
|
679
|
+
max_retries: Optional[int] = None,
|
|
680
|
+
) -> LookupResponse:
|
|
681
|
+
"""Everything about a product. ``currency`` adds prices in e.g. PLN, EUR or USD."""
|
|
682
|
+
return cast(LookupResponse, await self._request(_lookup(url, platform, id, currency, agent), timeout, max_retries))
|
|
683
|
+
|
|
684
|
+
async def convert(
|
|
685
|
+
self,
|
|
686
|
+
url: str,
|
|
687
|
+
*,
|
|
688
|
+
agents: Optional[Sequence[Agent]] = None,
|
|
689
|
+
timeout: Optional[float] = None,
|
|
690
|
+
max_retries: Optional[int] = None,
|
|
691
|
+
) -> ConvertResponse:
|
|
692
|
+
"""The same product as a link on every agent (or only ``agents``)."""
|
|
693
|
+
return cast(ConvertResponse, await self._request(_convert(url, agents), timeout, max_retries))
|
|
694
|
+
|
|
695
|
+
async def shipping_estimate(
|
|
696
|
+
self,
|
|
697
|
+
country: str,
|
|
698
|
+
*,
|
|
699
|
+
weight: Optional[float] = None,
|
|
700
|
+
dimensions: Optional[Dimensions] = None,
|
|
701
|
+
urls: Optional[Sequence[str]] = None,
|
|
702
|
+
currency: Optional[str] = None,
|
|
703
|
+
battery: Optional[bool] = None,
|
|
704
|
+
brands: Optional[bool] = None,
|
|
705
|
+
liquids: Optional[bool] = None,
|
|
706
|
+
timeout: Optional[float] = None,
|
|
707
|
+
max_retries: Optional[int] = None,
|
|
708
|
+
) -> ShippingEstimateResponse:
|
|
709
|
+
"""Shipping lines for a parcel: ``weight`` in grams, or up to 10 product ``urls``."""
|
|
710
|
+
call = _shipping_estimate(country, weight, dimensions, urls, currency, battery, brands, liquids)
|
|
711
|
+
return cast(ShippingEstimateResponse, await self._request(call, timeout, max_retries))
|
|
712
|
+
|
|
713
|
+
async def shipping_countries(self, *, timeout: Optional[float] = None, max_retries: Optional[int] = None) -> ShippingCountriesResponse:
|
|
714
|
+
"""Destination countries for shipping estimates and hauls (no key needed)."""
|
|
715
|
+
call = _Call("GET", "/shipping/countries", auth=False)
|
|
716
|
+
return cast(ShippingCountriesResponse, await self._request(call, timeout, max_retries))
|
|
717
|
+
|
|
718
|
+
async def haul(
|
|
719
|
+
self,
|
|
720
|
+
items: Sequence[HaulItemInput],
|
|
721
|
+
country: str,
|
|
722
|
+
*,
|
|
723
|
+
currency: Optional[str] = None,
|
|
724
|
+
battery: Optional[bool] = None,
|
|
725
|
+
brands: Optional[bool] = None,
|
|
726
|
+
liquids: Optional[bool] = None,
|
|
727
|
+
timeout: Optional[float] = None,
|
|
728
|
+
max_retries: Optional[int] = None,
|
|
729
|
+
) -> HaulResponse:
|
|
730
|
+
"""A whole haul: per-item prices and weights, totals and every shipping line."""
|
|
731
|
+
call = _haul(items, country, currency, battery, brands, liquids)
|
|
732
|
+
return cast(HaulResponse, await self._request(call, timeout, max_retries))
|
|
733
|
+
|
|
734
|
+
async def usage(self, *, timeout: Optional[float] = None, max_retries: Optional[int] = None) -> UsageResponse:
|
|
735
|
+
"""Your plan, today's usage and the remaining quota (doesn't count toward it)."""
|
|
736
|
+
return cast(UsageResponse, await self._request(_Call("GET", "/usage"), timeout, max_retries))
|
|
737
|
+
|
|
738
|
+
async def rates(self, base: str = "CNY", *, timeout: Optional[float] = None, max_retries: Optional[int] = None) -> RatesResponse:
|
|
739
|
+
"""Exchange rates (no key needed)."""
|
|
740
|
+
call = _Call("GET", "/rates", {"base": _text(base, "rates(): base")}, auth=False)
|
|
741
|
+
return cast(RatesResponse, await self._request(call, timeout, max_retries))
|
|
742
|
+
|
|
743
|
+
async def health(
|
|
744
|
+
self, *, days: Optional[int] = None, timeout: Optional[float] = None, max_retries: Optional[int] = None
|
|
745
|
+
) -> HealthResponse:
|
|
746
|
+
"""API status (no key needed). Also returned, not raised, while the API is down."""
|
|
747
|
+
return cast(HealthResponse, await self._request(_health(days), timeout, max_retries))
|
|
748
|
+
|
|
749
|
+
@overload
|
|
750
|
+
async def changelog(
|
|
751
|
+
self, *, limit: Optional[int] = None, lang: None = None, timeout: Optional[float] = None, max_retries: Optional[int] = None
|
|
752
|
+
) -> ChangelogResponse: ...
|
|
753
|
+
|
|
754
|
+
@overload
|
|
755
|
+
async def changelog(
|
|
756
|
+
self, *, limit: Optional[int] = None, lang: Literal["en", "pl"], timeout: Optional[float] = None, max_retries: Optional[int] = None
|
|
757
|
+
) -> LocalizedChangelogResponse: ...
|
|
758
|
+
|
|
759
|
+
async def changelog(
|
|
760
|
+
self,
|
|
761
|
+
*,
|
|
762
|
+
limit: Optional[int] = None,
|
|
763
|
+
lang: Optional[Literal["en", "pl"]] = None,
|
|
764
|
+
timeout: Optional[float] = None,
|
|
765
|
+
max_retries: Optional[int] = None,
|
|
766
|
+
) -> Union[ChangelogResponse, LocalizedChangelogResponse]:
|
|
767
|
+
"""Release notes (no key needed)."""
|
|
768
|
+
call = _Call("GET", "/changelog", {"limit": limit, "lang": lang}, auth=False)
|
|
769
|
+
return cast(ChangelogResponse, await self._request(call, timeout, max_retries))
|