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.
Files changed (40) hide show
  1. blitz_api/__init__.py +65 -0
  2. blitz_api/_base_client.py +191 -0
  3. blitz_api/_client.py +13 -0
  4. blitz_api/_client_async.py +143 -0
  5. blitz_api/_client_sync.py +145 -0
  6. blitz_api/_compat.py +26 -0
  7. blitz_api/_constants.py +36 -0
  8. blitz_api/_exceptions.py +113 -0
  9. blitz_api/_pagination_async.py +128 -0
  10. blitz_api/_pagination_base.py +52 -0
  11. blitz_api/_pagination_sync.py +130 -0
  12. blitz_api/_rate_limit.py +14 -0
  13. blitz_api/_rate_limit_async.py +67 -0
  14. blitz_api/_rate_limit_sync.py +69 -0
  15. blitz_api/_version.py +1 -0
  16. blitz_api/py.typed +0 -0
  17. blitz_api/resources/__init__.py +27 -0
  18. blitz_api/resources/_async/__init__.py +1 -0
  19. blitz_api/resources/_async/account.py +27 -0
  20. blitz_api/resources/_async/enrichment.py +116 -0
  21. blitz_api/resources/_async/search.py +153 -0
  22. blitz_api/resources/_async/utils.py +43 -0
  23. blitz_api/resources/_sync/__init__.py +3 -0
  24. blitz_api/resources/_sync/account.py +29 -0
  25. blitz_api/resources/_sync/enrichment.py +118 -0
  26. blitz_api/resources/_sync/search.py +155 -0
  27. blitz_api/resources/_sync/utils.py +45 -0
  28. blitz_api/types/__init__.py +108 -0
  29. blitz_api/types/_models.py +23 -0
  30. blitz_api/types/account.py +27 -0
  31. blitz_api/types/enrichment.py +76 -0
  32. blitz_api/types/enums.py +633 -0
  33. blitz_api/types/filters.py +130 -0
  34. blitz_api/types/search.py +36 -0
  35. blitz_api/types/shared.py +119 -0
  36. blitz_api/types/utils.py +35 -0
  37. blitz_api_py-0.1.0.dist-info/METADATA +220 -0
  38. blitz_api_py-0.1.0.dist-info/RECORD +40 -0
  39. blitz_api_py-0.1.0.dist-info/WHEEL +4 -0
  40. blitz_api_py-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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}
@@ -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
+ )