reefapi 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.
- reefapi/__init__.py +29 -0
- reefapi/_client.py +473 -0
- reefapi/_engines.py +12195 -0
- reefapi/_errors.py +58 -0
- reefapi/_types.py +57 -0
- reefapi/_version.py +1 -0
- reefapi/py.typed +0 -0
- reefapi-0.1.0.dist-info/METADATA +942 -0
- reefapi-0.1.0.dist-info/RECORD +11 -0
- reefapi-0.1.0.dist-info/WHEEL +4 -0
- reefapi-0.1.0.dist-info/licenses/LICENSE +21 -0
reefapi/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Official Python SDK for ReefAPI: one API key for 600+ live web-data APIs.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
from reefapi import ReefAPI
|
|
6
|
+
|
|
7
|
+
client = ReefAPI() # reads the REEFAPI_KEY environment variable
|
|
8
|
+
listings = client.ebay.search(query="iphone 13 pro")
|
|
9
|
+
product = client.call("amazon", "product/detail", asin="B0DGHMNQ5Z")
|
|
10
|
+
|
|
11
|
+
Docs: https://reefapi.com/docs
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from ._client import DEFAULT_BASE_URL, AsyncReefAPI, ReefAPI
|
|
15
|
+
from ._errors import ReefAPIError
|
|
16
|
+
from ._types import ActionInfo, EngineInfo, ParamSpec, ReefResponse
|
|
17
|
+
from ._version import __version__
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"ReefAPI",
|
|
21
|
+
"AsyncReefAPI",
|
|
22
|
+
"ReefAPIError",
|
|
23
|
+
"ReefResponse",
|
|
24
|
+
"EngineInfo",
|
|
25
|
+
"ActionInfo",
|
|
26
|
+
"ParamSpec",
|
|
27
|
+
"DEFAULT_BASE_URL",
|
|
28
|
+
"__version__",
|
|
29
|
+
]
|
reefapi/_client.py
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import copy
|
|
5
|
+
import os
|
|
6
|
+
import random
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from collections import deque
|
|
10
|
+
from typing import Any, Callable, Deque, Dict, List, Mapping, Optional, Tuple, TypeVar, Union, cast
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from ._engines import AsyncEngines, SyncEngines
|
|
15
|
+
from ._errors import ReefAPIError
|
|
16
|
+
from ._types import EngineInfo, ReefResponse
|
|
17
|
+
from ._version import __version__
|
|
18
|
+
|
|
19
|
+
__all__ = ["ReefAPI", "AsyncReefAPI", "DEFAULT_BASE_URL"]
|
|
20
|
+
|
|
21
|
+
DEFAULT_BASE_URL = "https://api.reefapi.com"
|
|
22
|
+
DEFAULT_TIMEOUT = 180.0
|
|
23
|
+
DEFAULT_CONNECT_TIMEOUT = 10.0
|
|
24
|
+
DEFAULT_MAX_RETRIES = 2
|
|
25
|
+
DEFAULT_RATE_LIMIT: Tuple[int, float] = (30, 3.0) # gateway limit: 30 requests / 3 s per key
|
|
26
|
+
DEFAULT_MAX_RETRY_WAIT = 60.0
|
|
27
|
+
_BACKOFF_BASE = 0.5
|
|
28
|
+
_BACKOFF_CAP = 8.0
|
|
29
|
+
ENV_KEY = "REEFAPI_KEY"
|
|
30
|
+
ENV_BASE_URL = "REEFAPI_BASE_URL"
|
|
31
|
+
|
|
32
|
+
_T = TypeVar("_T")
|
|
33
|
+
RateLimit = Optional[Tuple[int, float]]
|
|
34
|
+
|
|
35
|
+
# The request never left the machine for these, so retrying a POST cannot double-charge.
|
|
36
|
+
_NOT_SENT = (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _now() -> float: # patched in tests
|
|
40
|
+
return time.monotonic()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _sleep(seconds: float) -> None: # patched in tests
|
|
44
|
+
time.sleep(seconds)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def _async_sleep(seconds: float) -> None: # patched in tests
|
|
48
|
+
await asyncio.sleep(seconds)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _RateLimiter:
|
|
52
|
+
"""Client-side sliding window so a burst of calls stays inside the gateway's per-key limit."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, requests: int, window: float) -> None:
|
|
55
|
+
if requests < 1 or window <= 0:
|
|
56
|
+
raise ValueError("rate_limit must be (requests >= 1, window_seconds > 0)")
|
|
57
|
+
self.requests = requests
|
|
58
|
+
self.window = window
|
|
59
|
+
self._stamps: Deque[float] = deque()
|
|
60
|
+
self._lock = threading.Lock()
|
|
61
|
+
|
|
62
|
+
def reserve(self, now: float) -> float:
|
|
63
|
+
"""Take a slot if one is free and return 0, else return how long to wait."""
|
|
64
|
+
with self._lock:
|
|
65
|
+
while self._stamps and self._stamps[0] <= now - self.window:
|
|
66
|
+
self._stamps.popleft()
|
|
67
|
+
if len(self._stamps) < self.requests:
|
|
68
|
+
self._stamps.append(now)
|
|
69
|
+
return 0.0
|
|
70
|
+
return max(self._stamps[0] + self.window - now, 0.001)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _parse_retry_after(headers: Mapping[str, str]) -> Optional[float]:
|
|
74
|
+
raw = headers.get("retry-after")
|
|
75
|
+
if raw is None:
|
|
76
|
+
return None
|
|
77
|
+
try:
|
|
78
|
+
return max(float(raw.strip()), 0.0)
|
|
79
|
+
except ValueError:
|
|
80
|
+
return None # HTTP-date form: fall back to backoff
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _clean_params(params: Optional[Mapping[str, Any]], extra: Mapping[str, Any]) -> Dict[str, Any]:
|
|
84
|
+
merged: Dict[str, Any] = {}
|
|
85
|
+
if params:
|
|
86
|
+
merged.update(params)
|
|
87
|
+
merged.update(extra)
|
|
88
|
+
return {k: v for k, v in merged.items() if v is not None}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _envelope_error(resp: httpx.Response, body: Any) -> ReefAPIError:
|
|
92
|
+
status = resp.status_code
|
|
93
|
+
default_retryable = status == 429 or status >= 500
|
|
94
|
+
if isinstance(body, dict):
|
|
95
|
+
err_raw, meta_raw = body.get("error"), body.get("meta")
|
|
96
|
+
err: Dict[str, Any] = err_raw if isinstance(err_raw, dict) else {}
|
|
97
|
+
meta: Dict[str, Any] = meta_raw if isinstance(meta_raw, dict) else {}
|
|
98
|
+
code = str(err.get("code") or f"HTTP_{status}")
|
|
99
|
+
message = str(err.get("message") or body.get("detail") or f"HTTP {status}")
|
|
100
|
+
retryable = err.get("retryable")
|
|
101
|
+
return ReefAPIError(
|
|
102
|
+
code,
|
|
103
|
+
message,
|
|
104
|
+
bool(default_retryable if retryable is None else retryable),
|
|
105
|
+
status,
|
|
106
|
+
request_id=meta.get("request_id"),
|
|
107
|
+
meta=meta,
|
|
108
|
+
details=err.get("details"),
|
|
109
|
+
)
|
|
110
|
+
snippet = resp.text[:200].strip() if resp.text else ""
|
|
111
|
+
return ReefAPIError(
|
|
112
|
+
f"HTTP_{status}" if status >= 400 else "INVALID_RESPONSE",
|
|
113
|
+
f"Unexpected non-JSON response (HTTP {status}){': ' + snippet if snippet else ''}",
|
|
114
|
+
default_retryable,
|
|
115
|
+
status,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _parse_envelope(resp: httpx.Response) -> ReefResponse:
|
|
120
|
+
try:
|
|
121
|
+
body = resp.json()
|
|
122
|
+
except ValueError:
|
|
123
|
+
body = None
|
|
124
|
+
if isinstance(body, dict) and body.get("ok") is True and resp.status_code < 400:
|
|
125
|
+
meta_raw = body.get("meta")
|
|
126
|
+
meta: Dict[str, Any] = meta_raw if isinstance(meta_raw, dict) else {}
|
|
127
|
+
return ReefResponse(data=body.get("data"), meta=meta, status=resp.status_code, headers=resp.headers)
|
|
128
|
+
raise _envelope_error(resp, body)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _parse_plain(resp: httpx.Response) -> Any:
|
|
132
|
+
try:
|
|
133
|
+
body = resp.json()
|
|
134
|
+
except ValueError:
|
|
135
|
+
body = None
|
|
136
|
+
if resp.status_code < 400 and body is not None:
|
|
137
|
+
return body
|
|
138
|
+
raise _envelope_error(resp, body)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _transport_error(exc: Exception) -> ReefAPIError:
|
|
142
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
143
|
+
return ReefAPIError("TIMEOUT", f"Request timed out: {exc!r}", True, None)
|
|
144
|
+
return ReefAPIError("NETWORK_ERROR", f"Network error: {exc!r}", True, None)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class _BaseClient:
|
|
148
|
+
def __init__(
|
|
149
|
+
self,
|
|
150
|
+
api_key: Optional[str],
|
|
151
|
+
base_url: Optional[str],
|
|
152
|
+
timeout: Union[float, httpx.Timeout],
|
|
153
|
+
max_retries: int,
|
|
154
|
+
rate_limit: RateLimit,
|
|
155
|
+
max_retry_wait: float,
|
|
156
|
+
default_headers: Optional[Mapping[str, str]],
|
|
157
|
+
) -> None:
|
|
158
|
+
self.api_key = api_key if api_key is not None else os.environ.get(ENV_KEY)
|
|
159
|
+
self.base_url = (base_url or os.environ.get(ENV_BASE_URL) or DEFAULT_BASE_URL).rstrip("/")
|
|
160
|
+
self.timeout = timeout
|
|
161
|
+
self.max_retries = max(int(max_retries), 0)
|
|
162
|
+
self.max_retry_wait = max_retry_wait
|
|
163
|
+
self._limiter: Optional[_RateLimiter] = _RateLimiter(*rate_limit) if rate_limit else None
|
|
164
|
+
self._default_headers = dict(default_headers or {})
|
|
165
|
+
self._owns_http = True
|
|
166
|
+
|
|
167
|
+
# -- helpers ---------------------------------------------------------------------------------
|
|
168
|
+
def __repr__(self) -> str: # never show the key
|
|
169
|
+
return f"{type(self).__name__}(base_url={self.base_url!r})"
|
|
170
|
+
|
|
171
|
+
def _timeout(self) -> httpx.Timeout:
|
|
172
|
+
if isinstance(self.timeout, httpx.Timeout):
|
|
173
|
+
return self.timeout
|
|
174
|
+
return httpx.Timeout(self.timeout, connect=min(DEFAULT_CONNECT_TIMEOUT, self.timeout))
|
|
175
|
+
|
|
176
|
+
def _headers(self, with_key: bool) -> Dict[str, str]:
|
|
177
|
+
h = {
|
|
178
|
+
"accept": "application/json",
|
|
179
|
+
"user-agent": f"reefapi-python/{__version__}",
|
|
180
|
+
}
|
|
181
|
+
h.update(self._default_headers)
|
|
182
|
+
if with_key:
|
|
183
|
+
if not self.api_key:
|
|
184
|
+
raise ReefAPIError(
|
|
185
|
+
"MISSING_API_KEY",
|
|
186
|
+
f"No API key. Pass ReefAPI(api_key=...) or set the {ENV_KEY} environment variable. "
|
|
187
|
+
"Get a key at https://reefapi.com",
|
|
188
|
+
False,
|
|
189
|
+
None,
|
|
190
|
+
)
|
|
191
|
+
h["x-api-key"] = self.api_key
|
|
192
|
+
return h
|
|
193
|
+
|
|
194
|
+
def _action_url(self, engine: str, action: str) -> str:
|
|
195
|
+
engine = engine.strip("/")
|
|
196
|
+
action = action.strip("/")
|
|
197
|
+
if not engine or not action:
|
|
198
|
+
raise ValueError("engine and action are required, e.g. call('ebay', 'search', query='...')")
|
|
199
|
+
return f"{self.base_url}/{engine}/v1/{action}"
|
|
200
|
+
|
|
201
|
+
def _retry_delay(self, err: ReefAPIError, attempt: int, headers: Mapping[str, str]) -> Optional[float]:
|
|
202
|
+
if attempt >= self.max_retries:
|
|
203
|
+
return None
|
|
204
|
+
if not (err.retryable or err.status == 429):
|
|
205
|
+
return None
|
|
206
|
+
after = _parse_retry_after(headers)
|
|
207
|
+
if after is not None:
|
|
208
|
+
return after if after <= self.max_retry_wait else None
|
|
209
|
+
return min(_BACKOFF_CAP, _BACKOFF_BASE * (2.0 ** attempt)) * random.uniform(0.5, 1.0)
|
|
210
|
+
|
|
211
|
+
def _transport_delay(self, exc: Exception, attempt: int, idempotent: bool) -> Optional[float]:
|
|
212
|
+
if attempt >= self.max_retries:
|
|
213
|
+
return None
|
|
214
|
+
if not (idempotent or isinstance(exc, _NOT_SENT)):
|
|
215
|
+
return None # a POST that may have reached the server is not repeated automatically
|
|
216
|
+
return min(_BACKOFF_CAP, _BACKOFF_BASE * (2.0 ** attempt)) * random.uniform(0.5, 1.0)
|
|
217
|
+
|
|
218
|
+
def _with(self: _T, **changes: Any) -> _T:
|
|
219
|
+
clone = copy.copy(self)
|
|
220
|
+
for k, v in changes.items():
|
|
221
|
+
if v is not None:
|
|
222
|
+
setattr(clone, k, v)
|
|
223
|
+
clone._owns_http = False # type: ignore[attr-defined]
|
|
224
|
+
return clone
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class ReefAPI(SyncEngines, _BaseClient):
|
|
228
|
+
"""Synchronous ReefAPI client.
|
|
229
|
+
|
|
230
|
+
One API key for 600+ live web-data APIs (``POST https://api.reefapi.com/<engine>/v1/<action>``).
|
|
231
|
+
|
|
232
|
+
Example::
|
|
233
|
+
|
|
234
|
+
from reefapi import ReefAPI
|
|
235
|
+
|
|
236
|
+
client = ReefAPI() # reads REEFAPI_KEY
|
|
237
|
+
data = client.call("ebay", "search", query="iphone 13 pro")
|
|
238
|
+
data = client.ebay.search(query="iphone 13 pro") # typed helper, same call
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
api_key: Your key. Defaults to the ``REEFAPI_KEY`` environment variable.
|
|
242
|
+
base_url: Defaults to ``https://api.reefapi.com``.
|
|
243
|
+
timeout: Seconds per request (float) or an ``httpx.Timeout``. Default 180 s; some engines
|
|
244
|
+
render pages in a real browser and legitimately take over a minute.
|
|
245
|
+
max_retries: Automatic retries for errors the gateway marks ``retryable`` and for HTTP 429.
|
|
246
|
+
Default 2. ``0`` disables retries.
|
|
247
|
+
rate_limit: Client-side ``(requests, window_seconds)`` limiter, default ``(30, 3.0)`` = the
|
|
248
|
+
gateway's per-key limit. ``None`` disables it.
|
|
249
|
+
max_retry_wait: A ``Retry-After`` longer than this (seconds) is not waited for; the error is raised.
|
|
250
|
+
http_client: Bring your own ``httpx.Client`` (proxies, transports). It is not closed by ``close()``.
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
def __init__(
|
|
254
|
+
self,
|
|
255
|
+
api_key: Optional[str] = None,
|
|
256
|
+
*,
|
|
257
|
+
base_url: Optional[str] = None,
|
|
258
|
+
timeout: Union[float, httpx.Timeout] = DEFAULT_TIMEOUT,
|
|
259
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
260
|
+
rate_limit: RateLimit = DEFAULT_RATE_LIMIT,
|
|
261
|
+
max_retry_wait: float = DEFAULT_MAX_RETRY_WAIT,
|
|
262
|
+
default_headers: Optional[Mapping[str, str]] = None,
|
|
263
|
+
http_client: Optional[httpx.Client] = None,
|
|
264
|
+
) -> None:
|
|
265
|
+
_BaseClient.__init__(
|
|
266
|
+
self, api_key, base_url, timeout, max_retries, rate_limit, max_retry_wait, default_headers
|
|
267
|
+
)
|
|
268
|
+
self._http = http_client or httpx.Client()
|
|
269
|
+
self._owns_http = http_client is None
|
|
270
|
+
|
|
271
|
+
# -- public API ------------------------------------------------------------------------------
|
|
272
|
+
def call(self, engine: str, action: str, params: Optional[Mapping[str, Any]] = None, /, **kwargs: Any) -> Any:
|
|
273
|
+
"""Call ``POST /<engine>/v1/<action>`` and return the response ``data``.
|
|
274
|
+
|
|
275
|
+
Parameters can be passed as a dict, as keyword arguments, or both; ``None`` values are dropped.
|
|
276
|
+
Raises :class:`ReefAPIError` when the gateway answers ``ok: false`` (failed calls are not charged).
|
|
277
|
+
|
|
278
|
+
Example: ``client.call("amazon", "product/detail", asin="B0DGHMNQ5Z")``
|
|
279
|
+
"""
|
|
280
|
+
return self.call_raw(engine, action, params, **kwargs).data
|
|
281
|
+
|
|
282
|
+
def call_raw(
|
|
283
|
+
self, engine: str, action: str, params: Optional[Mapping[str, Any]] = None, /, **kwargs: Any
|
|
284
|
+
) -> ReefResponse:
|
|
285
|
+
"""Like :meth:`call` but returns the whole envelope (``data``, ``meta``, ``status``, ``headers``)."""
|
|
286
|
+
url = self._action_url(engine, action)
|
|
287
|
+
body = _clean_params(params, kwargs)
|
|
288
|
+
headers = self._headers(with_key=True)
|
|
289
|
+
return self._send(lambda: self._http.post(url, json=body, headers=headers, timeout=self._timeout()),
|
|
290
|
+
_parse_envelope, idempotent=False)
|
|
291
|
+
|
|
292
|
+
def engines(self) -> List[EngineInfo]:
|
|
293
|
+
"""List every engine in the live catalog (name, title, category, actions). No key needed."""
|
|
294
|
+
headers = self._headers(with_key=False)
|
|
295
|
+
url = f"{self.base_url}/catalog"
|
|
296
|
+
cat = self._send(lambda: self._http.get(url, params={"light": "1"}, headers=headers, timeout=self._timeout()),
|
|
297
|
+
_parse_plain, idempotent=True)
|
|
298
|
+
return list(cat.get("engines") or [])
|
|
299
|
+
|
|
300
|
+
def spec(self, engine: str) -> EngineInfo:
|
|
301
|
+
"""Full spec of one engine: every action with typed params, allowed values and examples. No key needed."""
|
|
302
|
+
headers = self._headers(with_key=False)
|
|
303
|
+
url = f"{self.base_url}/catalog"
|
|
304
|
+
cat = self._send(lambda: self._http.get(url, params={"engine": engine}, headers=headers, timeout=self._timeout()),
|
|
305
|
+
_parse_plain, idempotent=True)
|
|
306
|
+
found = [e for e in (cat.get("engines") or []) if e.get("name") == engine]
|
|
307
|
+
if not found:
|
|
308
|
+
raise ReefAPIError("UNKNOWN_API", f"No engine named {engine!r}. See client.engines().", False, 404)
|
|
309
|
+
return cast(EngineInfo, found[0])
|
|
310
|
+
|
|
311
|
+
def with_options(
|
|
312
|
+
self,
|
|
313
|
+
*,
|
|
314
|
+
timeout: Union[float, httpx.Timeout, None] = None,
|
|
315
|
+
max_retries: Optional[int] = None,
|
|
316
|
+
max_retry_wait: Optional[float] = None,
|
|
317
|
+
) -> "ReefAPI":
|
|
318
|
+
"""Return a copy with different per-request settings (shares the connection pool and limiter)."""
|
|
319
|
+
return self._with(timeout=timeout, max_retries=max_retries, max_retry_wait=max_retry_wait)
|
|
320
|
+
|
|
321
|
+
def close(self) -> None:
|
|
322
|
+
if self._owns_http:
|
|
323
|
+
self._http.close()
|
|
324
|
+
|
|
325
|
+
def __enter__(self) -> "ReefAPI":
|
|
326
|
+
return self
|
|
327
|
+
|
|
328
|
+
def __exit__(self, *exc: Any) -> None:
|
|
329
|
+
self.close()
|
|
330
|
+
|
|
331
|
+
# -- internals -------------------------------------------------------------------------------
|
|
332
|
+
def _send(self, send: Callable[[], httpx.Response], parse: Callable[[httpx.Response], _T], idempotent: bool) -> _T:
|
|
333
|
+
attempt = 0
|
|
334
|
+
while True:
|
|
335
|
+
if self._limiter is not None:
|
|
336
|
+
while True:
|
|
337
|
+
wait = self._limiter.reserve(_now())
|
|
338
|
+
if wait <= 0:
|
|
339
|
+
break
|
|
340
|
+
_sleep(wait)
|
|
341
|
+
try:
|
|
342
|
+
resp = send()
|
|
343
|
+
except httpx.TransportError as exc:
|
|
344
|
+
delay = self._transport_delay(exc, attempt, idempotent)
|
|
345
|
+
if delay is None:
|
|
346
|
+
raise _transport_error(exc) from exc
|
|
347
|
+
_sleep(delay)
|
|
348
|
+
attempt += 1
|
|
349
|
+
continue
|
|
350
|
+
try:
|
|
351
|
+
return parse(resp)
|
|
352
|
+
except ReefAPIError as err:
|
|
353
|
+
delay = self._retry_delay(err, attempt, resp.headers)
|
|
354
|
+
if delay is None:
|
|
355
|
+
raise
|
|
356
|
+
_sleep(delay)
|
|
357
|
+
attempt += 1
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
class AsyncReefAPI(AsyncEngines, _BaseClient):
|
|
361
|
+
"""Asynchronous ReefAPI client (same surface as :class:`ReefAPI`, every method is ``async``).
|
|
362
|
+
|
|
363
|
+
Example::
|
|
364
|
+
|
|
365
|
+
import asyncio
|
|
366
|
+
from reefapi import AsyncReefAPI
|
|
367
|
+
|
|
368
|
+
async def main():
|
|
369
|
+
async with AsyncReefAPI() as client:
|
|
370
|
+
data = await client.ebay.search(query="iphone 13 pro")
|
|
371
|
+
|
|
372
|
+
asyncio.run(main())
|
|
373
|
+
"""
|
|
374
|
+
|
|
375
|
+
def __init__(
|
|
376
|
+
self,
|
|
377
|
+
api_key: Optional[str] = None,
|
|
378
|
+
*,
|
|
379
|
+
base_url: Optional[str] = None,
|
|
380
|
+
timeout: Union[float, httpx.Timeout] = DEFAULT_TIMEOUT,
|
|
381
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
382
|
+
rate_limit: RateLimit = DEFAULT_RATE_LIMIT,
|
|
383
|
+
max_retry_wait: float = DEFAULT_MAX_RETRY_WAIT,
|
|
384
|
+
default_headers: Optional[Mapping[str, str]] = None,
|
|
385
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
386
|
+
) -> None:
|
|
387
|
+
_BaseClient.__init__(
|
|
388
|
+
self, api_key, base_url, timeout, max_retries, rate_limit, max_retry_wait, default_headers
|
|
389
|
+
)
|
|
390
|
+
self._http = http_client or httpx.AsyncClient()
|
|
391
|
+
self._owns_http = http_client is None
|
|
392
|
+
|
|
393
|
+
async def call(self, engine: str, action: str, params: Optional[Mapping[str, Any]] = None, /, **kwargs: Any) -> Any:
|
|
394
|
+
"""Call ``POST /<engine>/v1/<action>`` and return the response ``data`` (see :meth:`ReefAPI.call`)."""
|
|
395
|
+
return (await self.call_raw(engine, action, params, **kwargs)).data
|
|
396
|
+
|
|
397
|
+
async def call_raw(
|
|
398
|
+
self, engine: str, action: str, params: Optional[Mapping[str, Any]] = None, /, **kwargs: Any
|
|
399
|
+
) -> ReefResponse:
|
|
400
|
+
"""Like :meth:`call` but returns the whole envelope (``data``, ``meta``, ``status``, ``headers``)."""
|
|
401
|
+
url = self._action_url(engine, action)
|
|
402
|
+
body = _clean_params(params, kwargs)
|
|
403
|
+
headers = self._headers(with_key=True)
|
|
404
|
+
return await self._send(lambda: self._http.post(url, json=body, headers=headers, timeout=self._timeout()),
|
|
405
|
+
_parse_envelope, idempotent=False)
|
|
406
|
+
|
|
407
|
+
async def engines(self) -> List[EngineInfo]:
|
|
408
|
+
"""List every engine in the live catalog. No key needed."""
|
|
409
|
+
headers = self._headers(with_key=False)
|
|
410
|
+
url = f"{self.base_url}/catalog"
|
|
411
|
+
cat = await self._send(
|
|
412
|
+
lambda: self._http.get(url, params={"light": "1"}, headers=headers, timeout=self._timeout()),
|
|
413
|
+
_parse_plain, idempotent=True)
|
|
414
|
+
return list(cat.get("engines") or [])
|
|
415
|
+
|
|
416
|
+
async def spec(self, engine: str) -> EngineInfo:
|
|
417
|
+
"""Full spec of one engine (typed params, allowed values, examples). No key needed."""
|
|
418
|
+
headers = self._headers(with_key=False)
|
|
419
|
+
url = f"{self.base_url}/catalog"
|
|
420
|
+
cat = await self._send(
|
|
421
|
+
lambda: self._http.get(url, params={"engine": engine}, headers=headers, timeout=self._timeout()),
|
|
422
|
+
_parse_plain, idempotent=True)
|
|
423
|
+
found = [e for e in (cat.get("engines") or []) if e.get("name") == engine]
|
|
424
|
+
if not found:
|
|
425
|
+
raise ReefAPIError("UNKNOWN_API", f"No engine named {engine!r}. See client.engines().", False, 404)
|
|
426
|
+
return cast(EngineInfo, found[0])
|
|
427
|
+
|
|
428
|
+
def with_options(
|
|
429
|
+
self,
|
|
430
|
+
*,
|
|
431
|
+
timeout: Union[float, httpx.Timeout, None] = None,
|
|
432
|
+
max_retries: Optional[int] = None,
|
|
433
|
+
max_retry_wait: Optional[float] = None,
|
|
434
|
+
) -> "AsyncReefAPI":
|
|
435
|
+
"""Return a copy with different per-request settings (shares the connection pool and limiter)."""
|
|
436
|
+
return self._with(timeout=timeout, max_retries=max_retries, max_retry_wait=max_retry_wait)
|
|
437
|
+
|
|
438
|
+
async def aclose(self) -> None:
|
|
439
|
+
if self._owns_http:
|
|
440
|
+
await self._http.aclose()
|
|
441
|
+
|
|
442
|
+
async def __aenter__(self) -> "AsyncReefAPI":
|
|
443
|
+
return self
|
|
444
|
+
|
|
445
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
446
|
+
await self.aclose()
|
|
447
|
+
|
|
448
|
+
async def _send(self, send: Callable[[], Any], parse: Callable[[httpx.Response], _T], idempotent: bool) -> _T:
|
|
449
|
+
attempt = 0
|
|
450
|
+
while True:
|
|
451
|
+
if self._limiter is not None:
|
|
452
|
+
while True:
|
|
453
|
+
wait = self._limiter.reserve(_now())
|
|
454
|
+
if wait <= 0:
|
|
455
|
+
break
|
|
456
|
+
await _async_sleep(wait)
|
|
457
|
+
try:
|
|
458
|
+
resp = await send()
|
|
459
|
+
except httpx.TransportError as exc:
|
|
460
|
+
delay = self._transport_delay(exc, attempt, idempotent)
|
|
461
|
+
if delay is None:
|
|
462
|
+
raise _transport_error(exc) from exc
|
|
463
|
+
await _async_sleep(delay)
|
|
464
|
+
attempt += 1
|
|
465
|
+
continue
|
|
466
|
+
try:
|
|
467
|
+
return parse(resp)
|
|
468
|
+
except ReefAPIError as err:
|
|
469
|
+
delay = self._retry_delay(err, attempt, resp.headers)
|
|
470
|
+
if delay is None:
|
|
471
|
+
raise
|
|
472
|
+
await _async_sleep(delay)
|
|
473
|
+
attempt += 1
|