getanyapi 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.
- getanyapi/__init__.py +76 -0
- getanyapi/_account.py +132 -0
- getanyapi/_async_client.py +215 -0
- getanyapi/_client.py +274 -0
- getanyapi/_errors.py +115 -0
- getanyapi/_pagination.py +295 -0
- getanyapi/_transport.py +239 -0
- getanyapi/platforms/__init__.py +89 -0
- getanyapi/platforms/ahrefs.py +384 -0
- getanyapi/platforms/airbnb.py +120 -0
- getanyapi/platforms/alibaba.py +95 -0
- getanyapi/platforms/amazon.py +442 -0
- getanyapi/platforms/appstore.py +95 -0
- getanyapi/platforms/bluesky.py +215 -0
- getanyapi/platforms/booking.py +128 -0
- getanyapi/platforms/coinmarketcap.py +113 -0
- getanyapi/platforms/congress.py +106 -0
- getanyapi/platforms/dexscreener.py +104 -0
- getanyapi/platforms/ebay.py +215 -0
- getanyapi/platforms/email.py +166 -0
- getanyapi/platforms/facebook.py +2324 -0
- getanyapi/platforms/fiverr.py +122 -0
- getanyapi/platforms/github.py +954 -0
- getanyapi/platforms/glassdoor.py +93 -0
- getanyapi/platforms/google.py +232 -0
- getanyapi/platforms/google_ads.py +380 -0
- getanyapi/platforms/google_finance.py +170 -0
- getanyapi/platforms/google_shopping.py +103 -0
- getanyapi/platforms/hackernews.py +276 -0
- getanyapi/platforms/indeed.py +114 -0
- getanyapi/platforms/instagram.py +1868 -0
- getanyapi/platforms/linkedin.py +1064 -0
- getanyapi/platforms/maps.py +412 -0
- getanyapi/platforms/pandaexpress.py +262 -0
- getanyapi/platforms/person.py +96 -0
- getanyapi/platforms/pinterest.py +96 -0
- getanyapi/platforms/playstore.py +99 -0
- getanyapi/platforms/polymarket.py +109 -0
- getanyapi/platforms/realtor.py +104 -0
- getanyapi/platforms/reddit.py +582 -0
- getanyapi/platforms/redfin.py +95 -0
- getanyapi/platforms/rednote.py +807 -0
- getanyapi/platforms/sec.py +118 -0
- getanyapi/platforms/semrush.py +358 -0
- getanyapi/platforms/snapchat.py +146 -0
- getanyapi/platforms/social.py +105 -0
- getanyapi/platforms/spotify.py +588 -0
- getanyapi/platforms/substack.py +142 -0
- getanyapi/platforms/threads.py +358 -0
- getanyapi/platforms/tiktok.py +1827 -0
- getanyapi/platforms/tiktok_shop.py +536 -0
- getanyapi/platforms/tripadvisor.py +180 -0
- getanyapi/platforms/trustpilot.py +114 -0
- getanyapi/platforms/truthsocial.py +226 -0
- getanyapi/platforms/twitter.py +798 -0
- getanyapi/platforms/upwork.py +119 -0
- getanyapi/platforms/walmart.py +93 -0
- getanyapi/platforms/web.py +264 -0
- getanyapi/platforms/whatsapp.py +91 -0
- getanyapi/platforms/yahoo_finance.py +95 -0
- getanyapi/platforms/yelp.py +141 -0
- getanyapi/platforms/youtube.py +1452 -0
- getanyapi/platforms/zillow.py +218 -0
- getanyapi/py.typed +0 -0
- getanyapi/types.py +170 -0
- getanyapi-0.1.0.dist-info/METADATA +155 -0
- getanyapi-0.1.0.dist-info/RECORD +68 -0
- getanyapi-0.1.0.dist-info/WHEEL +4 -0
getanyapi/_errors.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Error hierarchy for the getanyapi SDK (SPEC 3.6).
|
|
2
|
+
|
|
3
|
+
Every failed call raises a subclass of :class:`AnyAPIError`. The HTTP status to
|
|
4
|
+
class mapping is frozen:
|
|
5
|
+
|
|
6
|
+
400 -> BadRequestError
|
|
7
|
+
401 -> AuthenticationError
|
|
8
|
+
402 -> InsufficientBalanceError
|
|
9
|
+
404 -> NotFoundError
|
|
10
|
+
429 -> RateLimitedError
|
|
11
|
+
502 -> UpstreamError
|
|
12
|
+
(any other non-2xx) -> AnyAPIError
|
|
13
|
+
|
|
14
|
+
Transport failures raise :class:`ConnectionError` (status 0); timeouts raise
|
|
15
|
+
:class:`TimeoutError` (status 0). Both names intentionally shadow the Python
|
|
16
|
+
builtins of the same name and are exported from the package root.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"AnyAPIError",
|
|
23
|
+
"BadRequestError",
|
|
24
|
+
"AuthenticationError",
|
|
25
|
+
"InsufficientBalanceError",
|
|
26
|
+
"NotFoundError",
|
|
27
|
+
"ResultNotFoundError",
|
|
28
|
+
"RateLimitedError",
|
|
29
|
+
"UpstreamError",
|
|
30
|
+
"ConnectionError",
|
|
31
|
+
"TimeoutError",
|
|
32
|
+
"error_for_status",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AnyAPIError(Exception):
|
|
37
|
+
"""Base class for every error raised by the SDK.
|
|
38
|
+
|
|
39
|
+
Attributes:
|
|
40
|
+
status: HTTP status code, or 0 for transport-level failures.
|
|
41
|
+
request_id: The x-request-id response header when present, else None.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self, message: str, *, status: int, request_id: str | None = None
|
|
46
|
+
) -> None:
|
|
47
|
+
super().__init__(message)
|
|
48
|
+
self.status = status
|
|
49
|
+
self.request_id = request_id
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class BadRequestError(AnyAPIError):
|
|
53
|
+
"""HTTP 400: the input failed validation."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AuthenticationError(AnyAPIError):
|
|
57
|
+
"""HTTP 401: the API key is missing or invalid."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class InsufficientBalanceError(AnyAPIError):
|
|
61
|
+
"""HTTP 402: the wallet balance or per-key cap was exceeded."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class NotFoundError(AnyAPIError):
|
|
65
|
+
"""HTTP 404: the slug or resource does not exist."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ResultNotFoundError(NotFoundError):
|
|
69
|
+
"""Raised by :func:`getanyapi.unwrap` when a found-data result had ``found: false``.
|
|
70
|
+
|
|
71
|
+
A subclass of :class:`NotFoundError` so ``except NotFoundError`` still catches
|
|
72
|
+
it; catch ``ResultNotFoundError`` specifically to handle only empty results
|
|
73
|
+
(not HTTP 404s). See SPEC 2.3 erratum.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class RateLimitedError(AnyAPIError):
|
|
78
|
+
"""HTTP 429: too many requests. Retried automatically."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class UpstreamError(AnyAPIError):
|
|
82
|
+
"""HTTP 502: an upstream backend failed."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ConnectionError(AnyAPIError):
|
|
86
|
+
"""A network or transport failure (status 0).
|
|
87
|
+
|
|
88
|
+
This name intentionally shadows the Python builtin ``ConnectionError``.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class TimeoutError(AnyAPIError):
|
|
93
|
+
"""A request that exceeded its timeout (status 0).
|
|
94
|
+
|
|
95
|
+
This name intentionally shadows the Python builtin ``TimeoutError``.
|
|
96
|
+
Timeouts are never retried.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
_STATUS_MAP: dict[int, type[AnyAPIError]] = {
|
|
101
|
+
400: BadRequestError,
|
|
102
|
+
401: AuthenticationError,
|
|
103
|
+
402: InsufficientBalanceError,
|
|
104
|
+
404: NotFoundError,
|
|
105
|
+
429: RateLimitedError,
|
|
106
|
+
502: UpstreamError,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def error_for_status(
|
|
111
|
+
status: int, message: str, *, request_id: str | None = None
|
|
112
|
+
) -> AnyAPIError:
|
|
113
|
+
"""Build the mapped error for a non-2xx HTTP status (SPEC 3.6)."""
|
|
114
|
+
cls = _STATUS_MAP.get(status, AnyAPIError)
|
|
115
|
+
return cls(message, status=status, request_id=request_id)
|
getanyapi/_pagination.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Cursor pagination for the sync and async clients (SPEC 2.5, 3.5).
|
|
2
|
+
|
|
3
|
+
A ``Paginator`` walks a paginated SKU one page at a time, yielding either the
|
|
4
|
+
flattened items (``__iter__``) or whole run-result pages (``.pages()``). Items
|
|
5
|
+
are validated into the SKU's pydantic item model, so callers get attribute
|
|
6
|
+
access (``post.title``), not raw dicts (SPEC 3.3 erratum, B3).
|
|
7
|
+
|
|
8
|
+
Walk semantics (frozen):
|
|
9
|
+
* Page 1 is ``run(slug, input, options)``.
|
|
10
|
+
* Read the page data object: for a found-data SKU that is ``output.data``
|
|
11
|
+
(or None when ``found`` is false); for a BARE SKU it is ``output`` itself
|
|
12
|
+
(SPEC 1.2 erratum).
|
|
13
|
+
* Yield each item from ``items_field``. If ``nextCursor`` is a non-empty
|
|
14
|
+
string, set ``input["cursor"] = nextCursor`` and repeat; a null/empty
|
|
15
|
+
``nextCursor`` ends the walk.
|
|
16
|
+
* ``options["max_items"]`` caps the TOTAL items yielded across all pages.
|
|
17
|
+
It is NOT sent as the wire ``max_items`` shaping param from the iterator;
|
|
18
|
+
the iterator manages paging itself.
|
|
19
|
+
|
|
20
|
+
The emitter targets the module-level helpers :func:`paginate` (sync) and
|
|
21
|
+
:func:`apaginate` (async): each takes the bound client, slug, input dict, the
|
|
22
|
+
items field name, the item model, the page-result model, and the envelope mode.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import copy
|
|
28
|
+
from collections.abc import AsyncIterator, Iterator
|
|
29
|
+
from typing import (
|
|
30
|
+
TYPE_CHECKING,
|
|
31
|
+
Any,
|
|
32
|
+
Generic,
|
|
33
|
+
TypeVar,
|
|
34
|
+
cast,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
from pydantic import BaseModel
|
|
38
|
+
|
|
39
|
+
from ._transport import as_dict, validate_run_result
|
|
40
|
+
from .types import (
|
|
41
|
+
AnyBareRunResult,
|
|
42
|
+
BareRunResult,
|
|
43
|
+
RequestOptions,
|
|
44
|
+
RunResult,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
if TYPE_CHECKING:
|
|
48
|
+
from ._async_client import AsyncAnyAPI
|
|
49
|
+
from ._client import AnyAPI
|
|
50
|
+
|
|
51
|
+
__all__ = ["Paginator", "AsyncPaginator", "paginate", "apaginate"]
|
|
52
|
+
|
|
53
|
+
Item = TypeVar("Item")
|
|
54
|
+
Data = TypeVar("Data")
|
|
55
|
+
|
|
56
|
+
_NEXT_CURSOR = "nextCursor"
|
|
57
|
+
_CURSOR = "cursor"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _raw_page_data(raw: dict[str, Any], bare: bool) -> dict[str, Any] | None:
|
|
61
|
+
"""The page data dict from the RAW run dict, per envelope, using WIRE keys.
|
|
62
|
+
|
|
63
|
+
For a bare SKU the data IS ``raw["output"]``; for a found-data SKU it is
|
|
64
|
+
``raw["output"]["data"]`` when ``found`` is true, else None. Reading from the
|
|
65
|
+
raw dict (not the validated model) keeps cursor/items lookups keyed on the
|
|
66
|
+
camelCase wire names even though the model exposes snake_case attributes.
|
|
67
|
+
"""
|
|
68
|
+
output = raw.get("output")
|
|
69
|
+
if not isinstance(output, dict):
|
|
70
|
+
return None
|
|
71
|
+
output_dict = cast("dict[str, Any]", output)
|
|
72
|
+
if bare:
|
|
73
|
+
return output_dict
|
|
74
|
+
if output_dict.get("found") is not True:
|
|
75
|
+
return None
|
|
76
|
+
data = output_dict.get("data")
|
|
77
|
+
return cast("dict[str, Any]", data) if isinstance(data, dict) else None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _field(data: dict[str, Any] | None, key: str) -> object:
|
|
81
|
+
"""Read a WIRE-keyed field from the raw page data dict."""
|
|
82
|
+
if data is None:
|
|
83
|
+
return None
|
|
84
|
+
return data.get(key)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _validate_page(
|
|
88
|
+
raw: dict[str, Any],
|
|
89
|
+
data_model: type[BaseModel] | None,
|
|
90
|
+
bare: bool,
|
|
91
|
+
) -> RunResult[Any] | BareRunResult[Any]:
|
|
92
|
+
"""Validate a raw run dict into the typed page result (found-data or bare)."""
|
|
93
|
+
if bare:
|
|
94
|
+
if data_model is None:
|
|
95
|
+
return AnyBareRunResult.model_validate(raw)
|
|
96
|
+
return BareRunResult[data_model].model_validate(raw) # type: ignore[valid-type]
|
|
97
|
+
if data_model is None:
|
|
98
|
+
return validate_run_result(raw)
|
|
99
|
+
return RunResult[data_model].model_validate(raw) # type: ignore[valid-type]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _validate_item(item: object, item_model: type[BaseModel] | None) -> Any:
|
|
103
|
+
"""Validate one page item into its pydantic model (or pass scalars through)."""
|
|
104
|
+
if item_model is None:
|
|
105
|
+
return item
|
|
106
|
+
return item_model.model_validate(item)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Paginator(Generic[Item, Data]):
|
|
110
|
+
"""Sync cursor walk over a paginated SKU."""
|
|
111
|
+
|
|
112
|
+
def __init__(
|
|
113
|
+
self,
|
|
114
|
+
client: AnyAPI,
|
|
115
|
+
slug: str,
|
|
116
|
+
input: dict[str, Any],
|
|
117
|
+
items_field: str,
|
|
118
|
+
item_model: type[BaseModel] | None,
|
|
119
|
+
data_model: type[BaseModel] | None,
|
|
120
|
+
bare: bool,
|
|
121
|
+
options: Any = None,
|
|
122
|
+
) -> None:
|
|
123
|
+
self._client = client
|
|
124
|
+
self._slug = slug
|
|
125
|
+
self._input = copy.deepcopy(input)
|
|
126
|
+
self._items_field = items_field
|
|
127
|
+
self._item_model = item_model
|
|
128
|
+
self._data_model = data_model
|
|
129
|
+
self._bare = bare
|
|
130
|
+
self._options = options
|
|
131
|
+
self._cap = _max_items(options)
|
|
132
|
+
self._page_options = _page_options(options)
|
|
133
|
+
|
|
134
|
+
def _walk(
|
|
135
|
+
self,
|
|
136
|
+
) -> Iterator[tuple[RunResult[Data] | BareRunResult[Data], dict[str, Any] | None]]:
|
|
137
|
+
cursor: str | None = None
|
|
138
|
+
while True:
|
|
139
|
+
page_input = dict(self._input)
|
|
140
|
+
if cursor is not None:
|
|
141
|
+
page_input[_CURSOR] = cursor
|
|
142
|
+
raw = self._client._run_raw( # pyright: ignore[reportPrivateUsage]
|
|
143
|
+
self._slug,
|
|
144
|
+
page_input,
|
|
145
|
+
cast("RequestOptions | None", self._page_options),
|
|
146
|
+
)
|
|
147
|
+
page = _validate_page(raw, self._data_model, self._bare)
|
|
148
|
+
data = _raw_page_data(raw, self._bare)
|
|
149
|
+
yield cast("RunResult[Data] | BareRunResult[Data]", page), data
|
|
150
|
+
cursor = _next_cursor(data)
|
|
151
|
+
if cursor is None:
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
def pages(self) -> Iterator[RunResult[Data] | BareRunResult[Data]]:
|
|
155
|
+
"""Yield whole run-result pages (read ``cost_usd`` per page)."""
|
|
156
|
+
for page, _ in self._walk():
|
|
157
|
+
yield page
|
|
158
|
+
|
|
159
|
+
def __iter__(self) -> Iterator[Item]:
|
|
160
|
+
count = 0
|
|
161
|
+
for _page, data in self._walk():
|
|
162
|
+
for item in _items(data, self._items_field):
|
|
163
|
+
if self._cap is not None and count >= self._cap:
|
|
164
|
+
return
|
|
165
|
+
yield cast("Item", _validate_item(item, self._item_model))
|
|
166
|
+
count += 1
|
|
167
|
+
if self._cap is not None and count >= self._cap:
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class AsyncPaginator(Generic[Item, Data]):
|
|
172
|
+
"""Async cursor walk over a paginated SKU."""
|
|
173
|
+
|
|
174
|
+
def __init__(
|
|
175
|
+
self,
|
|
176
|
+
client: AsyncAnyAPI,
|
|
177
|
+
slug: str,
|
|
178
|
+
input: dict[str, Any],
|
|
179
|
+
items_field: str,
|
|
180
|
+
item_model: type[BaseModel] | None,
|
|
181
|
+
data_model: type[BaseModel] | None,
|
|
182
|
+
bare: bool,
|
|
183
|
+
options: Any = None,
|
|
184
|
+
) -> None:
|
|
185
|
+
self._client = client
|
|
186
|
+
self._slug = slug
|
|
187
|
+
self._input = copy.deepcopy(input)
|
|
188
|
+
self._items_field = items_field
|
|
189
|
+
self._item_model = item_model
|
|
190
|
+
self._data_model = data_model
|
|
191
|
+
self._bare = bare
|
|
192
|
+
self._options = options
|
|
193
|
+
self._cap = _max_items(options)
|
|
194
|
+
self._page_options = _page_options(options)
|
|
195
|
+
|
|
196
|
+
async def _walk(
|
|
197
|
+
self,
|
|
198
|
+
) -> AsyncIterator[
|
|
199
|
+
tuple[RunResult[Data] | BareRunResult[Data], dict[str, Any] | None]
|
|
200
|
+
]:
|
|
201
|
+
cursor: str | None = None
|
|
202
|
+
while True:
|
|
203
|
+
page_input = dict(self._input)
|
|
204
|
+
if cursor is not None:
|
|
205
|
+
page_input[_CURSOR] = cursor
|
|
206
|
+
raw = await self._client._arun_raw( # pyright: ignore[reportPrivateUsage]
|
|
207
|
+
self._slug,
|
|
208
|
+
page_input,
|
|
209
|
+
cast("RequestOptions | None", self._page_options),
|
|
210
|
+
)
|
|
211
|
+
page = _validate_page(raw, self._data_model, self._bare)
|
|
212
|
+
data = _raw_page_data(raw, self._bare)
|
|
213
|
+
yield cast("RunResult[Data] | BareRunResult[Data]", page), data
|
|
214
|
+
cursor = _next_cursor(data)
|
|
215
|
+
if cursor is None:
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
async def pages(self) -> AsyncIterator[RunResult[Data] | BareRunResult[Data]]:
|
|
219
|
+
"""Yield whole run-result pages (read ``cost_usd`` per page)."""
|
|
220
|
+
async for page, _ in self._walk():
|
|
221
|
+
yield page
|
|
222
|
+
|
|
223
|
+
async def __aiter__(self) -> AsyncIterator[Item]:
|
|
224
|
+
count = 0
|
|
225
|
+
async for _page, data in self._walk():
|
|
226
|
+
for item in _items(data, self._items_field):
|
|
227
|
+
if self._cap is not None and count >= self._cap:
|
|
228
|
+
return
|
|
229
|
+
yield cast("Item", _validate_item(item, self._item_model))
|
|
230
|
+
count += 1
|
|
231
|
+
if self._cap is not None and count >= self._cap:
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _items(data: dict[str, Any] | None, items_field: str) -> list[Any]:
|
|
236
|
+
raw_items = _field(data, items_field)
|
|
237
|
+
if isinstance(raw_items, (list, tuple)):
|
|
238
|
+
return list(cast("list[Any]", raw_items))
|
|
239
|
+
return []
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _next_cursor(data: dict[str, Any] | None) -> str | None:
|
|
243
|
+
next_cursor = _field(data, _NEXT_CURSOR)
|
|
244
|
+
if isinstance(next_cursor, str) and next_cursor:
|
|
245
|
+
return next_cursor
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _max_items(options: object) -> int | None:
|
|
250
|
+
cap = as_dict(options).get("max_items")
|
|
251
|
+
return cap if isinstance(cap, int) else None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _page_options(options: object) -> object:
|
|
255
|
+
"""Strip max_items before sending: the iterator paces itself (SPEC 2.5)."""
|
|
256
|
+
if not isinstance(options, dict):
|
|
257
|
+
return options
|
|
258
|
+
source = as_dict(cast("object", options))
|
|
259
|
+
if "max_items" not in source:
|
|
260
|
+
return source or None
|
|
261
|
+
stripped: dict[str, object] = dict(source)
|
|
262
|
+
stripped.pop("max_items", None)
|
|
263
|
+
return stripped or None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def paginate(
|
|
267
|
+
client: AnyAPI,
|
|
268
|
+
slug: str,
|
|
269
|
+
input: dict[str, Any],
|
|
270
|
+
items_field: str,
|
|
271
|
+
item_model: type[BaseModel] | None = None,
|
|
272
|
+
data_model: type[BaseModel] | None = None,
|
|
273
|
+
bare: bool = False,
|
|
274
|
+
options: Any = None,
|
|
275
|
+
) -> Paginator[Any, Any]:
|
|
276
|
+
"""Build a sync Paginator bound to the client's run seam (emitter target)."""
|
|
277
|
+
return Paginator(
|
|
278
|
+
client, slug, input, items_field, item_model, data_model, bare, options
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def apaginate(
|
|
283
|
+
client: AsyncAnyAPI,
|
|
284
|
+
slug: str,
|
|
285
|
+
input: dict[str, Any],
|
|
286
|
+
items_field: str,
|
|
287
|
+
item_model: type[BaseModel] | None = None,
|
|
288
|
+
data_model: type[BaseModel] | None = None,
|
|
289
|
+
bare: bool = False,
|
|
290
|
+
options: Any = None,
|
|
291
|
+
) -> AsyncPaginator[Any, Any]:
|
|
292
|
+
"""Build an async Paginator bound to the client's run seam (emitter target)."""
|
|
293
|
+
return AsyncPaginator(
|
|
294
|
+
client, slug, input, items_field, item_model, data_model, bare, options
|
|
295
|
+
)
|
getanyapi/_transport.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Shared wire and retry engine for the sync and async clients (SPEC 2.2, 2.8).
|
|
2
|
+
|
|
3
|
+
Both :class:`getanyapi.AnyAPI` and :class:`getanyapi.AsyncAnyAPI` route every SKU run
|
|
4
|
+
through here. The wire contract is frozen:
|
|
5
|
+
|
|
6
|
+
POST {base_url}/v1/run/{slug}
|
|
7
|
+
Authorization: Bearer <api_key>
|
|
8
|
+
Content-Type: application/json
|
|
9
|
+
Accept: application/json
|
|
10
|
+
body = json(input)
|
|
11
|
+
query params (only when set): fields (comma-joined), max_items, summary=true
|
|
12
|
+
|
|
13
|
+
HTTP 200 parses into ``RunResult[Any]``; any other status maps to the frozen
|
|
14
|
+
error hierarchy. Retries cover only HTTP 429 and network failures, never
|
|
15
|
+
timeouts, with jittered exponential backoff honoring ``Retry-After`` on 429.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import email.utils
|
|
21
|
+
import random
|
|
22
|
+
import time
|
|
23
|
+
from datetime import datetime, timezone
|
|
24
|
+
from typing import Any, cast
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
from pydantic import ValidationError
|
|
28
|
+
|
|
29
|
+
from ._errors import (
|
|
30
|
+
AnyAPIError,
|
|
31
|
+
ConnectionError,
|
|
32
|
+
RateLimitedError,
|
|
33
|
+
TimeoutError,
|
|
34
|
+
error_for_status,
|
|
35
|
+
)
|
|
36
|
+
from .types import RequestOptions, RunResult
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"build_request",
|
|
40
|
+
"parse_raw",
|
|
41
|
+
"validate_run_result",
|
|
42
|
+
"compute_delay",
|
|
43
|
+
"RetryState",
|
|
44
|
+
"error_message",
|
|
45
|
+
"as_dict",
|
|
46
|
+
"is_retryable_error",
|
|
47
|
+
"sleep",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
_BASE_DELAY = 0.5 # seconds
|
|
51
|
+
_MAX_DELAY = 8.0 # seconds
|
|
52
|
+
_REQUEST_ID_HEADER = "x-request-id"
|
|
53
|
+
_RNG = random.Random()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _query_params(options: RequestOptions | None) -> dict[str, str]:
|
|
57
|
+
"""Build the response-shaping query params from options (SPEC 2.2)."""
|
|
58
|
+
params: dict[str, str] = {}
|
|
59
|
+
if not options:
|
|
60
|
+
return params
|
|
61
|
+
fields = options.get("fields")
|
|
62
|
+
if fields:
|
|
63
|
+
params["fields"] = ",".join(fields)
|
|
64
|
+
max_items = options.get("max_items")
|
|
65
|
+
if max_items is not None:
|
|
66
|
+
params["max_items"] = str(max_items)
|
|
67
|
+
if options.get("summary"):
|
|
68
|
+
params["summary"] = "true"
|
|
69
|
+
return params
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_request(
|
|
73
|
+
*,
|
|
74
|
+
base_url: str,
|
|
75
|
+
slug: str,
|
|
76
|
+
input: dict[str, Any],
|
|
77
|
+
api_key: str,
|
|
78
|
+
options: RequestOptions | None,
|
|
79
|
+
timeout: float,
|
|
80
|
+
) -> httpx.Request:
|
|
81
|
+
"""Assemble the httpx.Request for a SKU run (no client bound).
|
|
82
|
+
|
|
83
|
+
The per-request timeout is carried on the request's ``extensions`` so it
|
|
84
|
+
applies on ``client.send(request)`` without depending on the client default.
|
|
85
|
+
"""
|
|
86
|
+
url = f"{base_url.rstrip('/')}/v1/run/{slug}"
|
|
87
|
+
headers = {
|
|
88
|
+
"Authorization": f"Bearer {api_key}",
|
|
89
|
+
"Content-Type": "application/json",
|
|
90
|
+
"Accept": "application/json",
|
|
91
|
+
}
|
|
92
|
+
return httpx.Request(
|
|
93
|
+
"POST",
|
|
94
|
+
url,
|
|
95
|
+
params=_query_params(options),
|
|
96
|
+
headers=headers,
|
|
97
|
+
json=input,
|
|
98
|
+
extensions={"timeout": httpx.Timeout(timeout).as_dict()},
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _fallback_message(status: int) -> str:
|
|
103
|
+
return f"request failed with status {status}"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def as_dict(value: object) -> dict[str, object]:
|
|
107
|
+
"""Narrow an arbitrary JSON value to a str-keyed dict (empty if not one)."""
|
|
108
|
+
if isinstance(value, dict):
|
|
109
|
+
return cast("dict[str, object]", value)
|
|
110
|
+
return {}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def error_message(body: object, status: int) -> str:
|
|
114
|
+
"""Extract the ``{error}`` string from a JSON body, else a generic message."""
|
|
115
|
+
err = as_dict(body).get("error")
|
|
116
|
+
if isinstance(err, str):
|
|
117
|
+
return err
|
|
118
|
+
return _fallback_message(status)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def parse_raw(response: httpx.Response) -> dict[str, Any]:
|
|
122
|
+
"""Return the parsed JSON dict on 200, or raise the mapped error otherwise.
|
|
123
|
+
|
|
124
|
+
The raw-dict seam (SPEC N2): generated methods validate this dict directly
|
|
125
|
+
into their concrete ``RunResult[XData]`` / ``BareRunResult[XData]`` model, so
|
|
126
|
+
there is no model_validate(model_dump(...)) double-parse. The bare-vs-found
|
|
127
|
+
envelope choice is the caller's (the generated code knows its SKU's shape).
|
|
128
|
+
"""
|
|
129
|
+
request_id = response.headers.get(_REQUEST_ID_HEADER)
|
|
130
|
+
if response.status_code == 200:
|
|
131
|
+
try:
|
|
132
|
+
body = response.json()
|
|
133
|
+
except ValueError as exc:
|
|
134
|
+
raise AnyAPIError(
|
|
135
|
+
f"could not parse run response: {exc}",
|
|
136
|
+
status=200,
|
|
137
|
+
request_id=request_id,
|
|
138
|
+
) from exc
|
|
139
|
+
if not isinstance(body, dict):
|
|
140
|
+
raise AnyAPIError(
|
|
141
|
+
"run response was not a JSON object",
|
|
142
|
+
status=200,
|
|
143
|
+
request_id=request_id,
|
|
144
|
+
)
|
|
145
|
+
return cast("dict[str, Any]", body)
|
|
146
|
+
|
|
147
|
+
err_body: object = None
|
|
148
|
+
try:
|
|
149
|
+
err_body = response.json()
|
|
150
|
+
except ValueError:
|
|
151
|
+
err_body = None
|
|
152
|
+
message = error_message(err_body, response.status_code)
|
|
153
|
+
raise error_for_status(
|
|
154
|
+
response.status_code, message, request_id=request_id
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def validate_run_result(raw: dict[str, Any]) -> RunResult[Any]:
|
|
159
|
+
"""Validate a raw run dict into a generic ``RunResult[Any]`` (found-data).
|
|
160
|
+
|
|
161
|
+
Used by the generic ``client.run(slug, ...)`` helper. Bare SKUs are best
|
|
162
|
+
reached through their typed namespace method (which validates into a
|
|
163
|
+
``BareRunResult``); the generic path assumes the found-data envelope.
|
|
164
|
+
"""
|
|
165
|
+
try:
|
|
166
|
+
return RunResult[Any].model_validate(raw)
|
|
167
|
+
except ValidationError as exc:
|
|
168
|
+
raise AnyAPIError(
|
|
169
|
+
f"could not parse run response: {exc}", status=200
|
|
170
|
+
) from exc
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _retry_after_seconds(response: httpx.Response) -> float | None:
|
|
174
|
+
"""Parse a Retry-After header (seconds or HTTP-date), capped at max delay."""
|
|
175
|
+
raw = response.headers.get("retry-after")
|
|
176
|
+
if not raw:
|
|
177
|
+
return None
|
|
178
|
+
raw = raw.strip()
|
|
179
|
+
try:
|
|
180
|
+
secs = float(raw)
|
|
181
|
+
except ValueError:
|
|
182
|
+
secs = None
|
|
183
|
+
if secs is not None:
|
|
184
|
+
return min(max(secs, 0.0), _MAX_DELAY)
|
|
185
|
+
try:
|
|
186
|
+
parsed = email.utils.parsedate_to_datetime(raw)
|
|
187
|
+
except (TypeError, ValueError):
|
|
188
|
+
return None
|
|
189
|
+
if parsed.tzinfo is None:
|
|
190
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
191
|
+
delta = (parsed - datetime.now(timezone.utc)).total_seconds()
|
|
192
|
+
return min(max(delta, 0.0), _MAX_DELAY)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def compute_delay(attempt: int, rng: random.Random | None = None) -> float:
|
|
196
|
+
"""Jittered exponential backoff for retry ``attempt`` (0-based) (SPEC 2.8)."""
|
|
197
|
+
r: random.Random = rng or _RNG
|
|
198
|
+
base = min(_BASE_DELAY * float(2 ** attempt), _MAX_DELAY)
|
|
199
|
+
jitter = 0.5 + r.random()
|
|
200
|
+
return base * jitter
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class RetryState:
|
|
204
|
+
"""Tracks retry budget and computes the next delay across attempts.
|
|
205
|
+
|
|
206
|
+
Shared by the sync and async run loops so the retry policy lives in one
|
|
207
|
+
place. The caller drives the loop; this object only decides whether another
|
|
208
|
+
attempt is allowed and how long to wait.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
def __init__(self, max_retries: int) -> None:
|
|
212
|
+
self.max_retries = max(0, max_retries)
|
|
213
|
+
self.attempt = 0
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def can_retry(self) -> bool:
|
|
217
|
+
return self.attempt < self.max_retries
|
|
218
|
+
|
|
219
|
+
def next_delay(self, response: httpx.Response | None) -> float:
|
|
220
|
+
"""Delay before the next retry; honors Retry-After on a 429 response."""
|
|
221
|
+
delay = compute_delay(self.attempt)
|
|
222
|
+
if response is not None:
|
|
223
|
+
retry_after = _retry_after_seconds(response)
|
|
224
|
+
if retry_after is not None:
|
|
225
|
+
delay = retry_after
|
|
226
|
+
self.attempt += 1
|
|
227
|
+
return delay
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def is_retryable_error(exc: AnyAPIError) -> bool:
|
|
231
|
+
"""Retry only rate limits and connection failures, never timeouts."""
|
|
232
|
+
if isinstance(exc, TimeoutError):
|
|
233
|
+
return False
|
|
234
|
+
return isinstance(exc, (RateLimitedError, ConnectionError))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def sleep(seconds: float) -> None:
|
|
238
|
+
"""Blocking sleep seam (monkeypatched in tests)."""
|
|
239
|
+
time.sleep(seconds)
|