scrapelyserp 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.
@@ -0,0 +1,50 @@
1
+ """Official Python SDK for the Scrapely API (https://scrapelyserp.com).
2
+
3
+ from scrapelyserp import Scrapely
4
+
5
+ client = Scrapely() # reads SCRAPELY_API_KEY
6
+ print(client.account.get()["balance"])
7
+ """
8
+
9
+ from . import types
10
+ from ._base import DEFAULT_BASE_URL, RequestOptions, normalize_base_url
11
+ from ._errors import (
12
+ APIConnectionError,
13
+ APIError,
14
+ APITimeoutError,
15
+ AuthenticationError,
16
+ BadRequestError,
17
+ ComingSoonError,
18
+ ConflictError,
19
+ InsufficientCreditsError,
20
+ InternalServerError,
21
+ NotFoundError,
22
+ PermissionDeniedError,
23
+ RateLimitError,
24
+ ScrapelyError,
25
+ )
26
+ from ._resources import AsyncScrapely, Scrapely
27
+ from ._version import __version__
28
+
29
+ __all__ = [
30
+ "Scrapely",
31
+ "AsyncScrapely",
32
+ "RequestOptions",
33
+ "DEFAULT_BASE_URL",
34
+ "normalize_base_url",
35
+ "types",
36
+ "ScrapelyError",
37
+ "APIError",
38
+ "BadRequestError",
39
+ "AuthenticationError",
40
+ "InsufficientCreditsError",
41
+ "PermissionDeniedError",
42
+ "NotFoundError",
43
+ "ConflictError",
44
+ "RateLimitError",
45
+ "InternalServerError",
46
+ "ComingSoonError",
47
+ "APIConnectionError",
48
+ "APITimeoutError",
49
+ "__version__",
50
+ ]
scrapelyserp/_base.py ADDED
@@ -0,0 +1,272 @@
1
+ """The transport every resource method goes through: auth, retries, errors, pagination."""
2
+
3
+ import asyncio
4
+ import email.utils
5
+ import json
6
+ import os
7
+ import random
8
+ import re
9
+ import time
10
+ import uuid
11
+ from typing import Any, AsyncIterator, Dict, Iterator, Mapping, Optional, Tuple, TypedDict
12
+ from urllib.parse import quote
13
+
14
+ import httpx
15
+
16
+ from ._errors import APIConnectionError, APITimeoutError, AuthenticationError, error_for
17
+ from ._routes import ROUTES
18
+ from ._version import __version__
19
+
20
+ DEFAULT_BASE_URL = "https://scrapelyserp.com"
21
+ DEFAULT_TIMEOUT = 360.0 # seconds per attempt: a large product call may take up to 300 s
22
+
23
+
24
+ class RequestOptions(TypedDict, total=False):
25
+ """Per-call overrides."""
26
+
27
+ headers: Mapping[str, str]
28
+ timeout: float
29
+ max_retries: int
30
+
31
+
32
+ def normalize_base_url(url: str) -> str:
33
+ """``https://api.scrapelyserp.com/v1/`` -> ``https://api.scrapelyserp.com``: the routes carry ``/api/v1``."""
34
+ return re.sub(r"/(api/)?v1$", "", url.rstrip("/"))
35
+
36
+
37
+ def _merge(body: Optional[Mapping[str, Any]], fields: Mapping[str, Any]) -> Optional[Dict[str, Any]]:
38
+ if body is None and not fields:
39
+ return None
40
+ return {**(body or {}), **fields}
41
+
42
+
43
+ def _retry_after(value: Optional[str]) -> Optional[float]:
44
+ if not value:
45
+ return None
46
+ try:
47
+ return max(0.0, float(value))
48
+ except ValueError:
49
+ parsed = email.utils.parsedate_to_datetime(value) if value else None
50
+ return max(0.0, parsed.timestamp() - time.time()) if parsed else None
51
+
52
+
53
+ class BaseClient:
54
+ """Shared by the sync and async clients: configuration, request building, retry policy."""
55
+
56
+ def __init__(
57
+ self,
58
+ api_key: Optional[str] = None,
59
+ *,
60
+ base_url: Optional[str] = None,
61
+ max_retries: int = 2,
62
+ timeout: float = DEFAULT_TIMEOUT,
63
+ headers: Optional[Mapping[str, str]] = None,
64
+ ) -> None:
65
+ self.api_key = api_key if api_key is not None else os.environ.get("SCRAPELY_API_KEY") or None
66
+ self.base_url = normalize_base_url(base_url or os.environ.get("SCRAPELY_BASE_URL") or DEFAULT_BASE_URL)
67
+ self.max_retries = max_retries
68
+ self.timeout = timeout
69
+ self._headers = dict(headers or {})
70
+
71
+ def _build(
72
+ self,
73
+ operation_id: str,
74
+ path: Optional[Mapping[str, Any]],
75
+ query: Optional[Mapping[str, Any]],
76
+ body: Any,
77
+ idempotency_key: Optional[str],
78
+ options: Optional[RequestOptions],
79
+ ) -> Tuple[str, str, Dict[str, str], Dict[str, str], Optional[bytes]]:
80
+ if operation_id not in ROUTES:
81
+ raise ValueError(f"Unknown operation: {operation_id}")
82
+ method, template, flags = ROUTES[operation_id]
83
+ if not self.api_key and not flags & 1:
84
+ raise AuthenticationError(401, {"error": {"code": "UNAUTHORIZED", "message": "No API key: pass api_key or set SCRAPELY_API_KEY."}})
85
+
86
+ def sub(m: "re.Match[str]") -> str:
87
+ value = (path or {}).get(m.group(1))
88
+ if value is None or value == "":
89
+ raise ValueError(f"{operation_id}: missing path parameter `{m.group(1)}`")
90
+ return quote(str(value), safe="")
91
+
92
+ url = self.base_url + re.sub(r"\{(\w+)\}", sub, template)
93
+ params: Dict[str, str] = {}
94
+ for k, v in (query or {}).items():
95
+ if v is None:
96
+ continue
97
+ if isinstance(v, bool):
98
+ v = "true" if v else "false"
99
+ elif isinstance(v, (list, tuple)):
100
+ v = ",".join(str(x) for x in v)
101
+ params[k] = str(v)
102
+ headers = {"accept": "application/json", "user-agent": f"scrapelyserp-python/{__version__}", **self._headers}
103
+ if self.api_key:
104
+ headers["authorization"] = f"Bearer {self.api_key}"
105
+ content = None
106
+ if body is not None:
107
+ headers["content-type"] = "application/json"
108
+ content = json.dumps(body).encode()
109
+ if flags & 2:
110
+ # Generated once per call, so a retry can never create a second resource.
111
+ headers["idempotency-key"] = idempotency_key or str(uuid.uuid4())
112
+ headers.update((options or {}).get("headers") or {})
113
+ return method, url, params, headers, content
114
+
115
+ @staticmethod
116
+ def _parse(response: httpx.Response) -> Any:
117
+ text = response.text
118
+ if text and "json" in response.headers.get("content-type", ""):
119
+ try:
120
+ return json.loads(text)
121
+ except ValueError:
122
+ return text
123
+ return text or None
124
+
125
+ @staticmethod
126
+ def _retryable(status: int) -> bool:
127
+ return status == 429 or (status >= 500 and status != 501)
128
+
129
+ @staticmethod
130
+ def _backoff(attempt: int, retry_after: Optional[str]) -> float:
131
+ """``Retry-After`` when sent (capped at 60 s), else 0.5 s, 1 s, 2 s... (max 8 s) with jitter."""
132
+ ra = _retry_after(retry_after)
133
+ if ra is not None:
134
+ return min(ra, 60.0)
135
+ return min(0.5 * 2**attempt, 8.0) * (0.75 + random.random() * 0.25)
136
+
137
+
138
+ class SyncBaseClient(BaseClient):
139
+ def __init__(self, api_key: Optional[str] = None, *, http_client: Optional[httpx.Client] = None, **kwargs: Any) -> None:
140
+ super().__init__(api_key, **kwargs)
141
+ self._http = http_client or httpx.Client()
142
+
143
+ def close(self) -> None:
144
+ self._http.close()
145
+
146
+ def __enter__(self) -> "SyncBaseClient":
147
+ return self
148
+
149
+ def __exit__(self, *exc: Any) -> None:
150
+ self.close()
151
+
152
+ def request(
153
+ self,
154
+ operation_id: str,
155
+ *,
156
+ path: Optional[Mapping[str, Any]] = None,
157
+ query: Optional[Mapping[str, Any]] = None,
158
+ body: Any = None,
159
+ idempotency_key: Optional[str] = None,
160
+ options: Optional[RequestOptions] = None,
161
+ ) -> Any:
162
+ """Calls any operation by its operationId. The resource methods are typed wrappers of this."""
163
+ return self._request(operation_id, path=path, query=query, body=body, idempotency_key=idempotency_key, options=options)
164
+
165
+ def _request(self, operation_id: str, *, path: Any = None, query: Any = None, body: Any = None, idempotency_key: Optional[str] = None, options: Optional[RequestOptions] = None) -> Any:
166
+ method, url, params, headers, content = self._build(operation_id, path, query, body, idempotency_key, options)
167
+ opts = options or {}
168
+ max_retries = opts.get("max_retries", self.max_retries)
169
+ attempt = 0
170
+ while True:
171
+ try:
172
+ res = self._http.request(method, url, params=params, headers=headers, content=content, timeout=opts.get("timeout", self.timeout))
173
+ except httpx.TimeoutException as e:
174
+ if attempt < max_retries:
175
+ time.sleep(self._backoff(attempt, None))
176
+ attempt += 1
177
+ continue
178
+ raise APITimeoutError(f"Timed out: {e}") from e
179
+ except httpx.TransportError as e:
180
+ if attempt < max_retries:
181
+ time.sleep(self._backoff(attempt, None))
182
+ attempt += 1
183
+ continue
184
+ raise APIConnectionError(f"Connection error: {e}") from e
185
+ data = self._parse(res)
186
+ if res.is_success:
187
+ return data
188
+ if self._retryable(res.status_code) and attempt < max_retries:
189
+ time.sleep(self._backoff(attempt, res.headers.get("retry-after")))
190
+ attempt += 1
191
+ continue
192
+ raise error_for(res.status_code, data, res.headers)
193
+
194
+ def _paginate(self, operation_id: str, *, path: Any = None, query: Any = None, options: Optional[RequestOptions] = None, **_: Any) -> Iterator[Any]:
195
+ q = dict(query or {})
196
+ while True:
197
+ page = self._request(operation_id, path=path, query=q, options=options) or {}
198
+ data = page.get("data") or []
199
+ yield from data
200
+ cursor = page.get("next_starting_after") or (data[-1].get("id") if data and isinstance(data[-1], dict) else None)
201
+ if not page.get("has_more") or not cursor:
202
+ return
203
+ q["starting_after"] = cursor
204
+
205
+
206
+ class AsyncBaseClient(BaseClient):
207
+ def __init__(self, api_key: Optional[str] = None, *, http_client: Optional[httpx.AsyncClient] = None, **kwargs: Any) -> None:
208
+ super().__init__(api_key, **kwargs)
209
+ self._http = http_client or httpx.AsyncClient()
210
+
211
+ async def close(self) -> None:
212
+ await self._http.aclose()
213
+
214
+ async def __aenter__(self) -> "AsyncBaseClient":
215
+ return self
216
+
217
+ async def __aexit__(self, *exc: Any) -> None:
218
+ await self.close()
219
+
220
+ async def request(
221
+ self,
222
+ operation_id: str,
223
+ *,
224
+ path: Optional[Mapping[str, Any]] = None,
225
+ query: Optional[Mapping[str, Any]] = None,
226
+ body: Any = None,
227
+ idempotency_key: Optional[str] = None,
228
+ options: Optional[RequestOptions] = None,
229
+ ) -> Any:
230
+ """Calls any operation by its operationId. The resource methods are typed wrappers of this."""
231
+ return await self._request(operation_id, path=path, query=query, body=body, idempotency_key=idempotency_key, options=options)
232
+
233
+ async def _request(self, operation_id: str, *, path: Any = None, query: Any = None, body: Any = None, idempotency_key: Optional[str] = None, options: Optional[RequestOptions] = None) -> Any:
234
+ method, url, params, headers, content = self._build(operation_id, path, query, body, idempotency_key, options)
235
+ opts = options or {}
236
+ max_retries = opts.get("max_retries", self.max_retries)
237
+ attempt = 0
238
+ while True:
239
+ try:
240
+ res = await self._http.request(method, url, params=params, headers=headers, content=content, timeout=opts.get("timeout", self.timeout))
241
+ except httpx.TimeoutException as e:
242
+ if attempt < max_retries:
243
+ await asyncio.sleep(self._backoff(attempt, None))
244
+ attempt += 1
245
+ continue
246
+ raise APITimeoutError(f"Timed out: {e}") from e
247
+ except httpx.TransportError as e:
248
+ if attempt < max_retries:
249
+ await asyncio.sleep(self._backoff(attempt, None))
250
+ attempt += 1
251
+ continue
252
+ raise APIConnectionError(f"Connection error: {e}") from e
253
+ data = self._parse(res)
254
+ if res.is_success:
255
+ return data
256
+ if self._retryable(res.status_code) and attempt < max_retries:
257
+ await asyncio.sleep(self._backoff(attempt, res.headers.get("retry-after")))
258
+ attempt += 1
259
+ continue
260
+ raise error_for(res.status_code, data, res.headers)
261
+
262
+ async def _paginate(self, operation_id: str, *, path: Any = None, query: Any = None, options: Optional[RequestOptions] = None, **_: Any) -> AsyncIterator[Any]:
263
+ q = dict(query or {})
264
+ while True:
265
+ page = await self._request(operation_id, path=path, query=q, options=options) or {}
266
+ data = page.get("data") or []
267
+ for item in data:
268
+ yield item
269
+ cursor = page.get("next_starting_after") or (data[-1].get("id") if data and isinstance(data[-1], dict) else None)
270
+ if not page.get("has_more") or not cursor:
271
+ return
272
+ q["starting_after"] = cursor
@@ -0,0 +1,92 @@
1
+ """Every error the SDK raises is a ScrapelyError; API answers are an APIError subclass by status."""
2
+
3
+ from typing import Any, Dict, Optional, Type
4
+
5
+ import httpx
6
+
7
+
8
+ class ScrapelyError(Exception):
9
+ """Base class of every error this package raises."""
10
+
11
+
12
+ class APIError(ScrapelyError):
13
+ """The API answered with an error: ``{"error": {"code", "message", "param?", "docs"}}``."""
14
+
15
+ def __init__(self, status: int, body: Any, headers: Optional[httpx.Headers] = None) -> None:
16
+ err: Dict[str, Any] = body.get("error", {}) if isinstance(body, dict) and isinstance(body.get("error"), dict) else {}
17
+ #: HTTP status.
18
+ self.status: int = status
19
+ #: Stable, machine-readable (``INVALID_PARAM``, ``INSUFFICIENT_CREDITS``...). Branch on this.
20
+ self.code: str = err.get("code") or f"HTTP_{status}"
21
+ #: The parameter at fault, when there is one.
22
+ self.param: Optional[str] = err.get("param")
23
+ #: Link to this code's documentation.
24
+ self.docs: Optional[str] = err.get("docs")
25
+ #: ``X-Request-Id``: quote it to support.
26
+ self.request_id: Optional[str] = headers.get("x-request-id") if headers is not None else None
27
+ self.body: Any = body
28
+ self.headers = headers
29
+ message = err.get("message") or (body[:200] if isinstance(body, str) and body else f"HTTP {status}")
30
+ self.message: str = message
31
+ super().__init__(f"{self.code}: {message}" + (f" (param: {self.param})" if self.param else ""))
32
+
33
+
34
+ class BadRequestError(APIError):
35
+ pass
36
+
37
+
38
+ class AuthenticationError(APIError):
39
+ pass
40
+
41
+
42
+ class InsufficientCreditsError(APIError):
43
+ pass
44
+
45
+
46
+ class PermissionDeniedError(APIError):
47
+ pass
48
+
49
+
50
+ class NotFoundError(APIError):
51
+ pass
52
+
53
+
54
+ class ConflictError(APIError):
55
+ pass
56
+
57
+
58
+ class RateLimitError(APIError):
59
+ pass
60
+
61
+
62
+ class InternalServerError(APIError):
63
+ pass
64
+
65
+
66
+ class ComingSoonError(APIError):
67
+ """501 ``NOT_IMPLEMENTED``: announced but not live yet. Nothing was charged."""
68
+
69
+
70
+ class APIConnectionError(ScrapelyError):
71
+ """The request never got an answer (DNS, connection reset, offline)."""
72
+
73
+
74
+ class APITimeoutError(APIConnectionError):
75
+ pass
76
+
77
+
78
+ _BY_STATUS: Dict[int, Type[APIError]] = {
79
+ 400: BadRequestError,
80
+ 401: AuthenticationError,
81
+ 402: InsufficientCreditsError,
82
+ 403: PermissionDeniedError,
83
+ 404: NotFoundError,
84
+ 409: ConflictError,
85
+ 429: RateLimitError,
86
+ 501: ComingSoonError,
87
+ }
88
+
89
+
90
+ def error_for(status: int, body: Any, headers: Optional[httpx.Headers] = None) -> APIError:
91
+ cls = _BY_STATUS.get(status) or (InternalServerError if status >= 500 else APIError)
92
+ return cls(status, body, headers)