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/__init__.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""getanyapi - official typed Python SDK for AnyAPI.
|
|
2
|
+
|
|
3
|
+
from getanyapi import AnyAPI, AsyncAnyAPI, agent_signup, unwrap
|
|
4
|
+
from getanyapi import AnyAPIError, NotFoundError, RateLimitedError # etc.
|
|
5
|
+
|
|
6
|
+
Sync and async clients share generated per-platform namespaces (attached lazily)
|
|
7
|
+
through a single transport seam. Output models are pydantic v2; open provider
|
|
8
|
+
records round-trip unknown fields via ``.model_extra``. See ../../../SPEC.md
|
|
9
|
+
section 3 for the frozen public surface.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from ._async_client import AsyncAnyAPI
|
|
15
|
+
from ._client import AnyAPI, agent_signup
|
|
16
|
+
from ._errors import (
|
|
17
|
+
AnyAPIError,
|
|
18
|
+
AuthenticationError,
|
|
19
|
+
BadRequestError,
|
|
20
|
+
ConnectionError,
|
|
21
|
+
InsufficientBalanceError,
|
|
22
|
+
NotFoundError,
|
|
23
|
+
RateLimitedError,
|
|
24
|
+
ResultNotFoundError,
|
|
25
|
+
TimeoutError,
|
|
26
|
+
UpstreamError,
|
|
27
|
+
)
|
|
28
|
+
from ._pagination import AsyncPaginator, Paginator
|
|
29
|
+
from .types import (
|
|
30
|
+
AccountProfile,
|
|
31
|
+
AgentSignupResult,
|
|
32
|
+
Balance,
|
|
33
|
+
BareRunResult,
|
|
34
|
+
CatalogEntry,
|
|
35
|
+
Output,
|
|
36
|
+
OutputFound,
|
|
37
|
+
OutputNotFound,
|
|
38
|
+
RequestOptions,
|
|
39
|
+
RunResult,
|
|
40
|
+
unwrap,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
__version__ = "0.0.0"
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
# clients + top-level functions
|
|
47
|
+
"AnyAPI",
|
|
48
|
+
"AsyncAnyAPI",
|
|
49
|
+
"agent_signup",
|
|
50
|
+
"unwrap",
|
|
51
|
+
# result + data models
|
|
52
|
+
"RunResult",
|
|
53
|
+
"BareRunResult",
|
|
54
|
+
"Output",
|
|
55
|
+
"OutputFound",
|
|
56
|
+
"OutputNotFound",
|
|
57
|
+
"Balance",
|
|
58
|
+
"AccountProfile",
|
|
59
|
+
"CatalogEntry",
|
|
60
|
+
"RequestOptions",
|
|
61
|
+
"AgentSignupResult",
|
|
62
|
+
# pagination
|
|
63
|
+
"Paginator",
|
|
64
|
+
"AsyncPaginator",
|
|
65
|
+
# errors
|
|
66
|
+
"AnyAPIError",
|
|
67
|
+
"BadRequestError",
|
|
68
|
+
"AuthenticationError",
|
|
69
|
+
"InsufficientBalanceError",
|
|
70
|
+
"NotFoundError",
|
|
71
|
+
"ResultNotFoundError",
|
|
72
|
+
"RateLimitedError",
|
|
73
|
+
"UpstreamError",
|
|
74
|
+
"ConnectionError",
|
|
75
|
+
"TimeoutError",
|
|
76
|
+
]
|
getanyapi/_account.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Account, catalog, and agent-signup request/response mapping (SPEC 2.7, 3.7).
|
|
2
|
+
|
|
3
|
+
Pure functions that build the HTTP request pieces and map raw JSON bodies into
|
|
4
|
+
the public models. The sync and async clients share these so the wire mapping
|
|
5
|
+
lives in one place. Credits never appear in the public surface: the catalog
|
|
6
|
+
price is converted from internal credits to USD here, before any model is built.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, cast
|
|
12
|
+
|
|
13
|
+
from ._errors import AnyAPIError, error_for_status
|
|
14
|
+
from ._transport import as_dict, error_message
|
|
15
|
+
from .types import AccountProfile, AgentSignupResult, Balance, CatalogEntry
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"balance_path",
|
|
19
|
+
"me_path",
|
|
20
|
+
"catalog_request",
|
|
21
|
+
"describe_path",
|
|
22
|
+
"signup_request",
|
|
23
|
+
"parse_balance",
|
|
24
|
+
"parse_me",
|
|
25
|
+
"parse_catalog",
|
|
26
|
+
"parse_describe",
|
|
27
|
+
"parse_signup",
|
|
28
|
+
"map_error",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
_CREDIT_USD = 0.00001 # 1 credit = $0.00001 USD (internal unit, never surfaced)
|
|
32
|
+
|
|
33
|
+
balance_path = "/v1/balance"
|
|
34
|
+
me_path = "/v1/me"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def catalog_request(
|
|
38
|
+
query: str | None, category: str | None
|
|
39
|
+
) -> tuple[str, dict[str, str]]:
|
|
40
|
+
"""Path and query params for ``catalog()`` (GET /v1/apis)."""
|
|
41
|
+
params: dict[str, str] = {}
|
|
42
|
+
if query is not None:
|
|
43
|
+
params["query"] = query
|
|
44
|
+
if category is not None:
|
|
45
|
+
params["category"] = category
|
|
46
|
+
return "/v1/apis", params
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def describe_path(slug: str) -> str:
|
|
50
|
+
return f"/v1/apis/{slug}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def signup_request(
|
|
54
|
+
sponsor_email: str | None, label: str | None
|
|
55
|
+
) -> dict[str, Any]:
|
|
56
|
+
"""JSON body for ``agent_signup()`` (POST /agent/signup, no auth)."""
|
|
57
|
+
body: dict[str, Any] = {}
|
|
58
|
+
if sponsor_email is not None:
|
|
59
|
+
body["sponsorEmail"] = sponsor_email
|
|
60
|
+
if label is not None:
|
|
61
|
+
body["label"] = label
|
|
62
|
+
return body
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _split_slug(slug: str) -> tuple[str, str]:
|
|
66
|
+
"""Derive (platform, action) from a dotted slug; action "" if no dot."""
|
|
67
|
+
platform, _, action = slug.partition(".")
|
|
68
|
+
return platform, action
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def parse_balance(body: Any) -> Balance:
|
|
72
|
+
return Balance.model_validate(body)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_me(body: Any) -> AccountProfile:
|
|
76
|
+
"""Map /v1/me, dropping clerkUserId and signupGrantApplied (SPEC 2.7)."""
|
|
77
|
+
return AccountProfile.model_validate(body)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _str_field(raw: dict[str, object], key: str) -> str:
|
|
81
|
+
value = raw.get(key)
|
|
82
|
+
return value if isinstance(value, str) else ""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _from_credits(raw: dict[str, object]) -> float:
|
|
86
|
+
value = raw.get("fromCredits")
|
|
87
|
+
if isinstance(value, (int, float)):
|
|
88
|
+
return float(value)
|
|
89
|
+
return 0.0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _to_catalog_entry(raw: dict[str, object]) -> CatalogEntry:
|
|
93
|
+
slug = _str_field(raw, "slug")
|
|
94
|
+
platform, action = _split_slug(slug)
|
|
95
|
+
return CatalogEntry.model_validate(
|
|
96
|
+
{
|
|
97
|
+
"slug": slug,
|
|
98
|
+
"platform": platform,
|
|
99
|
+
"action": action,
|
|
100
|
+
"name": _str_field(raw, "name"),
|
|
101
|
+
"category": _str_field(raw, "category"),
|
|
102
|
+
"description": _str_field(raw, "description"),
|
|
103
|
+
"priceUsd": _from_credits(raw) * _CREDIT_USD,
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def parse_catalog(body: object) -> list[CatalogEntry]:
|
|
109
|
+
"""Map {apis:[...]} into CatalogEntry[] (SPEC 2.7)."""
|
|
110
|
+
raw = as_dict(body).get("apis")
|
|
111
|
+
if not isinstance(raw, list):
|
|
112
|
+
return []
|
|
113
|
+
apis: list[object] = list(cast("list[object]", raw))
|
|
114
|
+
return [_to_catalog_entry(as_dict(item)) for item in apis]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def parse_describe(body: object) -> CatalogEntry:
|
|
118
|
+
"""Map a single /v1/apis/{slug} entry into one CatalogEntry."""
|
|
119
|
+
return _to_catalog_entry(as_dict(body))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def parse_signup(body: Any) -> AgentSignupResult:
|
|
123
|
+
return AgentSignupResult.model_validate(body)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def map_error(
|
|
127
|
+
status: int, body: object, request_id: str | None
|
|
128
|
+
) -> AnyAPIError:
|
|
129
|
+
"""Map a non-2xx account/catalog response to the frozen error hierarchy."""
|
|
130
|
+
return error_for_status(
|
|
131
|
+
status, error_message(body, status), request_id=request_id
|
|
132
|
+
)
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Async AsyncAnyAPI client (SPEC 3.1).
|
|
2
|
+
|
|
3
|
+
Mirrors :class:`getanyapi.AnyAPI` with ``async def`` methods, ``aclose``, and an
|
|
4
|
+
async context manager. Generated async namespaces attach lazily via
|
|
5
|
+
``__getattr__`` using the same ``getanyapi.platforms.REGISTRY`` table as the sync
|
|
6
|
+
client (see ``_client`` module docstring for the full contract); the async
|
|
7
|
+
client instantiates the registry's async class.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import importlib
|
|
14
|
+
import os
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from . import _account
|
|
20
|
+
from . import _transport
|
|
21
|
+
from ._client import lookup_namespace
|
|
22
|
+
from ._errors import AnyAPIError, ConnectionError, TimeoutError
|
|
23
|
+
from ._transport import (
|
|
24
|
+
RetryState,
|
|
25
|
+
build_request,
|
|
26
|
+
is_retryable_error,
|
|
27
|
+
parse_raw,
|
|
28
|
+
)
|
|
29
|
+
from .types import (
|
|
30
|
+
AccountProfile,
|
|
31
|
+
Balance,
|
|
32
|
+
CatalogEntry,
|
|
33
|
+
RequestOptions,
|
|
34
|
+
RunResult,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
__all__ = ["AsyncAnyAPI"]
|
|
38
|
+
|
|
39
|
+
_DEFAULT_BASE_URL = "https://api.getanyapi.com"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _resolve_api_key(api_key: str | None) -> str:
|
|
43
|
+
key = api_key if api_key is not None else os.environ.get("ANYAPI_API_KEY")
|
|
44
|
+
if not key:
|
|
45
|
+
raise AnyAPIError(
|
|
46
|
+
"no API key: pass api_key= or set ANYAPI_API_KEY", status=0
|
|
47
|
+
)
|
|
48
|
+
return key
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AsyncAnyAPI:
|
|
52
|
+
"""Asynchronous AnyAPI client (SPEC 3.1)."""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
*,
|
|
57
|
+
api_key: str | None = None,
|
|
58
|
+
base_url: str = _DEFAULT_BASE_URL,
|
|
59
|
+
timeout: float = 60.0,
|
|
60
|
+
max_retries: int = 2,
|
|
61
|
+
http_client: httpx.AsyncClient | None = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
self._api_key = _resolve_api_key(api_key)
|
|
64
|
+
self._base_url = base_url.rstrip("/")
|
|
65
|
+
self._timeout = timeout
|
|
66
|
+
self._max_retries = max_retries
|
|
67
|
+
self._owns_client = http_client is None
|
|
68
|
+
self._http = http_client or httpx.AsyncClient(timeout=timeout)
|
|
69
|
+
self._namespaces: dict[str, Any] = {}
|
|
70
|
+
|
|
71
|
+
# -- generated namespace attachment -----------------------------------
|
|
72
|
+
|
|
73
|
+
def __getattr__(self, name: str) -> Any:
|
|
74
|
+
if name.startswith("_"):
|
|
75
|
+
raise AttributeError(name)
|
|
76
|
+
cache = self.__dict__.get("_namespaces")
|
|
77
|
+
if cache is not None and name in cache:
|
|
78
|
+
return cache[name]
|
|
79
|
+
entry = lookup_namespace(name)
|
|
80
|
+
if entry is None:
|
|
81
|
+
raise AttributeError(name)
|
|
82
|
+
module_suffix, _sync_class, async_class = entry
|
|
83
|
+
module = importlib.import_module(f"getanyapi.platforms.{module_suffix}")
|
|
84
|
+
namespace = getattr(module, async_class)(self)
|
|
85
|
+
if cache is not None:
|
|
86
|
+
cache[name] = namespace
|
|
87
|
+
return namespace
|
|
88
|
+
|
|
89
|
+
# -- transport seam ---------------------------------------------------
|
|
90
|
+
|
|
91
|
+
async def _arun_raw(
|
|
92
|
+
self,
|
|
93
|
+
slug: str,
|
|
94
|
+
input: dict[str, Any],
|
|
95
|
+
options: RequestOptions | None = None,
|
|
96
|
+
) -> dict[str, Any]:
|
|
97
|
+
"""Execute one SKU run with retries, returning the raw JSON dict.
|
|
98
|
+
|
|
99
|
+
The raw seam generated methods call (SPEC N2): the generated code
|
|
100
|
+
validates this dict into its concrete ``RunResult[XData]`` /
|
|
101
|
+
``BareRunResult[XData]`` model, so there is no double-parse.
|
|
102
|
+
"""
|
|
103
|
+
timeout = self._timeout
|
|
104
|
+
max_retries = self._max_retries
|
|
105
|
+
if options:
|
|
106
|
+
opt_timeout = options.get("timeout")
|
|
107
|
+
if opt_timeout is not None:
|
|
108
|
+
timeout = float(opt_timeout)
|
|
109
|
+
opt_retries = options.get("max_retries")
|
|
110
|
+
if opt_retries is not None:
|
|
111
|
+
max_retries = int(opt_retries)
|
|
112
|
+
request = build_request(
|
|
113
|
+
base_url=self._base_url,
|
|
114
|
+
slug=slug,
|
|
115
|
+
input=input,
|
|
116
|
+
api_key=self._api_key,
|
|
117
|
+
options=options,
|
|
118
|
+
timeout=timeout,
|
|
119
|
+
)
|
|
120
|
+
retry = RetryState(max_retries)
|
|
121
|
+
while True:
|
|
122
|
+
response: httpx.Response | None = None
|
|
123
|
+
try:
|
|
124
|
+
response = await self._http.send(request)
|
|
125
|
+
return parse_raw(response)
|
|
126
|
+
except AnyAPIError as exc:
|
|
127
|
+
if is_retryable_error(exc) and retry.can_retry:
|
|
128
|
+
await asyncio.sleep(retry.next_delay(response))
|
|
129
|
+
continue
|
|
130
|
+
raise
|
|
131
|
+
except httpx.TimeoutException as exc:
|
|
132
|
+
raise TimeoutError(
|
|
133
|
+
str(exc) or "request timed out", status=0
|
|
134
|
+
) from exc
|
|
135
|
+
except httpx.HTTPError as exc:
|
|
136
|
+
if retry.can_retry:
|
|
137
|
+
await asyncio.sleep(retry.next_delay(None))
|
|
138
|
+
continue
|
|
139
|
+
raise ConnectionError(
|
|
140
|
+
str(exc) or "connection failed", status=0
|
|
141
|
+
) from exc
|
|
142
|
+
|
|
143
|
+
async def _arun(
|
|
144
|
+
self,
|
|
145
|
+
slug: str,
|
|
146
|
+
input: dict[str, Any],
|
|
147
|
+
options: RequestOptions | None = None,
|
|
148
|
+
) -> RunResult[Any]:
|
|
149
|
+
"""Execute one SKU run and parse the generic found-data envelope."""
|
|
150
|
+
raw = await self._arun_raw(slug, input, options)
|
|
151
|
+
return _transport.validate_run_result(raw)
|
|
152
|
+
|
|
153
|
+
async def run(
|
|
154
|
+
self,
|
|
155
|
+
slug: str,
|
|
156
|
+
input: dict[str, Any],
|
|
157
|
+
*,
|
|
158
|
+
options: RequestOptions | None = None,
|
|
159
|
+
) -> RunResult[Any]:
|
|
160
|
+
"""Generic typed run for any SKU by slug (SPEC 3.1)."""
|
|
161
|
+
return await self._arun(slug, input, options)
|
|
162
|
+
|
|
163
|
+
# -- account + catalog ------------------------------------------------
|
|
164
|
+
|
|
165
|
+
async def _get(
|
|
166
|
+
self, path: str, params: dict[str, str] | None = None
|
|
167
|
+
) -> object:
|
|
168
|
+
url = f"{self._base_url}{path}"
|
|
169
|
+
headers = {
|
|
170
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
171
|
+
"Accept": "application/json",
|
|
172
|
+
}
|
|
173
|
+
response = await self._http.get(
|
|
174
|
+
url, params=params or {}, headers=headers
|
|
175
|
+
)
|
|
176
|
+
return self._json_or_raise(response)
|
|
177
|
+
|
|
178
|
+
def _json_or_raise(self, response: httpx.Response) -> object:
|
|
179
|
+
request_id = response.headers.get("x-request-id")
|
|
180
|
+
body: object = None
|
|
181
|
+
try:
|
|
182
|
+
body = response.json()
|
|
183
|
+
except ValueError:
|
|
184
|
+
body = None
|
|
185
|
+
if response.status_code != 200:
|
|
186
|
+
raise _account.map_error(response.status_code, body, request_id)
|
|
187
|
+
return body
|
|
188
|
+
|
|
189
|
+
async def balance(self) -> Balance:
|
|
190
|
+
return _account.parse_balance(await self._get(_account.balance_path))
|
|
191
|
+
|
|
192
|
+
async def me(self) -> AccountProfile:
|
|
193
|
+
return _account.parse_me(await self._get(_account.me_path))
|
|
194
|
+
|
|
195
|
+
async def catalog(
|
|
196
|
+
self, *, query: str | None = None, category: str | None = None
|
|
197
|
+
) -> list[CatalogEntry]:
|
|
198
|
+
path, params = _account.catalog_request(query, category)
|
|
199
|
+
return _account.parse_catalog(await self._get(path, params))
|
|
200
|
+
|
|
201
|
+
async def describe(self, slug: str) -> CatalogEntry:
|
|
202
|
+
body = await self._get(_account.describe_path(slug))
|
|
203
|
+
return _account.parse_describe(body)
|
|
204
|
+
|
|
205
|
+
# -- lifecycle --------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
async def aclose(self) -> None:
|
|
208
|
+
if self._owns_client:
|
|
209
|
+
await self._http.aclose()
|
|
210
|
+
|
|
211
|
+
async def __aenter__(self) -> AsyncAnyAPI:
|
|
212
|
+
return self
|
|
213
|
+
|
|
214
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
215
|
+
await self.aclose()
|
getanyapi/_client.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Sync AnyAPI client (SPEC 3.1) and the module-level agent_signup helper.
|
|
2
|
+
|
|
3
|
+
Generated per-platform namespaces attach lazily. On first access,
|
|
4
|
+
``__getattr__(name)`` looks the name up in the generated registry
|
|
5
|
+
``getanyapi.platforms.REGISTRY`` and imports the platform module, instantiating its
|
|
6
|
+
sync ``Namespace`` class bound to this client.
|
|
7
|
+
|
|
8
|
+
Namespace-attachment contract (target for the py-emitter):
|
|
9
|
+
``getanyapi.platforms.__init__`` exposes a module-level dict named ``REGISTRY``
|
|
10
|
+
mapping the client attribute name (the snake_case platform, e.g. "amazon")
|
|
11
|
+
to a 3-tuple ``(module_suffix, sync_class_name, async_class_name)``:
|
|
12
|
+
|
|
13
|
+
REGISTRY: dict[str, tuple[str, str, str]] = {
|
|
14
|
+
"amazon": ("amazon", "AmazonNamespace", "AsyncAmazonNamespace"),
|
|
15
|
+
...
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
Each generated module ``getanyapi.platforms.<module_suffix>`` defines both the
|
|
19
|
+
sync class ``<sync_class_name>`` and the async class ``<async_class_name>``.
|
|
20
|
+
Each namespace class has ``__init__(self, client)`` storing the client, and
|
|
21
|
+
per-SKU methods that call ``client._run(slug, input, options)`` (sync) or
|
|
22
|
+
``client._arun(...)`` (async), plus ``iter_*`` methods returning a
|
|
23
|
+
``Paginator``/``AsyncPaginator`` via ``getanyapi._pagination.paginate`` /
|
|
24
|
+
``apaginate``. The sync client instantiates the sync class; the async client
|
|
25
|
+
instantiates the async class. Both look the attribute up by the SAME key in
|
|
26
|
+
the SAME registry, so one generated table drives both clients.
|
|
27
|
+
|
|
28
|
+
Namespace instances are cached on the client instance after first access.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import importlib
|
|
34
|
+
import os
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
import httpx
|
|
38
|
+
|
|
39
|
+
from . import _account, _transport
|
|
40
|
+
from ._errors import AnyAPIError, ConnectionError, TimeoutError
|
|
41
|
+
from ._transport import (
|
|
42
|
+
RetryState,
|
|
43
|
+
build_request,
|
|
44
|
+
is_retryable_error,
|
|
45
|
+
parse_raw,
|
|
46
|
+
)
|
|
47
|
+
from .types import (
|
|
48
|
+
AccountProfile,
|
|
49
|
+
AgentSignupResult,
|
|
50
|
+
Balance,
|
|
51
|
+
CatalogEntry,
|
|
52
|
+
RequestOptions,
|
|
53
|
+
RunResult,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
__all__ = ["AnyAPI", "agent_signup"]
|
|
57
|
+
|
|
58
|
+
_DEFAULT_BASE_URL = "https://api.getanyapi.com"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def lookup_namespace(name: str) -> tuple[str, str, str] | None:
|
|
62
|
+
"""Look ``name`` up in the generated platform registry (typed accessor)."""
|
|
63
|
+
try:
|
|
64
|
+
module = importlib.import_module("getanyapi.platforms")
|
|
65
|
+
except ImportError:
|
|
66
|
+
return None
|
|
67
|
+
registry: dict[str, tuple[str, str, str]] = getattr(module, "REGISTRY", {})
|
|
68
|
+
return registry.get(name)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _resolve_api_key(api_key: str | None) -> str:
|
|
72
|
+
key = api_key if api_key is not None else os.environ.get("ANYAPI_API_KEY")
|
|
73
|
+
if not key:
|
|
74
|
+
raise AnyAPIError(
|
|
75
|
+
"no API key: pass api_key= or set ANYAPI_API_KEY", status=0
|
|
76
|
+
)
|
|
77
|
+
return key
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class AnyAPI:
|
|
81
|
+
"""Synchronous AnyAPI client (SPEC 3.1).
|
|
82
|
+
|
|
83
|
+
Generated per-platform namespaces (``client.amazon``, ``client.facebook``,
|
|
84
|
+
...) attach lazily via ``__getattr__``; see the module docstring for the
|
|
85
|
+
registry contract the emitter targets.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
*,
|
|
91
|
+
api_key: str | None = None,
|
|
92
|
+
base_url: str = _DEFAULT_BASE_URL,
|
|
93
|
+
timeout: float = 60.0,
|
|
94
|
+
max_retries: int = 2,
|
|
95
|
+
http_client: httpx.Client | None = None,
|
|
96
|
+
) -> None:
|
|
97
|
+
self._api_key = _resolve_api_key(api_key)
|
|
98
|
+
self._base_url = base_url.rstrip("/")
|
|
99
|
+
self._timeout = timeout
|
|
100
|
+
self._max_retries = max_retries
|
|
101
|
+
self._owns_client = http_client is None
|
|
102
|
+
self._http = http_client or httpx.Client(timeout=timeout)
|
|
103
|
+
self._namespaces: dict[str, Any] = {}
|
|
104
|
+
|
|
105
|
+
# -- generated namespace attachment -----------------------------------
|
|
106
|
+
|
|
107
|
+
def __getattr__(self, name: str) -> Any:
|
|
108
|
+
# Only called for attributes not found normally.
|
|
109
|
+
if name.startswith("_"):
|
|
110
|
+
raise AttributeError(name)
|
|
111
|
+
cache = self.__dict__.get("_namespaces")
|
|
112
|
+
if cache is not None and name in cache:
|
|
113
|
+
return cache[name]
|
|
114
|
+
entry = lookup_namespace(name)
|
|
115
|
+
if entry is None:
|
|
116
|
+
raise AttributeError(name)
|
|
117
|
+
module_suffix, sync_class, _async_class = entry
|
|
118
|
+
module = importlib.import_module(f"getanyapi.platforms.{module_suffix}")
|
|
119
|
+
namespace = getattr(module, sync_class)(self)
|
|
120
|
+
if cache is not None:
|
|
121
|
+
cache[name] = namespace
|
|
122
|
+
return namespace
|
|
123
|
+
|
|
124
|
+
# -- transport seam ---------------------------------------------------
|
|
125
|
+
|
|
126
|
+
def _run_raw(
|
|
127
|
+
self,
|
|
128
|
+
slug: str,
|
|
129
|
+
input: dict[str, Any],
|
|
130
|
+
options: RequestOptions | None = None,
|
|
131
|
+
) -> dict[str, Any]:
|
|
132
|
+
"""Execute one SKU run with retries, returning the raw JSON dict.
|
|
133
|
+
|
|
134
|
+
The raw seam generated methods call (SPEC N2): the generated code
|
|
135
|
+
validates this dict into its concrete ``RunResult[XData]`` /
|
|
136
|
+
``BareRunResult[XData]`` model, so there is no double-parse.
|
|
137
|
+
"""
|
|
138
|
+
timeout = self._timeout
|
|
139
|
+
max_retries = self._max_retries
|
|
140
|
+
if options:
|
|
141
|
+
opt_timeout = options.get("timeout")
|
|
142
|
+
if opt_timeout is not None:
|
|
143
|
+
timeout = float(opt_timeout)
|
|
144
|
+
opt_retries = options.get("max_retries")
|
|
145
|
+
if opt_retries is not None:
|
|
146
|
+
max_retries = int(opt_retries)
|
|
147
|
+
request = build_request(
|
|
148
|
+
base_url=self._base_url,
|
|
149
|
+
slug=slug,
|
|
150
|
+
input=input,
|
|
151
|
+
api_key=self._api_key,
|
|
152
|
+
options=options,
|
|
153
|
+
timeout=timeout,
|
|
154
|
+
)
|
|
155
|
+
retry = RetryState(max_retries)
|
|
156
|
+
while True:
|
|
157
|
+
response: httpx.Response | None = None
|
|
158
|
+
try:
|
|
159
|
+
response = self._http.send(request)
|
|
160
|
+
return parse_raw(response)
|
|
161
|
+
except AnyAPIError as exc:
|
|
162
|
+
if is_retryable_error(exc) and retry.can_retry:
|
|
163
|
+
_transport.sleep(retry.next_delay(response))
|
|
164
|
+
continue
|
|
165
|
+
raise
|
|
166
|
+
except httpx.TimeoutException as exc:
|
|
167
|
+
raise TimeoutError(
|
|
168
|
+
str(exc) or "request timed out", status=0
|
|
169
|
+
) from exc
|
|
170
|
+
except httpx.HTTPError as exc:
|
|
171
|
+
if retry.can_retry:
|
|
172
|
+
_transport.sleep(retry.next_delay(None))
|
|
173
|
+
continue
|
|
174
|
+
raise ConnectionError(
|
|
175
|
+
str(exc) or "connection failed", status=0
|
|
176
|
+
) from exc
|
|
177
|
+
|
|
178
|
+
def _run(
|
|
179
|
+
self,
|
|
180
|
+
slug: str,
|
|
181
|
+
input: dict[str, Any],
|
|
182
|
+
options: RequestOptions | None = None,
|
|
183
|
+
) -> RunResult[Any]:
|
|
184
|
+
"""Execute one SKU run and parse the generic found-data envelope."""
|
|
185
|
+
raw = self._run_raw(slug, input, options)
|
|
186
|
+
return _transport.validate_run_result(raw)
|
|
187
|
+
|
|
188
|
+
def run(
|
|
189
|
+
self,
|
|
190
|
+
slug: str,
|
|
191
|
+
input: dict[str, Any],
|
|
192
|
+
*,
|
|
193
|
+
options: RequestOptions | None = None,
|
|
194
|
+
) -> RunResult[Any]:
|
|
195
|
+
"""Generic typed run for any SKU by slug (SPEC 3.1)."""
|
|
196
|
+
return self._run(slug, input, options)
|
|
197
|
+
|
|
198
|
+
# -- account + catalog ------------------------------------------------
|
|
199
|
+
|
|
200
|
+
def _get(self, path: str, params: dict[str, str] | None = None) -> object:
|
|
201
|
+
url = f"{self._base_url}{path}"
|
|
202
|
+
headers = {
|
|
203
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
204
|
+
"Accept": "application/json",
|
|
205
|
+
}
|
|
206
|
+
response = self._http.get(url, params=params or {}, headers=headers)
|
|
207
|
+
return self._json_or_raise(response)
|
|
208
|
+
|
|
209
|
+
def _json_or_raise(self, response: httpx.Response) -> object:
|
|
210
|
+
request_id = response.headers.get("x-request-id")
|
|
211
|
+
body: object = None
|
|
212
|
+
try:
|
|
213
|
+
body = response.json()
|
|
214
|
+
except ValueError:
|
|
215
|
+
body = None
|
|
216
|
+
if response.status_code != 200:
|
|
217
|
+
raise _account.map_error(response.status_code, body, request_id)
|
|
218
|
+
return body
|
|
219
|
+
|
|
220
|
+
def balance(self) -> Balance:
|
|
221
|
+
return _account.parse_balance(self._get(_account.balance_path))
|
|
222
|
+
|
|
223
|
+
def me(self) -> AccountProfile:
|
|
224
|
+
return _account.parse_me(self._get(_account.me_path))
|
|
225
|
+
|
|
226
|
+
def catalog(
|
|
227
|
+
self, *, query: str | None = None, category: str | None = None
|
|
228
|
+
) -> list[CatalogEntry]:
|
|
229
|
+
path, params = _account.catalog_request(query, category)
|
|
230
|
+
return _account.parse_catalog(self._get(path, params))
|
|
231
|
+
|
|
232
|
+
def describe(self, slug: str) -> CatalogEntry:
|
|
233
|
+
return _account.parse_describe(self._get(_account.describe_path(slug)))
|
|
234
|
+
|
|
235
|
+
# -- lifecycle --------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
def close(self) -> None:
|
|
238
|
+
if self._owns_client:
|
|
239
|
+
self._http.close()
|
|
240
|
+
|
|
241
|
+
def __enter__(self) -> AnyAPI:
|
|
242
|
+
return self
|
|
243
|
+
|
|
244
|
+
def __exit__(self, *exc: object) -> None:
|
|
245
|
+
self.close()
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def agent_signup(
|
|
249
|
+
*,
|
|
250
|
+
base_url: str = _DEFAULT_BASE_URL,
|
|
251
|
+
sponsor_email: str | None = None,
|
|
252
|
+
label: str | None = None,
|
|
253
|
+
) -> AgentSignupResult:
|
|
254
|
+
"""Self-signup for an API key with no auth (SPEC 3.1, POST /agent/signup)."""
|
|
255
|
+
body = _account.signup_request(sponsor_email, label)
|
|
256
|
+
with httpx.Client(timeout=60.0) as http:
|
|
257
|
+
response = http.post(
|
|
258
|
+
f"{base_url.rstrip('/')}/agent/signup",
|
|
259
|
+
json=body,
|
|
260
|
+
headers={
|
|
261
|
+
"Content-Type": "application/json",
|
|
262
|
+
"Accept": "application/json",
|
|
263
|
+
},
|
|
264
|
+
)
|
|
265
|
+
request_id = response.headers.get("x-request-id")
|
|
266
|
+
parsed: object = None
|
|
267
|
+
try:
|
|
268
|
+
parsed = response.json()
|
|
269
|
+
except ValueError:
|
|
270
|
+
parsed = None
|
|
271
|
+
# The gateway returns 200 for agent signup; accept 200 only (SPEC S7).
|
|
272
|
+
if response.status_code != 200:
|
|
273
|
+
raise _account.map_error(response.status_code, parsed, request_id)
|
|
274
|
+
return _account.parse_signup(parsed)
|