herascrape 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.
herascrape/__init__.py ADDED
@@ -0,0 +1,52 @@
1
+ from ._errors import (
2
+ AuthenticationError,
3
+ BadRequestError,
4
+ ForbiddenError,
5
+ HerascrapeAPIError,
6
+ HerascrapeError,
7
+ HerascrapeTransportError,
8
+ InternalError,
9
+ InvalidCursorError,
10
+ InvalidParameterError,
11
+ NotFoundError,
12
+ PrivateAccountError,
13
+ ProfileNotFoundError,
14
+ RateLimitError,
15
+ ServerError,
16
+ ServiceUnavailableError,
17
+ UpstreamError,
18
+ ValidationError,
19
+ )
20
+ from .async_client import AsyncClient
21
+ from .client import Client
22
+ from .ratelimit import RateLimit
23
+ from .types import BalanceResponse, MediaIdResolution, Page
24
+
25
+ __version__ = "0.1.0"
26
+
27
+ __all__ = [
28
+ "AsyncClient",
29
+ "AuthenticationError",
30
+ "BadRequestError",
31
+ "BalanceResponse",
32
+ "Client",
33
+ "ForbiddenError",
34
+ "HerascrapeAPIError",
35
+ "HerascrapeError",
36
+ "HerascrapeTransportError",
37
+ "InternalError",
38
+ "InvalidCursorError",
39
+ "InvalidParameterError",
40
+ "MediaIdResolution",
41
+ "NotFoundError",
42
+ "Page",
43
+ "PrivateAccountError",
44
+ "ProfileNotFoundError",
45
+ "RateLimit",
46
+ "RateLimitError",
47
+ "ServerError",
48
+ "ServiceUnavailableError",
49
+ "UpstreamError",
50
+ "ValidationError",
51
+ "__version__",
52
+ ]
herascrape/_base.py ADDED
@@ -0,0 +1,276 @@
1
+ import asyncio
2
+ import json
3
+ import math
4
+ import os
5
+ import random
6
+ import time
7
+ from collections.abc import Awaitable, Callable, Mapping
8
+ from typing import Any, Literal, TypeVar, cast
9
+
10
+ import httpx
11
+
12
+ from ._errors import (
13
+ AuthenticationError,
14
+ HerascrapeAPIError,
15
+ HerascrapeTransportError,
16
+ InvalidCursorError,
17
+ RateLimitError,
18
+ ServiceUnavailableError,
19
+ UpstreamError,
20
+ error_from_response,
21
+ )
22
+ from .ratelimit import DEFAULT_RATE_LIMIT, RateLimit, RateLimiter
23
+
24
+ DEFAULT_BASE_URL = "https://api.herascrape.com"
25
+ API_KEY_ENV_VAR = "HERASCRAPE_API_KEY"
26
+
27
+ Param = str | int | float | bool
28
+ ClientError = HerascrapeAPIError | HerascrapeTransportError
29
+
30
+ _BACKOFF_BASE = 0.5
31
+ _BACKOFF_CAP = 8.0
32
+ _BACKOFF_JITTER = 0.5
33
+ _MAX_RETRY_AFTER = 300.0
34
+
35
+ _RETRYABLE = (
36
+ HerascrapeTransportError,
37
+ UpstreamError,
38
+ ServiceUnavailableError,
39
+ InvalidCursorError,
40
+ )
41
+
42
+
43
+ def unwrap(payload: Any) -> Any:
44
+ """Return the `data` member of a success envelope."""
45
+ return payload["data"]
46
+
47
+
48
+ def drop_none(params: Mapping[str, Param | None] | None) -> dict[str, Param]:
49
+ """Query parameters without the unset (`None`) ones."""
50
+ if params is None:
51
+ return {}
52
+ return {name: value for name, value in params.items() if value is not None}
53
+
54
+
55
+ def _parse_retry_after(value: str | None) -> float | None:
56
+ """Seconds from a numeric `Retry-After`; `None` if absent or not usable."""
57
+ if value is None:
58
+ return None
59
+ try:
60
+ seconds = float(value)
61
+ except ValueError:
62
+ return None
63
+ return max(seconds, 0.0) if math.isfinite(seconds) else None
64
+
65
+
66
+ def _json_object(body: bytes) -> dict[str, Any]:
67
+ """The body as a JSON object; empty if it is anything else."""
68
+ try:
69
+ payload: Any = json.loads(body)
70
+ except ValueError:
71
+ return {}
72
+ return cast(dict[str, Any], payload) if isinstance(payload, dict) else {}
73
+
74
+
75
+ def parse_error(
76
+ *, status_code: int, headers: Mapping[str, str], body: bytes
77
+ ) -> HerascrapeAPIError:
78
+ """Build the exception for an error response, whatever its body."""
79
+ payload = _json_object(body)
80
+ error_code = payload.get("error_code")
81
+ return error_from_response(
82
+ status_code=status_code,
83
+ error_code=error_code if isinstance(error_code, str) else None,
84
+ detail=payload.get("detail"),
85
+ request_id=headers.get("x-request-id"),
86
+ retry_after=_parse_retry_after(headers.get("retry-after")),
87
+ )
88
+
89
+
90
+ def is_retryable(exc: ClientError) -> bool:
91
+ """Transient failures worth another attempt (429 is handled apart)."""
92
+ return isinstance(exc, _RETRYABLE)
93
+
94
+
95
+ def backoff_delay(
96
+ attempt: int, *, rng: Callable[[], float] = random.random
97
+ ) -> float:
98
+ """Seconds to wait before retry number `attempt` (0 for the first).
99
+
100
+ Doubles from 0.5s up to an 8s cap, then drops by up to half at random.
101
+ """
102
+ delay = min(_BACKOFF_CAP, _BACKOFF_BASE * 2**attempt)
103
+ return delay * (1 - _BACKOFF_JITTER * rng())
104
+
105
+
106
+ def retry_delay(exc: ClientError, *, attempt: int) -> float | None:
107
+ """Seconds to wait before retrying `exc`, or `None` if it is final."""
108
+ if isinstance(exc, RateLimitError):
109
+ if exc.retry_after is not None:
110
+ return min(exc.retry_after, _MAX_RETRY_AFTER)
111
+ return backoff_delay(attempt)
112
+ return backoff_delay(attempt) if is_retryable(exc) else None
113
+
114
+
115
+ def transport_failure(exc: httpx.TransportError) -> HerascrapeTransportError:
116
+ """The client-side error for a connection failure or timeout."""
117
+ return HerascrapeTransportError(f"{type(exc).__name__}: {exc}")
118
+
119
+
120
+ def response_data(response: httpx.Response) -> Any:
121
+ """The unwrapped `data` of a success response; raises on an error one."""
122
+ if response.is_success:
123
+ return unwrap(response.json())
124
+ raise parse_error(
125
+ status_code=response.status_code,
126
+ headers=response.headers,
127
+ body=response.content,
128
+ )
129
+
130
+
131
+ _Client = TypeVar("_Client", bound="BaseSyncClient")
132
+
133
+
134
+ class BaseSyncClient:
135
+ """Synchronous transport shared by every generated endpoint method."""
136
+
137
+ def __init__(
138
+ self,
139
+ api_key: str | None = None,
140
+ *,
141
+ base_url: str = DEFAULT_BASE_URL,
142
+ timeout: float = 10.0,
143
+ max_retries: int = 3,
144
+ rate_limit: RateLimit | None = DEFAULT_RATE_LIMIT,
145
+ transport: httpx.BaseTransport | None = None,
146
+ sleep: Callable[[float], None] = time.sleep,
147
+ ) -> None:
148
+ key = api_key or os.environ.get(API_KEY_ENV_VAR)
149
+ if not key:
150
+ raise AuthenticationError(
151
+ status_code=401,
152
+ detail=f"No API key: pass api_key or set {API_KEY_ENV_VAR}.",
153
+ )
154
+ self._max_retries = max_retries
155
+ self._sleep = sleep
156
+ self._limiter = RateLimiter(rate_limit, sleep=sleep)
157
+ self._client = httpx.Client(
158
+ base_url=base_url,
159
+ headers={"x-api-key": key, "accept": "application/json"},
160
+ timeout=timeout,
161
+ transport=transport,
162
+ )
163
+
164
+ def _request(
165
+ self,
166
+ *,
167
+ method: Literal["get"],
168
+ path: str,
169
+ params: Mapping[str, Param | None] | None = None,
170
+ ) -> Any:
171
+ """Send a request and return the unwrapped `data`, retrying."""
172
+ query = drop_none(params)
173
+ attempt = 0
174
+ while True:
175
+ self._limiter.acquire()
176
+ try:
177
+ return self._send(method=method, path=path, params=query)
178
+ except (HerascrapeAPIError, HerascrapeTransportError) as exc:
179
+ delay = retry_delay(exc, attempt=attempt)
180
+ if delay is None or attempt >= self._max_retries:
181
+ raise
182
+ self._sleep(delay)
183
+ attempt += 1
184
+
185
+ def _send(
186
+ self, *, method: Literal["get"], path: str, params: dict[str, Param]
187
+ ) -> Any:
188
+ try:
189
+ response = self._client.request(method, path, params=params)
190
+ except httpx.TransportError as exc:
191
+ raise transport_failure(exc) from exc
192
+ return response_data(response)
193
+
194
+ def close(self) -> None:
195
+ """Release the underlying connection pool."""
196
+ self._client.close()
197
+
198
+ def __enter__(self: _Client) -> _Client:
199
+ return self
200
+
201
+ def __exit__(self, *exc_info: object) -> None:
202
+ self.close()
203
+
204
+
205
+ _AsyncClient = TypeVar("_AsyncClient", bound="BaseAsyncClient")
206
+
207
+
208
+ class BaseAsyncClient:
209
+ """Asynchronous transport shared by every generated endpoint method."""
210
+
211
+ def __init__(
212
+ self,
213
+ api_key: str | None = None,
214
+ *,
215
+ base_url: str = DEFAULT_BASE_URL,
216
+ timeout: float = 10.0,
217
+ max_retries: int = 3,
218
+ rate_limit: RateLimit | None = DEFAULT_RATE_LIMIT,
219
+ transport: httpx.AsyncBaseTransport | None = None,
220
+ asleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
221
+ ) -> None:
222
+ key = api_key or os.environ.get(API_KEY_ENV_VAR)
223
+ if not key:
224
+ raise AuthenticationError(
225
+ status_code=401,
226
+ detail=f"No API key: pass api_key or set {API_KEY_ENV_VAR}.",
227
+ )
228
+ self._max_retries = max_retries
229
+ self._asleep = asleep
230
+ self._limiter = RateLimiter(rate_limit)
231
+ self._client = httpx.AsyncClient(
232
+ base_url=base_url,
233
+ headers={"x-api-key": key, "accept": "application/json"},
234
+ timeout=timeout,
235
+ transport=transport,
236
+ )
237
+
238
+ async def _request(
239
+ self,
240
+ *,
241
+ method: Literal["get"],
242
+ path: str,
243
+ params: Mapping[str, Param | None] | None = None,
244
+ ) -> Any:
245
+ """Send a request and return the unwrapped `data`, retrying."""
246
+ query = drop_none(params)
247
+ attempt = 0
248
+ while True:
249
+ await self._limiter.aacquire(self._asleep)
250
+ try:
251
+ return await self._send(method=method, path=path, params=query)
252
+ except (HerascrapeAPIError, HerascrapeTransportError) as exc:
253
+ delay = retry_delay(exc, attempt=attempt)
254
+ if delay is None or attempt >= self._max_retries:
255
+ raise
256
+ await self._asleep(delay)
257
+ attempt += 1
258
+
259
+ async def _send(
260
+ self, *, method: Literal["get"], path: str, params: dict[str, Param]
261
+ ) -> Any:
262
+ try:
263
+ response = await self._client.request(method, path, params=params)
264
+ except httpx.TransportError as exc:
265
+ raise transport_failure(exc) from exc
266
+ return response_data(response)
267
+
268
+ async def aclose(self) -> None:
269
+ """Release the underlying connection pool."""
270
+ await self._client.aclose()
271
+
272
+ async def __aenter__(self: _AsyncClient) -> _AsyncClient:
273
+ return self
274
+
275
+ async def __aexit__(self, *exc_info: object) -> None:
276
+ await self.aclose()