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/_exceptions.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Exception hierarchy raised by the Blitz API SDK.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
BlitzError
|
|
6
|
+
├── APIConnectionError # request never completed (network / timeout)
|
|
7
|
+
│ └── APITimeoutError
|
|
8
|
+
├── APIResponseValidationError # a 2xx body was not valid JSON / not the expected shape
|
|
9
|
+
└── APIStatusError # a non-2xx HTTP response was received
|
|
10
|
+
├── AuthenticationError # 401
|
|
11
|
+
├── InsufficientCreditsError # 402
|
|
12
|
+
├── NotFoundError # 404
|
|
13
|
+
├── RateLimitError # 429 (after retries are exhausted)
|
|
14
|
+
└── ServerError # 5xx (after retries are exhausted)
|
|
15
|
+
|
|
16
|
+
Catch :class:`BlitzError` to handle anything this SDK raises.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import TYPE_CHECKING, Any
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
import httpx
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BlitzError(Exception):
|
|
28
|
+
"""Base class for every error raised by this SDK."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class APIConnectionError(BlitzError):
|
|
32
|
+
"""The request could not be completed (connection error, DNS, etc.)."""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self, message: str = "Connection error.", *, request: httpx.Request | None = None
|
|
36
|
+
) -> None:
|
|
37
|
+
super().__init__(message)
|
|
38
|
+
self.request = request
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class APITimeoutError(APIConnectionError):
|
|
42
|
+
"""The request timed out."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, *, request: httpx.Request | None = None) -> None:
|
|
45
|
+
super().__init__("Request timed out.", request=request)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class APIResponseValidationError(BlitzError):
|
|
49
|
+
"""A 2xx response body could not be parsed into the expected model.
|
|
50
|
+
|
|
51
|
+
Raised when the server returns success but the body is not valid JSON (e.g. an
|
|
52
|
+
HTML error page from a proxy, or an empty body) or does not match the response
|
|
53
|
+
model. This keeps a successful-status surprise inside the :class:`BlitzError`
|
|
54
|
+
hierarchy instead of leaking a raw ``json``/``pydantic`` error.
|
|
55
|
+
|
|
56
|
+
Attributes:
|
|
57
|
+
response: The underlying :class:`httpx.Response`.
|
|
58
|
+
status_code: The HTTP status code (a 2xx).
|
|
59
|
+
request_id: The value of the ``x-request-id`` response header, if any.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, message: str, *, response: httpx.Response) -> None:
|
|
63
|
+
super().__init__(message)
|
|
64
|
+
self.message = message
|
|
65
|
+
self.response = response
|
|
66
|
+
self.status_code = response.status_code
|
|
67
|
+
self.request_id = response.headers.get("x-request-id")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class APIStatusError(BlitzError):
|
|
71
|
+
"""The API returned a non-success HTTP status code.
|
|
72
|
+
|
|
73
|
+
Attributes:
|
|
74
|
+
status_code: The HTTP status code of the response.
|
|
75
|
+
body: The parsed JSON body, or the raw text if it was not JSON.
|
|
76
|
+
message: A human-readable message extracted from the body when present.
|
|
77
|
+
request_id: The value of the ``x-request-id`` response header, if any.
|
|
78
|
+
response: The underlying :class:`httpx.Response`.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
message: str,
|
|
84
|
+
*,
|
|
85
|
+
response: httpx.Response,
|
|
86
|
+
body: Any | None = None,
|
|
87
|
+
) -> None:
|
|
88
|
+
super().__init__(message)
|
|
89
|
+
self.message = message
|
|
90
|
+
self.response = response
|
|
91
|
+
self.status_code = response.status_code
|
|
92
|
+
self.body = body
|
|
93
|
+
self.request_id = response.headers.get("x-request-id")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class AuthenticationError(APIStatusError):
|
|
97
|
+
"""401 — the API key is missing or invalid."""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class InsufficientCreditsError(APIStatusError):
|
|
101
|
+
"""402 — the key is valid but the account is out of credits."""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class NotFoundError(APIStatusError):
|
|
105
|
+
"""404 — the API key or resource does not exist."""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class RateLimitError(APIStatusError):
|
|
109
|
+
"""429 — too many requests (raised only after retries are exhausted)."""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class ServerError(APIStatusError):
|
|
113
|
+
"""5xx — the API failed (raised only after retries are exhausted)."""
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Auto-paginating page objects (async source).
|
|
2
|
+
|
|
3
|
+
The sync versions in ``_pagination_sync.py`` are generated from this file by
|
|
4
|
+
``scripts/gen_sync.py`` (it strips ``async``/``await`` and renames the ``Async*`` /
|
|
5
|
+
``AsyncIterator`` / ``__aiter__`` tokens). Edit this file, then run the generator.
|
|
6
|
+
|
|
7
|
+
``search.people``/``search.companies`` return the cursor-based page class and
|
|
8
|
+
``search.employee_finder`` the page-number-based one. Both auto-paginate over every item
|
|
9
|
+
when iterated (``for``/``async for``), and also expose ``.auto_paging_iter()``,
|
|
10
|
+
``.iter_pages()``, and ``.get_next_page()``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import AsyncIterator
|
|
16
|
+
from typing import Any, Generic, TypeVar, cast
|
|
17
|
+
|
|
18
|
+
from typing_extensions import Self
|
|
19
|
+
|
|
20
|
+
from ._pagination_base import BasePage
|
|
21
|
+
|
|
22
|
+
ItemT = TypeVar("ItemT")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AsyncPaginator(BasePage, Generic[ItemT]):
|
|
26
|
+
"""Mixes auto-pagination over items/pages onto a concrete page model."""
|
|
27
|
+
|
|
28
|
+
results: list[ItemT] = []
|
|
29
|
+
|
|
30
|
+
# ``__iter__`` (the generated sync twin of this) intentionally overrides
|
|
31
|
+
# ``BaseModel.__iter__`` to yield items instead of (field, value) pairs.
|
|
32
|
+
def __aiter__(self) -> AsyncIterator[ItemT]: # type: ignore[override]
|
|
33
|
+
return self._auto_paging_items()
|
|
34
|
+
|
|
35
|
+
async def _auto_paging_items(self, max_items: int | None = None) -> AsyncIterator[ItemT]:
|
|
36
|
+
page = self
|
|
37
|
+
emitted = 0
|
|
38
|
+
while True:
|
|
39
|
+
for item in page.results:
|
|
40
|
+
yield item
|
|
41
|
+
emitted += 1
|
|
42
|
+
if max_items is not None and emitted >= max_items:
|
|
43
|
+
return
|
|
44
|
+
if not page.has_next_page():
|
|
45
|
+
return
|
|
46
|
+
page = await page._fetch_next()
|
|
47
|
+
|
|
48
|
+
def auto_paging_iter(self, *, max_items: int | None = None) -> AsyncIterator[ItemT]:
|
|
49
|
+
"""Iterate every item across all pages, optionally stopping after ``max_items``."""
|
|
50
|
+
return self._auto_paging_items(max_items)
|
|
51
|
+
|
|
52
|
+
async def iter_pages(self, *, max_pages: int | None = None) -> AsyncIterator[Self]:
|
|
53
|
+
"""Iterate page objects across the result set, optionally capped at ``max_pages``."""
|
|
54
|
+
page = self
|
|
55
|
+
seen = 0
|
|
56
|
+
while True:
|
|
57
|
+
yield page
|
|
58
|
+
seen += 1
|
|
59
|
+
if max_pages is not None and seen >= max_pages:
|
|
60
|
+
return
|
|
61
|
+
if not page.has_next_page():
|
|
62
|
+
return
|
|
63
|
+
page = await page._fetch_next()
|
|
64
|
+
|
|
65
|
+
async def get_next_page(self) -> Self | None:
|
|
66
|
+
"""Fetch the next page, or ``None`` if this is the last one."""
|
|
67
|
+
if not self.has_next_page():
|
|
68
|
+
return None
|
|
69
|
+
return await self._fetch_next()
|
|
70
|
+
|
|
71
|
+
async def _fetch_next(self) -> Self:
|
|
72
|
+
return cast(
|
|
73
|
+
"Self",
|
|
74
|
+
await self._client._request(
|
|
75
|
+
self._method,
|
|
76
|
+
self._path,
|
|
77
|
+
body=self._next_body(),
|
|
78
|
+
cast_to=type(self),
|
|
79
|
+
timeout=self._timeout,
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class AsyncCursorPage(AsyncPaginator[ItemT], Generic[ItemT]):
|
|
85
|
+
"""Cursor-paginated page (``search.people`` / ``search.companies``)."""
|
|
86
|
+
|
|
87
|
+
total_results: int | None = None
|
|
88
|
+
results_length: int | None = None
|
|
89
|
+
max_results: int | None = None
|
|
90
|
+
cursor: str | None = None
|
|
91
|
+
|
|
92
|
+
def has_next_page(self) -> bool:
|
|
93
|
+
# The API terminates the walk by returning ``cursor=null`` on the last page, so a
|
|
94
|
+
# non-null cursor is the single source of truth. We deliberately do NOT also gate
|
|
95
|
+
# on ``results`` being non-empty: a sparse intermediate page (empty results but a
|
|
96
|
+
# valid forward cursor) must keep paging, not silently truncate the result set.
|
|
97
|
+
return bool(self.cursor)
|
|
98
|
+
|
|
99
|
+
def _next_body(self) -> dict[str, Any]:
|
|
100
|
+
return {**self._body, "cursor": self.cursor}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class AsyncPageNumberPage(AsyncPaginator[ItemT], Generic[ItemT]):
|
|
104
|
+
"""Page-number-paginated page (``search.employee_finder``)."""
|
|
105
|
+
|
|
106
|
+
company_linkedin_url: str | None = None
|
|
107
|
+
max_results: int | None = None
|
|
108
|
+
results_length: int | None = None
|
|
109
|
+
page: int | None = None
|
|
110
|
+
total_pages: int | None = None
|
|
111
|
+
|
|
112
|
+
def _current_page(self) -> int:
|
|
113
|
+
# Prefer the page the server echoed; fall back to the page we requested
|
|
114
|
+
# (default 1) so iteration still advances even if the field is absent.
|
|
115
|
+
if self.page is not None:
|
|
116
|
+
return self.page
|
|
117
|
+
requested = self._body.get("page")
|
|
118
|
+
return requested if isinstance(requested, int) else 1
|
|
119
|
+
|
|
120
|
+
def has_next_page(self) -> bool:
|
|
121
|
+
return (
|
|
122
|
+
self.total_pages is not None
|
|
123
|
+
and self._current_page() < self.total_pages
|
|
124
|
+
and bool(self.results)
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def _next_body(self) -> dict[str, Any]:
|
|
128
|
+
return {**self._body, "page": self._current_page() + 1}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Shared base for the auto-paginating page models.
|
|
2
|
+
|
|
3
|
+
Field-free pagination *state* and the request context needed to fetch the next page
|
|
4
|
+
live here (no ``async``, so this file is NOT processed by ``scripts/gen_sync.py``). The
|
|
5
|
+
sync vs async iteration surface lives in ``_pagination_async.py`` (source) and
|
|
6
|
+
``_pagination_sync.py`` (generated). Both the sync and async clients import
|
|
7
|
+
:class:`BasePage` for a single, flavour-neutral ``isinstance`` check when binding context.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from pydantic import PrivateAttr
|
|
15
|
+
|
|
16
|
+
from .types._models import BlitzModel
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BasePage(BlitzModel):
|
|
20
|
+
"""Common pagination context. Subclasses add the page fields + iteration."""
|
|
21
|
+
|
|
22
|
+
# Bound by the client after a page is parsed, so the page can fetch the next one.
|
|
23
|
+
_client: Any = PrivateAttr(default=None)
|
|
24
|
+
_method: str = PrivateAttr(default="POST")
|
|
25
|
+
_path: str = PrivateAttr(default="")
|
|
26
|
+
_body: dict[str, Any] = PrivateAttr(default_factory=dict)
|
|
27
|
+
# The per-call timeout override the first page was fetched with, so every
|
|
28
|
+
# subsequent auto-paged request honours it too. ``Any`` (rather than
|
|
29
|
+
# ``TimeoutParam``) keeps this module from importing ``httpx`` at runtime.
|
|
30
|
+
_timeout: Any = PrivateAttr(default=None)
|
|
31
|
+
|
|
32
|
+
def _bind(
|
|
33
|
+
self,
|
|
34
|
+
client: Any,
|
|
35
|
+
method: str,
|
|
36
|
+
path: str,
|
|
37
|
+
body: dict[str, Any] | None,
|
|
38
|
+
timeout: Any = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
self._client = client
|
|
41
|
+
self._method = method
|
|
42
|
+
self._path = path
|
|
43
|
+
self._body = dict(body) if body else {}
|
|
44
|
+
self._timeout = timeout
|
|
45
|
+
|
|
46
|
+
def has_next_page(self) -> bool:
|
|
47
|
+
"""Whether another page is available (overridden per pagination style)."""
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
def _next_body(self) -> dict[str, Any]:
|
|
51
|
+
"""The request body for the next page (overridden per pagination style)."""
|
|
52
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# This file is @generated by scripts/gen_sync.py from src/blitz_api/_pagination_async.py.
|
|
2
|
+
# Do not edit by hand — edit the async source and run `python scripts/gen_sync.py`.
|
|
3
|
+
"""Auto-paginating page objects (async source).
|
|
4
|
+
|
|
5
|
+
The sync versions in ``_pagination_sync.py`` are generated from this file by
|
|
6
|
+
``scripts/gen_sync.py`` (it strips ``async``/``await`` and renames the ``Async*`` /
|
|
7
|
+
``AsyncIterator`` / ``__aiter__`` tokens). Edit this file, then run the generator.
|
|
8
|
+
|
|
9
|
+
``search.people``/``search.companies`` return the cursor-based page class and
|
|
10
|
+
``search.employee_finder`` the page-number-based one. Both auto-paginate over every item
|
|
11
|
+
when iterated (``for``/``async for``), and also expose ``.auto_paging_iter()``,
|
|
12
|
+
``.iter_pages()``, and ``.get_next_page()``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Iterator
|
|
18
|
+
from typing import Any, Generic, TypeVar, cast
|
|
19
|
+
|
|
20
|
+
from typing_extensions import Self
|
|
21
|
+
|
|
22
|
+
from ._pagination_base import BasePage
|
|
23
|
+
|
|
24
|
+
ItemT = TypeVar("ItemT")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Paginator(BasePage, Generic[ItemT]):
|
|
28
|
+
"""Mixes auto-pagination over items/pages onto a concrete page model."""
|
|
29
|
+
|
|
30
|
+
results: list[ItemT] = []
|
|
31
|
+
|
|
32
|
+
# ``__iter__`` (the generated sync twin of this) intentionally overrides
|
|
33
|
+
# ``BaseModel.__iter__`` to yield items instead of (field, value) pairs.
|
|
34
|
+
def __iter__(self) -> Iterator[ItemT]: # type: ignore[override]
|
|
35
|
+
return self._auto_paging_items()
|
|
36
|
+
|
|
37
|
+
def _auto_paging_items(self, max_items: int | None = None) -> Iterator[ItemT]:
|
|
38
|
+
page = self
|
|
39
|
+
emitted = 0
|
|
40
|
+
while True:
|
|
41
|
+
for item in page.results:
|
|
42
|
+
yield item
|
|
43
|
+
emitted += 1
|
|
44
|
+
if max_items is not None and emitted >= max_items:
|
|
45
|
+
return
|
|
46
|
+
if not page.has_next_page():
|
|
47
|
+
return
|
|
48
|
+
page = page._fetch_next()
|
|
49
|
+
|
|
50
|
+
def auto_paging_iter(self, *, max_items: int | None = None) -> Iterator[ItemT]:
|
|
51
|
+
"""Iterate every item across all pages, optionally stopping after ``max_items``."""
|
|
52
|
+
return self._auto_paging_items(max_items)
|
|
53
|
+
|
|
54
|
+
def iter_pages(self, *, max_pages: int | None = None) -> Iterator[Self]:
|
|
55
|
+
"""Iterate page objects across the result set, optionally capped at ``max_pages``."""
|
|
56
|
+
page = self
|
|
57
|
+
seen = 0
|
|
58
|
+
while True:
|
|
59
|
+
yield page
|
|
60
|
+
seen += 1
|
|
61
|
+
if max_pages is not None and seen >= max_pages:
|
|
62
|
+
return
|
|
63
|
+
if not page.has_next_page():
|
|
64
|
+
return
|
|
65
|
+
page = page._fetch_next()
|
|
66
|
+
|
|
67
|
+
def get_next_page(self) -> Self | None:
|
|
68
|
+
"""Fetch the next page, or ``None`` if this is the last one."""
|
|
69
|
+
if not self.has_next_page():
|
|
70
|
+
return None
|
|
71
|
+
return self._fetch_next()
|
|
72
|
+
|
|
73
|
+
def _fetch_next(self) -> Self:
|
|
74
|
+
return cast(
|
|
75
|
+
"Self",
|
|
76
|
+
self._client._request(
|
|
77
|
+
self._method,
|
|
78
|
+
self._path,
|
|
79
|
+
body=self._next_body(),
|
|
80
|
+
cast_to=type(self),
|
|
81
|
+
timeout=self._timeout,
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class CursorPage(Paginator[ItemT], Generic[ItemT]):
|
|
87
|
+
"""Cursor-paginated page (``search.people`` / ``search.companies``)."""
|
|
88
|
+
|
|
89
|
+
total_results: int | None = None
|
|
90
|
+
results_length: int | None = None
|
|
91
|
+
max_results: int | None = None
|
|
92
|
+
cursor: str | None = None
|
|
93
|
+
|
|
94
|
+
def has_next_page(self) -> bool:
|
|
95
|
+
# The API terminates the walk by returning ``cursor=null`` on the last page, so a
|
|
96
|
+
# non-null cursor is the single source of truth. We deliberately do NOT also gate
|
|
97
|
+
# on ``results`` being non-empty: a sparse intermediate page (empty results but a
|
|
98
|
+
# valid forward cursor) must keep paging, not silently truncate the result set.
|
|
99
|
+
return bool(self.cursor)
|
|
100
|
+
|
|
101
|
+
def _next_body(self) -> dict[str, Any]:
|
|
102
|
+
return {**self._body, "cursor": self.cursor}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class PageNumberPage(Paginator[ItemT], Generic[ItemT]):
|
|
106
|
+
"""Page-number-paginated page (``search.employee_finder``)."""
|
|
107
|
+
|
|
108
|
+
company_linkedin_url: str | None = None
|
|
109
|
+
max_results: int | None = None
|
|
110
|
+
results_length: int | None = None
|
|
111
|
+
page: int | None = None
|
|
112
|
+
total_pages: int | None = None
|
|
113
|
+
|
|
114
|
+
def _current_page(self) -> int:
|
|
115
|
+
# Prefer the page the server echoed; fall back to the page we requested
|
|
116
|
+
# (default 1) so iteration still advances even if the field is absent.
|
|
117
|
+
if self.page is not None:
|
|
118
|
+
return self.page
|
|
119
|
+
requested = self._body.get("page")
|
|
120
|
+
return requested if isinstance(requested, int) else 1
|
|
121
|
+
|
|
122
|
+
def has_next_page(self) -> bool:
|
|
123
|
+
return (
|
|
124
|
+
self.total_pages is not None
|
|
125
|
+
and self._current_page() < self.total_pages
|
|
126
|
+
and bool(self.results)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def _next_body(self) -> dict[str, Any]:
|
|
130
|
+
return {**self._body, "page": self._current_page() + 1}
|
blitz_api/_rate_limit.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Client-side rate limiters: re-exports the async source and its generated sync twin.
|
|
2
|
+
|
|
3
|
+
The implementations live in ``_rate_limit_async.py`` (hand-written source) and
|
|
4
|
+
``_rate_limit_sync.py`` (generated from it by ``scripts/gen_sync.py``); this module just
|
|
5
|
+
re-exports both so ``from blitz_api._rate_limit import RateLimiter`` keeps working —
|
|
6
|
+
mirroring how ``_client.py`` re-exports the two clients.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from ._rate_limit_async import AsyncRateLimiter
|
|
12
|
+
from ._rate_limit_sync import RateLimiter
|
|
13
|
+
|
|
14
|
+
__all__ = ["AsyncRateLimiter", "RateLimiter"]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Client-side sliding-window rate limiter (async source; sync twin generated).
|
|
2
|
+
|
|
3
|
+
The API enforces a per-key request rate (5 req/s by default). This limiter throttles
|
|
4
|
+
outgoing requests *before* they are sent so a single client instance stays under the
|
|
5
|
+
limit proactively; the server-side 429 retry path is the backstop for bursts across
|
|
6
|
+
processes.
|
|
7
|
+
|
|
8
|
+
The algorithm is a sliding window: at most ``rps`` requests may begin in any rolling
|
|
9
|
+
one-second window. This matches the Blitz docs ("max 5 requests per 1000 ms") and the
|
|
10
|
+
official reference client. Unlike a token bucket — whose initial capacity lets a fresh
|
|
11
|
+
client fire ``rps`` requests *and* refill within the same second, briefly doubling the
|
|
12
|
+
rate — a sliding window never exceeds ``rps`` in any one-second window, including the
|
|
13
|
+
first. ``rps`` tracks the API's integer ``max_requests_per_seconds``; a fractional value
|
|
14
|
+
is floored to a per-second integer budget (minimum 1). The clock and sleep are injectable
|
|
15
|
+
so tests can drive them with a fake clock.
|
|
16
|
+
|
|
17
|
+
The synchronous ``RateLimiter`` in ``_rate_limit_sync.py`` is generated from this file by
|
|
18
|
+
``scripts/gen_sync.py`` — edit this source, never the generated twin.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import time
|
|
25
|
+
from collections import deque
|
|
26
|
+
from collections.abc import Callable
|
|
27
|
+
|
|
28
|
+
from ._compat import AsyncSleep
|
|
29
|
+
|
|
30
|
+
#: Width of the rolling window, in seconds.
|
|
31
|
+
_WINDOW = 1.0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AsyncRateLimiter:
|
|
35
|
+
"""Sliding-window limiter, safe to share across concurrent callers."""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
rps: float | None,
|
|
40
|
+
*,
|
|
41
|
+
monotonic: Callable[[], float] = time.monotonic,
|
|
42
|
+
sleep: AsyncSleep = asyncio.sleep,
|
|
43
|
+
) -> None:
|
|
44
|
+
self.rps = rps
|
|
45
|
+
# Integer request budget per window. ``rps`` is typed ``float`` for ergonomics, but
|
|
46
|
+
# the deque admission below counts whole requests, so floor it (min 1 when enabled).
|
|
47
|
+
self._budget = max(1, int(rps)) if rps else 0
|
|
48
|
+
self._sends: deque[float] = deque()
|
|
49
|
+
self._lock = asyncio.Lock()
|
|
50
|
+
self._monotonic = monotonic
|
|
51
|
+
self._sleep = sleep
|
|
52
|
+
|
|
53
|
+
async def acquire(self) -> None:
|
|
54
|
+
"""Wait until a request may be sent under the rolling-window limit."""
|
|
55
|
+
if not self.rps:
|
|
56
|
+
return
|
|
57
|
+
while True:
|
|
58
|
+
async with self._lock:
|
|
59
|
+
now = self._monotonic()
|
|
60
|
+
cutoff = now - _WINDOW
|
|
61
|
+
while self._sends and self._sends[0] <= cutoff:
|
|
62
|
+
self._sends.popleft()
|
|
63
|
+
if len(self._sends) < self._budget:
|
|
64
|
+
self._sends.append(now)
|
|
65
|
+
return
|
|
66
|
+
wait = self._sends[0] + _WINDOW - now
|
|
67
|
+
await self._sleep(max(0.0, wait))
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# This file is @generated by scripts/gen_sync.py from src/blitz_api/_rate_limit_async.py.
|
|
2
|
+
# Do not edit by hand — edit the async source and run `python scripts/gen_sync.py`.
|
|
3
|
+
"""Client-side sliding-window rate limiter (async source; sync twin generated).
|
|
4
|
+
|
|
5
|
+
The API enforces a per-key request rate (5 req/s by default). This limiter throttles
|
|
6
|
+
outgoing requests *before* they are sent so a single client instance stays under the
|
|
7
|
+
limit proactively; the server-side 429 retry path is the backstop for bursts across
|
|
8
|
+
processes.
|
|
9
|
+
|
|
10
|
+
The algorithm is a sliding window: at most ``rps`` requests may begin in any rolling
|
|
11
|
+
one-second window. This matches the Blitz docs ("max 5 requests per 1000 ms") and the
|
|
12
|
+
official reference client. Unlike a token bucket — whose initial capacity lets a fresh
|
|
13
|
+
client fire ``rps`` requests *and* refill within the same second, briefly doubling the
|
|
14
|
+
rate — a sliding window never exceeds ``rps`` in any one-second window, including the
|
|
15
|
+
first. ``rps`` tracks the API's integer ``max_requests_per_seconds``; a fractional value
|
|
16
|
+
is floored to a per-second integer budget (minimum 1). The clock and sleep are injectable
|
|
17
|
+
so tests can drive them with a fake clock.
|
|
18
|
+
|
|
19
|
+
The synchronous ``RateLimiter`` in ``_rate_limit_sync.py`` is generated from this file by
|
|
20
|
+
``scripts/gen_sync.py`` — edit this source, never the generated twin.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
from collections import deque
|
|
28
|
+
from collections.abc import Callable
|
|
29
|
+
|
|
30
|
+
from ._compat import SyncSleep
|
|
31
|
+
|
|
32
|
+
#: Width of the rolling window, in seconds.
|
|
33
|
+
_WINDOW = 1.0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class RateLimiter:
|
|
37
|
+
"""Sliding-window limiter, safe to share across concurrent callers."""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
rps: float | None,
|
|
42
|
+
*,
|
|
43
|
+
monotonic: Callable[[], float] = time.monotonic,
|
|
44
|
+
sleep: SyncSleep = time.sleep,
|
|
45
|
+
) -> None:
|
|
46
|
+
self.rps = rps
|
|
47
|
+
# Integer request budget per window. ``rps`` is typed ``float`` for ergonomics, but
|
|
48
|
+
# the deque admission below counts whole requests, so floor it (min 1 when enabled).
|
|
49
|
+
self._budget = max(1, int(rps)) if rps else 0
|
|
50
|
+
self._sends: deque[float] = deque()
|
|
51
|
+
self._lock = threading.Lock()
|
|
52
|
+
self._monotonic = monotonic
|
|
53
|
+
self._sleep = sleep
|
|
54
|
+
|
|
55
|
+
def acquire(self) -> None:
|
|
56
|
+
"""Wait until a request may be sent under the rolling-window limit."""
|
|
57
|
+
if not self.rps:
|
|
58
|
+
return
|
|
59
|
+
while True:
|
|
60
|
+
with self._lock:
|
|
61
|
+
now = self._monotonic()
|
|
62
|
+
cutoff = now - _WINDOW
|
|
63
|
+
while self._sends and self._sends[0] <= cutoff:
|
|
64
|
+
self._sends.popleft()
|
|
65
|
+
if len(self._sends) < self._budget:
|
|
66
|
+
self._sends.append(now)
|
|
67
|
+
return
|
|
68
|
+
wait = self._sends[0] + _WINDOW - now
|
|
69
|
+
self._sleep(max(0.0, wait))
|
blitz_api/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0" # x-release-please-version
|
blitz_api/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Namespaced API resources, grouped by the Blitz API's OpenAPI tags.
|
|
2
|
+
|
|
3
|
+
The ``Async*`` classes are hand-written in ``resources/_async``; the sync classes in
|
|
4
|
+
``resources/_sync`` are generated from them by ``scripts/gen_sync.py``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from ._async.account import AsyncAccountResource
|
|
10
|
+
from ._async.enrichment import AsyncEnrichmentResource
|
|
11
|
+
from ._async.search import AsyncSearchResource
|
|
12
|
+
from ._async.utils import AsyncUtilsResource
|
|
13
|
+
from ._sync.account import AccountResource
|
|
14
|
+
from ._sync.enrichment import EnrichmentResource
|
|
15
|
+
from ._sync.search import SearchResource
|
|
16
|
+
from ._sync.utils import UtilsResource
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"AccountResource",
|
|
20
|
+
"AsyncAccountResource",
|
|
21
|
+
"SearchResource",
|
|
22
|
+
"AsyncSearchResource",
|
|
23
|
+
"EnrichmentResource",
|
|
24
|
+
"AsyncEnrichmentResource",
|
|
25
|
+
"UtilsResource",
|
|
26
|
+
"AsyncUtilsResource",
|
|
27
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Blitz API resource classes, grouped by the API's OpenAPI tags."""
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""The Account resource: ``client.account``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from ..._compat import TimeoutParam
|
|
8
|
+
from ...types.account import KeyInfo
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from ..._client import AsyncBlitzAPI
|
|
12
|
+
|
|
13
|
+
_KEY_INFO_PATH = "/v2/account/key-info"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AsyncAccountResource:
|
|
17
|
+
def __init__(self, client: AsyncBlitzAPI) -> None:
|
|
18
|
+
self._client = client
|
|
19
|
+
|
|
20
|
+
async def key_info(self, *, timeout: TimeoutParam = None) -> KeyInfo:
|
|
21
|
+
"""Check the API key's validity, credit balance, and rate limit.
|
|
22
|
+
|
|
23
|
+
A cheap health check to run before a batch job.
|
|
24
|
+
"""
|
|
25
|
+
return await self._client._request(
|
|
26
|
+
"GET", _KEY_INFO_PATH, body=None, cast_to=KeyInfo, timeout=timeout
|
|
27
|
+
)
|