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