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 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