blitz-api-py 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.
- blitz_api/__init__.py +65 -0
- blitz_api/_base_client.py +191 -0
- blitz_api/_client.py +13 -0
- blitz_api/_client_async.py +143 -0
- blitz_api/_client_sync.py +145 -0
- blitz_api/_compat.py +26 -0
- blitz_api/_constants.py +36 -0
- blitz_api/_exceptions.py +113 -0
- blitz_api/_pagination_async.py +128 -0
- blitz_api/_pagination_base.py +52 -0
- blitz_api/_pagination_sync.py +130 -0
- blitz_api/_rate_limit.py +14 -0
- blitz_api/_rate_limit_async.py +67 -0
- blitz_api/_rate_limit_sync.py +69 -0
- blitz_api/_version.py +1 -0
- blitz_api/py.typed +0 -0
- blitz_api/resources/__init__.py +27 -0
- blitz_api/resources/_async/__init__.py +1 -0
- blitz_api/resources/_async/account.py +27 -0
- blitz_api/resources/_async/enrichment.py +116 -0
- blitz_api/resources/_async/search.py +153 -0
- blitz_api/resources/_async/utils.py +43 -0
- blitz_api/resources/_sync/__init__.py +3 -0
- blitz_api/resources/_sync/account.py +29 -0
- blitz_api/resources/_sync/enrichment.py +118 -0
- blitz_api/resources/_sync/search.py +155 -0
- blitz_api/resources/_sync/utils.py +45 -0
- blitz_api/types/__init__.py +108 -0
- blitz_api/types/_models.py +23 -0
- blitz_api/types/account.py +27 -0
- blitz_api/types/enrichment.py +76 -0
- blitz_api/types/enums.py +633 -0
- blitz_api/types/filters.py +130 -0
- blitz_api/types/search.py +36 -0
- blitz_api/types/shared.py +119 -0
- blitz_api/types/utils.py +35 -0
- blitz_api_py-0.1.0.dist-info/METADATA +220 -0
- blitz_api_py-0.1.0.dist-info/RECORD +40 -0
- blitz_api_py-0.1.0.dist-info/WHEEL +4 -0
- blitz_api_py-0.1.0.dist-info/licenses/LICENSE +21 -0
blitz_api/__init__.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Typed Python SDK for the Blitz API.
|
|
2
|
+
|
|
3
|
+
Quickstart::
|
|
4
|
+
|
|
5
|
+
from blitz_api import BlitzAPI
|
|
6
|
+
|
|
7
|
+
client = BlitzAPI() # reads the BLITZ_API_KEY environment variable
|
|
8
|
+
result = client.enrichment.email(
|
|
9
|
+
person_linkedin_url="https://www.linkedin.com/in/example",
|
|
10
|
+
)
|
|
11
|
+
print(result.found, result.email)
|
|
12
|
+
|
|
13
|
+
See https://docs.blitz-api.ai for the full API reference.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from ._client import AsyncBlitzAPI, BlitzAPI
|
|
19
|
+
from ._exceptions import (
|
|
20
|
+
APIConnectionError,
|
|
21
|
+
APIResponseValidationError,
|
|
22
|
+
APIStatusError,
|
|
23
|
+
APITimeoutError,
|
|
24
|
+
AuthenticationError,
|
|
25
|
+
BlitzError,
|
|
26
|
+
InsufficientCreditsError,
|
|
27
|
+
NotFoundError,
|
|
28
|
+
RateLimitError,
|
|
29
|
+
ServerError,
|
|
30
|
+
)
|
|
31
|
+
from ._pagination_async import AsyncCursorPage, AsyncPageNumberPage
|
|
32
|
+
from ._pagination_sync import CursorPage, PageNumberPage
|
|
33
|
+
from ._version import __version__
|
|
34
|
+
from .types import (
|
|
35
|
+
CompanyFilter,
|
|
36
|
+
Industry,
|
|
37
|
+
PeopleFilter,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"__version__",
|
|
42
|
+
# clients
|
|
43
|
+
"BlitzAPI",
|
|
44
|
+
"AsyncBlitzAPI",
|
|
45
|
+
# pagination (returned by the search.* methods)
|
|
46
|
+
"CursorPage",
|
|
47
|
+
"AsyncCursorPage",
|
|
48
|
+
"PageNumberPage",
|
|
49
|
+
"AsyncPageNumberPage",
|
|
50
|
+
# exceptions
|
|
51
|
+
"BlitzError",
|
|
52
|
+
"APIConnectionError",
|
|
53
|
+
"APITimeoutError",
|
|
54
|
+
"APIResponseValidationError",
|
|
55
|
+
"APIStatusError",
|
|
56
|
+
"AuthenticationError",
|
|
57
|
+
"InsufficientCreditsError",
|
|
58
|
+
"NotFoundError",
|
|
59
|
+
"RateLimitError",
|
|
60
|
+
"ServerError",
|
|
61
|
+
# commonly-used types (full set under blitz_api.types)
|
|
62
|
+
"Industry",
|
|
63
|
+
"CompanyFilter",
|
|
64
|
+
"PeopleFilter",
|
|
65
|
+
]
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Shared, IO-free request-pipeline logic for the sync and async clients.
|
|
2
|
+
|
|
3
|
+
The actual network calls live in :mod:`blitz_api._client`; everything here is
|
|
4
|
+
pure so both clients share identical header building, retry decisions, backoff
|
|
5
|
+
computation, error mapping, and response parsing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import random
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from email.utils import parsedate_to_datetime
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from typing import Any, TypeVar, cast
|
|
16
|
+
from urllib.parse import urljoin
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
from pydantic import ValidationError
|
|
20
|
+
|
|
21
|
+
from . import _constants as C
|
|
22
|
+
from ._exceptions import (
|
|
23
|
+
APIResponseValidationError,
|
|
24
|
+
APIStatusError,
|
|
25
|
+
AuthenticationError,
|
|
26
|
+
BlitzError,
|
|
27
|
+
InsufficientCreditsError,
|
|
28
|
+
NotFoundError,
|
|
29
|
+
RateLimitError,
|
|
30
|
+
ServerError,
|
|
31
|
+
)
|
|
32
|
+
from .types._models import BlitzModel
|
|
33
|
+
|
|
34
|
+
ResponseT = TypeVar("ResponseT", bound=BlitzModel)
|
|
35
|
+
|
|
36
|
+
# Status code -> exception class. Anything not listed that is still non-2xx
|
|
37
|
+
# falls back to a generic APIStatusError (or ServerError for any 5xx).
|
|
38
|
+
_STATUS_EXCEPTIONS: dict[int, type[APIStatusError]] = {
|
|
39
|
+
401: AuthenticationError,
|
|
40
|
+
402: InsufficientCreditsError,
|
|
41
|
+
404: NotFoundError,
|
|
42
|
+
429: RateLimitError,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def to_jsonable(value: Any) -> Any:
|
|
47
|
+
"""Recursively prepare a request payload for JSON serialization.
|
|
48
|
+
|
|
49
|
+
Resolves :class:`enum.Enum` members to their values and drops ``None`` so
|
|
50
|
+
that optional, unset fields are simply omitted from the request body.
|
|
51
|
+
"""
|
|
52
|
+
if isinstance(value, Enum):
|
|
53
|
+
return value.value
|
|
54
|
+
if isinstance(value, dict):
|
|
55
|
+
mapping = cast("dict[str, Any]", value)
|
|
56
|
+
return {k: to_jsonable(v) for k, v in mapping.items() if v is not None}
|
|
57
|
+
if isinstance(value, (list, tuple)):
|
|
58
|
+
sequence = cast("list[Any]", value)
|
|
59
|
+
return [to_jsonable(v) for v in sequence]
|
|
60
|
+
return value
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _retry_after_seconds(raw: str | None) -> float:
|
|
64
|
+
"""Parse a ``Retry-After`` header (delta-seconds or HTTP-date) into seconds.
|
|
65
|
+
|
|
66
|
+
Falls back to :data:`DEFAULT_RETRY_AFTER_SECONDS` when the header is absent or
|
|
67
|
+
unparseable. The caller clamps the result to a maximum.
|
|
68
|
+
"""
|
|
69
|
+
if not raw:
|
|
70
|
+
return C.DEFAULT_RETRY_AFTER_SECONDS
|
|
71
|
+
raw = raw.strip()
|
|
72
|
+
try:
|
|
73
|
+
return max(0.0, float(raw))
|
|
74
|
+
except ValueError:
|
|
75
|
+
pass
|
|
76
|
+
try:
|
|
77
|
+
when = parsedate_to_datetime(raw)
|
|
78
|
+
except (TypeError, ValueError):
|
|
79
|
+
return C.DEFAULT_RETRY_AFTER_SECONDS
|
|
80
|
+
if when.tzinfo is None:
|
|
81
|
+
when = when.replace(tzinfo=timezone.utc)
|
|
82
|
+
return max(0.0, (when - datetime.now(timezone.utc)).total_seconds())
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class BaseClient:
|
|
86
|
+
"""Configuration and pure helpers shared by both clients."""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
*,
|
|
91
|
+
api_key: str | None,
|
|
92
|
+
base_url: str,
|
|
93
|
+
max_retries: int,
|
|
94
|
+
) -> None:
|
|
95
|
+
resolved = api_key if api_key is not None else os.environ.get(C.API_KEY_ENV_VAR)
|
|
96
|
+
if not resolved:
|
|
97
|
+
raise BlitzError(
|
|
98
|
+
"No API key provided. Pass api_key=... or set the "
|
|
99
|
+
f"{C.API_KEY_ENV_VAR} environment variable."
|
|
100
|
+
)
|
|
101
|
+
self.api_key = resolved
|
|
102
|
+
self.base_url = base_url.rstrip("/") + "/"
|
|
103
|
+
self.max_retries = max_retries
|
|
104
|
+
|
|
105
|
+
# -- request shaping -------------------------------------------------
|
|
106
|
+
|
|
107
|
+
def _build_url(self, path: str) -> str:
|
|
108
|
+
return urljoin(self.base_url, path.lstrip("/"))
|
|
109
|
+
|
|
110
|
+
def _build_headers(self) -> dict[str, str]:
|
|
111
|
+
return {
|
|
112
|
+
C.API_KEY_HEADER: self.api_key,
|
|
113
|
+
"Content-Type": "application/json",
|
|
114
|
+
"Accept": "application/json",
|
|
115
|
+
"User-Agent": C.USER_AGENT,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
# -- retry policy ----------------------------------------------------
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def _should_retry(status_code: int) -> bool:
|
|
122
|
+
return status_code == 429 or status_code >= 500
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def _should_retry_exception(exc: httpx.RequestError) -> bool:
|
|
126
|
+
"""Whether a transport-level error is safe to retry.
|
|
127
|
+
|
|
128
|
+
Only failures where the request never reached the server are retried, so a
|
|
129
|
+
retry cannot double-process (or double-bill) a request. A connect timeout,
|
|
130
|
+
connection error, or pool timeout means nothing was sent. A *read* or *write*
|
|
131
|
+
timeout means the request may already have been received and processed by the
|
|
132
|
+
server — retrying a billable POST there could charge the caller twice — so
|
|
133
|
+
those are surfaced immediately instead.
|
|
134
|
+
"""
|
|
135
|
+
return isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout))
|
|
136
|
+
|
|
137
|
+
def _backoff_seconds(self, attempt: int) -> float:
|
|
138
|
+
"""Exponential backoff with full jitter (attempt starts at 1)."""
|
|
139
|
+
base = min(8.0, 0.5 * 2.0 ** (attempt - 1))
|
|
140
|
+
return base + random.uniform(0.0, 0.5)
|
|
141
|
+
|
|
142
|
+
def _retry_delay(self, response: httpx.Response, attempt: int) -> float:
|
|
143
|
+
if response.status_code == 429:
|
|
144
|
+
seconds = _retry_after_seconds(response.headers.get("retry-after"))
|
|
145
|
+
return min(seconds, C.MAX_RETRY_WAIT_SECONDS)
|
|
146
|
+
return self._backoff_seconds(attempt)
|
|
147
|
+
|
|
148
|
+
# -- response handling ----------------------------------------------
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def _parse_body(response: httpx.Response) -> Any:
|
|
152
|
+
try:
|
|
153
|
+
return response.json()
|
|
154
|
+
except ValueError:
|
|
155
|
+
return response.text
|
|
156
|
+
|
|
157
|
+
def _make_status_error(self, response: httpx.Response) -> APIStatusError:
|
|
158
|
+
body: Any = self._parse_body(response)
|
|
159
|
+
message: str | None = None
|
|
160
|
+
if isinstance(body, dict):
|
|
161
|
+
mapping = cast("dict[str, Any]", body)
|
|
162
|
+
raw = mapping.get("message") or mapping.get("error")
|
|
163
|
+
if isinstance(raw, str):
|
|
164
|
+
message = raw
|
|
165
|
+
if message is None:
|
|
166
|
+
message = f"HTTP {response.status_code} from {response.request.url}"
|
|
167
|
+
|
|
168
|
+
exc_class = _STATUS_EXCEPTIONS.get(response.status_code)
|
|
169
|
+
if exc_class is None:
|
|
170
|
+
exc_class = ServerError if response.status_code >= 500 else APIStatusError
|
|
171
|
+
return exc_class(message, response=response, body=cast("Any", body))
|
|
172
|
+
|
|
173
|
+
@staticmethod
|
|
174
|
+
def _parse_model(response: httpx.Response, cast_to: type[ResponseT]) -> ResponseT:
|
|
175
|
+
try:
|
|
176
|
+
data = response.json()
|
|
177
|
+
except ValueError as exc:
|
|
178
|
+
content_type = response.headers.get("content-type", "an unknown content type")
|
|
179
|
+
raise APIResponseValidationError(
|
|
180
|
+
f"Expected a JSON response body from {response.request.url}, got {content_type}.",
|
|
181
|
+
response=response,
|
|
182
|
+
) from exc
|
|
183
|
+
try:
|
|
184
|
+
return cast_to.model_validate(data)
|
|
185
|
+
except ValidationError as exc:
|
|
186
|
+
# Parametrized generics (e.g. CursorPage[Person]) may lack ``__name__``.
|
|
187
|
+
name = getattr(cast_to, "__name__", str(cast_to))
|
|
188
|
+
raise APIResponseValidationError(
|
|
189
|
+
f"Response body did not match {name}.",
|
|
190
|
+
response=response,
|
|
191
|
+
) from exc
|
blitz_api/_client.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""The public Blitz API clients: :class:`BlitzAPI` (sync) and :class:`AsyncBlitzAPI`.
|
|
2
|
+
|
|
3
|
+
The async client is hand-written in :mod:`blitz_api._client_async`; the sync client in
|
|
4
|
+
:mod:`blitz_api._client_sync` is generated from it by ``scripts/gen_sync.py``. This module
|
|
5
|
+
re-exports both so ``from blitz_api._client import BlitzAPI`` keeps working.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from ._client_async import AsyncBlitzAPI
|
|
11
|
+
from ._client_sync import BlitzAPI
|
|
12
|
+
|
|
13
|
+
__all__ = ["AsyncBlitzAPI", "BlitzAPI"]
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""The Blitz API client implementation (async source; sync is generated alongside)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from functools import cached_property
|
|
6
|
+
from types import TracebackType
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from . import _constants as C
|
|
12
|
+
from ._base_client import BaseClient, ResponseT, to_jsonable
|
|
13
|
+
from ._compat import AsyncSleep, TimeoutParam
|
|
14
|
+
from ._exceptions import APIConnectionError, APITimeoutError
|
|
15
|
+
from ._pagination_base import BasePage
|
|
16
|
+
from ._rate_limit import AsyncRateLimiter
|
|
17
|
+
from .resources import (
|
|
18
|
+
AsyncAccountResource,
|
|
19
|
+
AsyncEnrichmentResource,
|
|
20
|
+
AsyncSearchResource,
|
|
21
|
+
AsyncUtilsResource,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AsyncBlitzAPI(BaseClient):
|
|
26
|
+
"""Client for the Blitz API.
|
|
27
|
+
|
|
28
|
+
``AsyncBlitzAPI`` is the async client and ``BlitzAPI`` is the sync client; both
|
|
29
|
+
expose an identical method surface. See the ``blitz_api`` package docstring for a
|
|
30
|
+
quickstart. (This docstring is shared with the generated sync client, so it stays
|
|
31
|
+
flavour-neutral.)
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
api_key: str | None = None,
|
|
37
|
+
*,
|
|
38
|
+
base_url: str = C.DEFAULT_BASE_URL,
|
|
39
|
+
timeout: float | httpx.Timeout = C.DEFAULT_TIMEOUT,
|
|
40
|
+
max_retries: int = C.DEFAULT_MAX_RETRIES,
|
|
41
|
+
rate_limit_rps: float | None = C.DEFAULT_RATE_LIMIT_RPS,
|
|
42
|
+
http_client: httpx.AsyncClient | None = None,
|
|
43
|
+
sleep: AsyncSleep | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
super().__init__(api_key=api_key, base_url=base_url, max_retries=max_retries)
|
|
46
|
+
if http_client is not None and timeout != C.DEFAULT_TIMEOUT:
|
|
47
|
+
raise ValueError(
|
|
48
|
+
"Pass `timeout` or `http_client`, not both: a supplied http_client carries "
|
|
49
|
+
"its own timeout. Set the timeout on your httpx client instead."
|
|
50
|
+
)
|
|
51
|
+
self._http_client = http_client or httpx.AsyncClient(timeout=timeout)
|
|
52
|
+
self._owns_http_client = http_client is None
|
|
53
|
+
self._rate_limiter = AsyncRateLimiter(rate_limit_rps)
|
|
54
|
+
if sleep is None:
|
|
55
|
+
import asyncio
|
|
56
|
+
|
|
57
|
+
sleep = asyncio.sleep
|
|
58
|
+
self._sleep = sleep
|
|
59
|
+
|
|
60
|
+
async def _request(
|
|
61
|
+
self,
|
|
62
|
+
method: str,
|
|
63
|
+
path: str,
|
|
64
|
+
*,
|
|
65
|
+
body: Any | None,
|
|
66
|
+
cast_to: type[ResponseT],
|
|
67
|
+
timeout: TimeoutParam = None,
|
|
68
|
+
) -> ResponseT:
|
|
69
|
+
url = self._build_url(path)
|
|
70
|
+
headers = self._build_headers()
|
|
71
|
+
json_body = to_jsonable(body) if body is not None else None
|
|
72
|
+
|
|
73
|
+
attempt = 0
|
|
74
|
+
while True:
|
|
75
|
+
await self._rate_limiter.acquire()
|
|
76
|
+
try:
|
|
77
|
+
if timeout is None:
|
|
78
|
+
response = await self._http_client.request(
|
|
79
|
+
method, url, headers=headers, json=json_body
|
|
80
|
+
)
|
|
81
|
+
else:
|
|
82
|
+
response = await self._http_client.request(
|
|
83
|
+
method, url, headers=headers, json=json_body, timeout=timeout
|
|
84
|
+
)
|
|
85
|
+
except httpx.TimeoutException as exc:
|
|
86
|
+
if self._should_retry_exception(exc) and attempt < self.max_retries:
|
|
87
|
+
attempt += 1
|
|
88
|
+
await self._sleep(self._backoff_seconds(attempt))
|
|
89
|
+
continue
|
|
90
|
+
raise APITimeoutError(request=exc.request) from exc
|
|
91
|
+
except httpx.RequestError as exc:
|
|
92
|
+
if self._should_retry_exception(exc) and attempt < self.max_retries:
|
|
93
|
+
attempt += 1
|
|
94
|
+
await self._sleep(self._backoff_seconds(attempt))
|
|
95
|
+
continue
|
|
96
|
+
raise APIConnectionError(
|
|
97
|
+
str(exc) or "Connection error.", request=exc.request
|
|
98
|
+
) from exc
|
|
99
|
+
|
|
100
|
+
if response.is_success:
|
|
101
|
+
result = self._parse_model(response, cast_to)
|
|
102
|
+
if isinstance(result, BasePage):
|
|
103
|
+
result._bind(self, method, path, json_body, timeout)
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
if self._should_retry(response.status_code) and attempt < self.max_retries:
|
|
107
|
+
attempt += 1
|
|
108
|
+
await self._sleep(self._retry_delay(response, attempt))
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
raise self._make_status_error(response)
|
|
112
|
+
|
|
113
|
+
async def close(self) -> None:
|
|
114
|
+
"""Close the underlying HTTP connection pool (if owned by this client)."""
|
|
115
|
+
if self._owns_http_client:
|
|
116
|
+
await self._http_client.aclose()
|
|
117
|
+
|
|
118
|
+
async def __aenter__(self) -> AsyncBlitzAPI:
|
|
119
|
+
return self
|
|
120
|
+
|
|
121
|
+
async def __aexit__(
|
|
122
|
+
self,
|
|
123
|
+
exc_type: type[BaseException] | None,
|
|
124
|
+
exc: BaseException | None,
|
|
125
|
+
tb: TracebackType | None,
|
|
126
|
+
) -> None:
|
|
127
|
+
await self.close()
|
|
128
|
+
|
|
129
|
+
@cached_property
|
|
130
|
+
def account(self) -> AsyncAccountResource:
|
|
131
|
+
return AsyncAccountResource(self)
|
|
132
|
+
|
|
133
|
+
@cached_property
|
|
134
|
+
def search(self) -> AsyncSearchResource:
|
|
135
|
+
return AsyncSearchResource(self)
|
|
136
|
+
|
|
137
|
+
@cached_property
|
|
138
|
+
def enrichment(self) -> AsyncEnrichmentResource:
|
|
139
|
+
return AsyncEnrichmentResource(self)
|
|
140
|
+
|
|
141
|
+
@cached_property
|
|
142
|
+
def utils(self) -> AsyncUtilsResource:
|
|
143
|
+
return AsyncUtilsResource(self)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# This file is @generated by scripts/gen_sync.py from src/blitz_api/_client_async.py.
|
|
2
|
+
# Do not edit by hand — edit the async source and run `python scripts/gen_sync.py`.
|
|
3
|
+
"""The Blitz API client implementation (async source; sync is generated alongside)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from functools import cached_property
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from . import _constants as C
|
|
14
|
+
from ._base_client import BaseClient, ResponseT, to_jsonable
|
|
15
|
+
from ._compat import SyncSleep, TimeoutParam
|
|
16
|
+
from ._exceptions import APIConnectionError, APITimeoutError
|
|
17
|
+
from ._pagination_base import BasePage
|
|
18
|
+
from ._rate_limit import RateLimiter
|
|
19
|
+
from .resources import (
|
|
20
|
+
AccountResource,
|
|
21
|
+
EnrichmentResource,
|
|
22
|
+
SearchResource,
|
|
23
|
+
UtilsResource,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BlitzAPI(BaseClient):
|
|
28
|
+
"""Client for the Blitz API.
|
|
29
|
+
|
|
30
|
+
``AsyncBlitzAPI`` is the async client and ``BlitzAPI`` is the sync client; both
|
|
31
|
+
expose an identical method surface. See the ``blitz_api`` package docstring for a
|
|
32
|
+
quickstart. (This docstring is shared with the generated sync client, so it stays
|
|
33
|
+
flavour-neutral.)
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
api_key: str | None = None,
|
|
39
|
+
*,
|
|
40
|
+
base_url: str = C.DEFAULT_BASE_URL,
|
|
41
|
+
timeout: float | httpx.Timeout = C.DEFAULT_TIMEOUT,
|
|
42
|
+
max_retries: int = C.DEFAULT_MAX_RETRIES,
|
|
43
|
+
rate_limit_rps: float | None = C.DEFAULT_RATE_LIMIT_RPS,
|
|
44
|
+
http_client: httpx.Client | None = None,
|
|
45
|
+
sleep: SyncSleep | None = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
super().__init__(api_key=api_key, base_url=base_url, max_retries=max_retries)
|
|
48
|
+
if http_client is not None and timeout != C.DEFAULT_TIMEOUT:
|
|
49
|
+
raise ValueError(
|
|
50
|
+
"Pass `timeout` or `http_client`, not both: a supplied http_client carries "
|
|
51
|
+
"its own timeout. Set the timeout on your httpx client instead."
|
|
52
|
+
)
|
|
53
|
+
self._http_client = http_client or httpx.Client(timeout=timeout)
|
|
54
|
+
self._owns_http_client = http_client is None
|
|
55
|
+
self._rate_limiter = RateLimiter(rate_limit_rps)
|
|
56
|
+
if sleep is None:
|
|
57
|
+
import time
|
|
58
|
+
|
|
59
|
+
sleep = time.sleep
|
|
60
|
+
self._sleep = sleep
|
|
61
|
+
|
|
62
|
+
def _request(
|
|
63
|
+
self,
|
|
64
|
+
method: str,
|
|
65
|
+
path: str,
|
|
66
|
+
*,
|
|
67
|
+
body: Any | None,
|
|
68
|
+
cast_to: type[ResponseT],
|
|
69
|
+
timeout: TimeoutParam = None,
|
|
70
|
+
) -> ResponseT:
|
|
71
|
+
url = self._build_url(path)
|
|
72
|
+
headers = self._build_headers()
|
|
73
|
+
json_body = to_jsonable(body) if body is not None else None
|
|
74
|
+
|
|
75
|
+
attempt = 0
|
|
76
|
+
while True:
|
|
77
|
+
self._rate_limiter.acquire()
|
|
78
|
+
try:
|
|
79
|
+
if timeout is None:
|
|
80
|
+
response = self._http_client.request(
|
|
81
|
+
method, url, headers=headers, json=json_body
|
|
82
|
+
)
|
|
83
|
+
else:
|
|
84
|
+
response = self._http_client.request(
|
|
85
|
+
method, url, headers=headers, json=json_body, timeout=timeout
|
|
86
|
+
)
|
|
87
|
+
except httpx.TimeoutException as exc:
|
|
88
|
+
if self._should_retry_exception(exc) and attempt < self.max_retries:
|
|
89
|
+
attempt += 1
|
|
90
|
+
self._sleep(self._backoff_seconds(attempt))
|
|
91
|
+
continue
|
|
92
|
+
raise APITimeoutError(request=exc.request) from exc
|
|
93
|
+
except httpx.RequestError as exc:
|
|
94
|
+
if self._should_retry_exception(exc) and attempt < self.max_retries:
|
|
95
|
+
attempt += 1
|
|
96
|
+
self._sleep(self._backoff_seconds(attempt))
|
|
97
|
+
continue
|
|
98
|
+
raise APIConnectionError(
|
|
99
|
+
str(exc) or "Connection error.", request=exc.request
|
|
100
|
+
) from exc
|
|
101
|
+
|
|
102
|
+
if response.is_success:
|
|
103
|
+
result = self._parse_model(response, cast_to)
|
|
104
|
+
if isinstance(result, BasePage):
|
|
105
|
+
result._bind(self, method, path, json_body, timeout)
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
if self._should_retry(response.status_code) and attempt < self.max_retries:
|
|
109
|
+
attempt += 1
|
|
110
|
+
self._sleep(self._retry_delay(response, attempt))
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
raise self._make_status_error(response)
|
|
114
|
+
|
|
115
|
+
def close(self) -> None:
|
|
116
|
+
"""Close the underlying HTTP connection pool (if owned by this client)."""
|
|
117
|
+
if self._owns_http_client:
|
|
118
|
+
self._http_client.close()
|
|
119
|
+
|
|
120
|
+
def __enter__(self) -> BlitzAPI:
|
|
121
|
+
return self
|
|
122
|
+
|
|
123
|
+
def __exit__(
|
|
124
|
+
self,
|
|
125
|
+
exc_type: type[BaseException] | None,
|
|
126
|
+
exc: BaseException | None,
|
|
127
|
+
tb: TracebackType | None,
|
|
128
|
+
) -> None:
|
|
129
|
+
self.close()
|
|
130
|
+
|
|
131
|
+
@cached_property
|
|
132
|
+
def account(self) -> AccountResource:
|
|
133
|
+
return AccountResource(self)
|
|
134
|
+
|
|
135
|
+
@cached_property
|
|
136
|
+
def search(self) -> SearchResource:
|
|
137
|
+
return SearchResource(self)
|
|
138
|
+
|
|
139
|
+
@cached_property
|
|
140
|
+
def enrichment(self) -> EnrichmentResource:
|
|
141
|
+
return EnrichmentResource(self)
|
|
142
|
+
|
|
143
|
+
@cached_property
|
|
144
|
+
def utils(self) -> UtilsResource:
|
|
145
|
+
return UtilsResource(self)
|
blitz_api/_compat.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Type aliases that differ between the async source and the generated sync code.
|
|
2
|
+
|
|
3
|
+
The async client takes an awaitable sleep callable; the sync client takes a plain
|
|
4
|
+
one. ``scripts/gen_sync.py`` rewrites the token ``AsyncSleep`` to ``SyncSleep`` when
|
|
5
|
+
it transliterates the async source, so both flavours stay precisely typed without the
|
|
6
|
+
generator having to rewrite a subscripted generic (which token-level renaming can't do).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Awaitable, Callable
|
|
12
|
+
from typing import TYPE_CHECKING, Union
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
#: Sleep callable accepted by ``AsyncBlitzAPI`` (e.g. ``asyncio.sleep``).
|
|
18
|
+
AsyncSleep = Callable[[float], Awaitable[None]]
|
|
19
|
+
|
|
20
|
+
#: Sleep callable accepted by ``BlitzAPI`` (e.g. ``time.sleep``).
|
|
21
|
+
SyncSleep = Callable[[float], None]
|
|
22
|
+
|
|
23
|
+
#: Per-call timeout override: a number of seconds, an :class:`httpx.Timeout`, or
|
|
24
|
+
#: ``None`` to fall back to the client-wide timeout. (Defined with ``Union`` and a
|
|
25
|
+
#: forward reference so importing it never forces ``httpx`` to load at runtime.)
|
|
26
|
+
TimeoutParam = Union[float, "httpx.Timeout", None]
|
blitz_api/_constants.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Internal defaults shared by the sync and async clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._version import __version__
|
|
6
|
+
|
|
7
|
+
#: Production API base URL.
|
|
8
|
+
DEFAULT_BASE_URL = "https://api.blitz-api.ai"
|
|
9
|
+
|
|
10
|
+
#: Environment variable read when ``api_key`` is not passed explicitly.
|
|
11
|
+
API_KEY_ENV_VAR = "BLITZ_API_KEY"
|
|
12
|
+
|
|
13
|
+
#: Header carrying the API key (Blitz uses ``x-api-key``, not ``Authorization``).
|
|
14
|
+
API_KEY_HEADER = "x-api-key"
|
|
15
|
+
|
|
16
|
+
#: Default per-request timeout, in seconds.
|
|
17
|
+
DEFAULT_TIMEOUT = 30.0
|
|
18
|
+
|
|
19
|
+
#: Number of retries (in addition to the first attempt) for transient failures.
|
|
20
|
+
DEFAULT_MAX_RETRIES = 3
|
|
21
|
+
|
|
22
|
+
#: Default client-side rate limit. The API allows 5 req/s on every plan; the
|
|
23
|
+
#: per-key value is discoverable via ``client.account.key_info()``.
|
|
24
|
+
DEFAULT_RATE_LIMIT_RPS = 5.0
|
|
25
|
+
|
|
26
|
+
#: Seconds to wait after a 429 when the response has no ``Retry-After`` header.
|
|
27
|
+
#: Matches the behaviour of the official reference client.
|
|
28
|
+
DEFAULT_RETRY_AFTER_SECONDS = 60.0
|
|
29
|
+
|
|
30
|
+
#: Upper bound (seconds) on any single retry wait, including a server-supplied
|
|
31
|
+
#: ``Retry-After``. A safety clamp so a pathological header value (e.g.
|
|
32
|
+
#: ``Retry-After: 86400``) can't make the client sleep for hours.
|
|
33
|
+
MAX_RETRY_WAIT_SECONDS = 120.0
|
|
34
|
+
|
|
35
|
+
#: Sent as the ``User-Agent`` header so requests are attributable to this SDK.
|
|
36
|
+
USER_AGENT = f"blitz-api-py/{__version__}"
|