leadsets 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.
leadsets/__init__.py ADDED
@@ -0,0 +1,58 @@
1
+ """Official Python SDK for the Leadsets B2B data API.
2
+
3
+ >>> import os
4
+ >>> from leadsets import Leadsets
5
+ >>> ls = Leadsets(os.environ["LEADSETS_KEY"])
6
+ >>> res = ls.people.search(function=["revenue"], seniority=["vp", "head"], employees="20-200",
7
+ ... country="US", industry="saas", limit=10)
8
+ >>> res.items[0].match_reasons, res.usage
9
+ """
10
+
11
+ from . import types
12
+ from ._base import DEFAULT_BASE_URL
13
+ from ._client import AsyncLeadsets, Leadsets
14
+ from ._exceptions import (
15
+ APIConnectionError,
16
+ APIError,
17
+ APITimeoutError,
18
+ AuthenticationError,
19
+ FreeRecordsExhaustedError,
20
+ IdempotencyConflictError,
21
+ InternalServerError,
22
+ InvalidRequestError,
23
+ LeadsetsError,
24
+ NotFoundError,
25
+ PaymentRequiredError,
26
+ PermissionDeniedError,
27
+ RateLimitError,
28
+ ScrapeUnitsExhaustedError,
29
+ ServiceUnavailableError,
30
+ )
31
+ from ._exceptions import APIConnectionError as ConnectionError
32
+ from ._exceptions import APITimeoutError as TimeoutError
33
+ from ._version import __version__
34
+
35
+ __all__ = [
36
+ "DEFAULT_BASE_URL",
37
+ "APIConnectionError",
38
+ "APIError",
39
+ "APITimeoutError",
40
+ "AsyncLeadsets",
41
+ "AuthenticationError",
42
+ "ConnectionError",
43
+ "FreeRecordsExhaustedError",
44
+ "IdempotencyConflictError",
45
+ "InternalServerError",
46
+ "InvalidRequestError",
47
+ "Leadsets",
48
+ "LeadsetsError",
49
+ "NotFoundError",
50
+ "PaymentRequiredError",
51
+ "PermissionDeniedError",
52
+ "RateLimitError",
53
+ "ScrapeUnitsExhaustedError",
54
+ "ServiceUnavailableError",
55
+ "TimeoutError",
56
+ "__version__",
57
+ "types",
58
+ ]
leadsets/_base.py ADDED
@@ -0,0 +1,171 @@
1
+ """Shared request logic for the sync and async clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import platform
7
+ import random
8
+ import uuid
9
+ from collections.abc import Mapping
10
+ from typing import Any, TypeVar
11
+
12
+ import httpx
13
+
14
+ from ._exceptions import APIError, LeadsetsError, make_api_error, parse_retry_after
15
+ from ._models import Model
16
+ from ._version import __version__
17
+ from .types import RateLimitInfo, ResponseMeta
18
+
19
+ DEFAULT_BASE_URL = "https://api.leadsets.ai"
20
+ DEFAULT_TIMEOUT = 120.0
21
+ DEFAULT_MAX_RETRIES = 2
22
+ DEFAULT_MAX_RETRY_DELAY = 60.0
23
+
24
+ USER_AGENT = f"leadsets-sdk-python/{__version__} (Python {platform.python_version()}; httpx {httpx.__version__})"
25
+
26
+ M = TypeVar("M", bound=Model)
27
+
28
+
29
+ def _env(name: str) -> str | None:
30
+ v = os.environ.get(name)
31
+ return v.strip() if v and v.strip() else None
32
+
33
+
34
+ def resolve_api_key(api_key: str | None) -> str:
35
+ key = api_key or _env("LEADSETS_API_KEY") or _env("LEADSETS_KEY")
36
+ if not key:
37
+ raise LeadsetsError(
38
+ "No API key. Pass one to Leadsets(api_key) or set the LEADSETS_API_KEY environment variable. "
39
+ "Get a free key at https://www.leadsets.ai/signup."
40
+ )
41
+ return key
42
+
43
+
44
+ def resolve_base_url(base_url: str | None) -> str:
45
+ return (base_url or _env("LEADSETS_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
46
+
47
+
48
+ def new_idempotency_key() -> str:
49
+ return str(uuid.uuid4())
50
+
51
+
52
+ def backoff(attempt: int) -> float:
53
+ """Exponential backoff with jitter: ~0.5 s, 1 s, 2 s … capped at 8 s."""
54
+ base: float = min(8.0, 0.5 * 2**attempt)
55
+ return base * (0.75 + random.random() * 0.25)
56
+
57
+
58
+ def clean(params: Mapping[str, Any]) -> dict[str, Any]:
59
+ """Drop unset (None) parameters; turn tuples and other sequences into lists."""
60
+ out: dict[str, Any] = {}
61
+ for k, v in params.items():
62
+ if v is None:
63
+ continue
64
+ if isinstance(v, (tuple, set, frozenset)):
65
+ v = list(v)
66
+ out[k] = v
67
+ return out
68
+
69
+
70
+ class BaseClient:
71
+ def __init__(
72
+ self,
73
+ api_key: str | None,
74
+ *,
75
+ base_url: str | None,
76
+ max_retries: int,
77
+ timeout: float | httpx.Timeout | None,
78
+ max_retry_delay: float,
79
+ default_headers: Mapping[str, str] | None,
80
+ ) -> None:
81
+ self.api_key = resolve_api_key(api_key)
82
+ self.base_url = resolve_base_url(base_url)
83
+ self.max_retries = max(0, int(max_retries))
84
+ self.timeout = timeout
85
+ self.max_retry_delay = max(0.0, float(max_retry_delay))
86
+ self._default_headers = dict(default_headers or {})
87
+
88
+ def _headers(
89
+ self, *, has_body: bool, idempotency_key: str | None, extra: Mapping[str, str] | None
90
+ ) -> dict[str, str]:
91
+ h = {
92
+ "Accept": "application/json",
93
+ "User-Agent": USER_AGENT,
94
+ **self._default_headers,
95
+ **(extra or {}),
96
+ "Authorization": f"Bearer {self.api_key}",
97
+ }
98
+ if has_body:
99
+ h["Content-Type"] = "application/json"
100
+ if idempotency_key:
101
+ h["Idempotency-Key"] = idempotency_key
102
+ return h
103
+
104
+ def _url(self, path: str) -> str:
105
+ return f"{self.base_url}/v1{path}"
106
+
107
+ def _retry_delay(self, err: APIError | None, attempt: int) -> float | None:
108
+ """Seconds to wait before retrying, or None to not retry. ``err`` is None for network errors."""
109
+ if err is None:
110
+ return backoff(attempt)
111
+ if not (err.status_code in (408, 429) or err.status_code >= 500):
112
+ return None
113
+ if err.retry_after is not None:
114
+ if err.retry_after > self.max_retry_delay:
115
+ return None
116
+ return err.retry_after + random.random() * 0.25
117
+ return backoff(attempt)
118
+
119
+ @staticmethod
120
+ def _parse(cls: type[M], response: httpx.Response, idempotency_key: str | None, attempts: int) -> M:
121
+ try:
122
+ data = response.json() if response.content else {}
123
+ except ValueError:
124
+ data = {}
125
+ if not isinstance(data, dict):
126
+ data = {}
127
+ obj = cls.from_dict(data)
128
+ if hasattr(obj, "last_response"):
129
+ object.__setattr__(obj, "last_response", response_meta(response, data, idempotency_key, attempts))
130
+ return obj
131
+
132
+ @staticmethod
133
+ def _error(response: httpx.Response) -> APIError:
134
+ return make_api_error(response)
135
+
136
+
137
+ def _int(v: str | None) -> int | None:
138
+ if v is None or v == "":
139
+ return None
140
+ try:
141
+ return int(v)
142
+ except ValueError:
143
+ try:
144
+ return int(float(v))
145
+ except ValueError:
146
+ return None
147
+
148
+
149
+ def rate_limit_info(h: httpx.Headers) -> RateLimitInfo:
150
+ return RateLimitInfo(
151
+ records_remaining=_int(h.get("x-leadsets-records-remaining")),
152
+ requests_remaining=_int(h.get("x-leadsets-requests-remaining")),
153
+ weekly_remaining=_int(h.get("x-leadsets-weekly-remaining")),
154
+ free_remaining=_int(h.get("x-leadsets-free-remaining")),
155
+ retry_after=parse_retry_after(h.get("retry-after")),
156
+ )
157
+
158
+
159
+ def response_meta(
160
+ response: httpx.Response, data: dict[str, Any], idempotency_key: str | None, attempts: int
161
+ ) -> ResponseMeta:
162
+ rid = data.get("request_id")
163
+ return ResponseMeta(
164
+ status_code=response.status_code,
165
+ headers=response.headers,
166
+ request_id=rid if isinstance(rid, str) else None,
167
+ idempotency_key=idempotency_key,
168
+ idempotent_replayed=response.headers.get("idempotent-replayed") == "true",
169
+ rate_limit=rate_limit_info(response.headers),
170
+ attempts=attempts,
171
+ )
leadsets/_client.py ADDED
@@ -0,0 +1,239 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ from collections.abc import Mapping
6
+ from types import TracebackType
7
+ from typing import Any, Literal
8
+
9
+ import httpx
10
+
11
+ from ._base import (
12
+ DEFAULT_MAX_RETRIES,
13
+ DEFAULT_MAX_RETRY_DELAY,
14
+ DEFAULT_TIMEOUT,
15
+ BaseClient,
16
+ M,
17
+ new_idempotency_key,
18
+ )
19
+ from ._exceptions import APIConnectionError, APIError, APITimeoutError
20
+ from .resources.companies import AsyncCompanies, Companies
21
+ from .resources.emails import AsyncEmails, Emails
22
+ from .resources.jobs import AsyncJobs, Jobs
23
+ from .resources.people import AsyncPeople, People
24
+ from .resources.scrape import AsyncScrape, Scrape
25
+ from .resources.usage import AsyncUsageResource, UsageResource
26
+
27
+ Method = Literal["GET", "POST"]
28
+
29
+
30
+ def _sleep(seconds: float) -> None:
31
+ time.sleep(seconds)
32
+
33
+
34
+ async def _async_sleep(seconds: float) -> None:
35
+ await asyncio.sleep(seconds)
36
+
37
+
38
+ class Leadsets(BaseClient):
39
+ """The Leadsets API client.
40
+
41
+ >>> ls = Leadsets(os.environ["LEADSETS_KEY"])
42
+ >>> res = ls.people.search(function=["revenue"], country="US", limit=10)
43
+ """
44
+
45
+ people: People
46
+ companies: Companies
47
+ emails: Emails
48
+ scrape: Scrape
49
+ jobs: Jobs
50
+ usage: UsageResource
51
+
52
+ def __init__(
53
+ self,
54
+ api_key: str | None = None,
55
+ *,
56
+ base_url: str | None = None,
57
+ max_retries: int = DEFAULT_MAX_RETRIES,
58
+ timeout: float | httpx.Timeout | None = DEFAULT_TIMEOUT,
59
+ max_retry_delay: float = DEFAULT_MAX_RETRY_DELAY,
60
+ default_headers: Mapping[str, str] | None = None,
61
+ http_client: httpx.Client | None = None,
62
+ ) -> None:
63
+ """
64
+ Args:
65
+ api_key: Your API key (``ls_live_…``). Defaults to ``LEADSETS_API_KEY`` or ``LEADSETS_KEY``.
66
+ base_url: Defaults to https://api.leadsets.ai (or ``LEADSETS_BASE_URL``).
67
+ max_retries: Retries on 429, 5xx, timeouts and network errors. Default 2.
68
+ timeout: Per-attempt timeout in seconds. Default 120.
69
+ max_retry_delay: The longest ``Retry-After`` (seconds) to wait for before retrying.
70
+ A 429 asking for longer (a weekly limit, say) is raised at once. Default 60.
71
+ default_headers: Headers sent with every request.
72
+ http_client: Your own ``httpx.Client`` (proxies, transports…).
73
+ """
74
+ super().__init__(
75
+ api_key,
76
+ base_url=base_url,
77
+ max_retries=max_retries,
78
+ timeout=timeout,
79
+ max_retry_delay=max_retry_delay,
80
+ default_headers=default_headers,
81
+ )
82
+ self._owns_client = http_client is None
83
+ self._client = http_client or httpx.Client(timeout=timeout)
84
+ self.people = People(self)
85
+ self.companies = Companies(self)
86
+ self.emails = Emails(self)
87
+ self.scrape = Scrape(self)
88
+ self.jobs = Jobs(self)
89
+ self.usage = UsageResource(self)
90
+
91
+ def request(
92
+ self,
93
+ method: Method,
94
+ path: str,
95
+ cls: type[M],
96
+ *,
97
+ body: Mapping[str, Any] | None = None,
98
+ idempotent: bool = False,
99
+ idempotency_key: str | None = None,
100
+ timeout: float | httpx.Timeout | None = None,
101
+ max_retries: int | None = None,
102
+ extra_headers: Mapping[str, str] | None = None,
103
+ ) -> M:
104
+ key = idempotency_key or (new_idempotency_key() if idempotent else None)
105
+ headers = self._headers(has_body=body is not None, idempotency_key=key, extra=extra_headers)
106
+ retries = self.max_retries if max_retries is None else max(0, max_retries)
107
+ t = self.timeout if timeout is None else timeout
108
+ for attempt in range(retries + 1):
109
+ try:
110
+ res = self._client.request(method, self._url(path), json=body, headers=headers, timeout=t)
111
+ except httpx.TimeoutException as e:
112
+ if attempt < retries:
113
+ _sleep(self._retry_delay(None, attempt) or 0)
114
+ continue
115
+ raise APITimeoutError() from e
116
+ except httpx.TransportError as e:
117
+ if attempt < retries:
118
+ _sleep(self._retry_delay(None, attempt) or 0)
119
+ continue
120
+ raise APIConnectionError() from e
121
+ if res.is_success:
122
+ return self._parse(cls, res, key, attempt + 1)
123
+ err: APIError = self._error(res)
124
+ delay = self._retry_delay(err, attempt)
125
+ if attempt < retries and delay is not None:
126
+ _sleep(delay)
127
+ continue
128
+ raise err
129
+ raise AssertionError("unreachable") # pragma: no cover
130
+
131
+ def close(self) -> None:
132
+ """Close the underlying HTTP client (if the SDK created it)."""
133
+ if self._owns_client:
134
+ self._client.close()
135
+
136
+ def __enter__(self) -> Leadsets:
137
+ return self
138
+
139
+ def __exit__(
140
+ self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
141
+ ) -> None:
142
+ self.close()
143
+
144
+
145
+ class AsyncLeadsets(BaseClient):
146
+ """The async Leadsets API client.
147
+
148
+ >>> async with AsyncLeadsets() as ls:
149
+ ... res = await ls.people.search(function=["revenue"], country="US", limit=10)
150
+ """
151
+
152
+ people: AsyncPeople
153
+ companies: AsyncCompanies
154
+ emails: AsyncEmails
155
+ scrape: AsyncScrape
156
+ jobs: AsyncJobs
157
+ usage: AsyncUsageResource
158
+
159
+ def __init__(
160
+ self,
161
+ api_key: str | None = None,
162
+ *,
163
+ base_url: str | None = None,
164
+ max_retries: int = DEFAULT_MAX_RETRIES,
165
+ timeout: float | httpx.Timeout | None = DEFAULT_TIMEOUT,
166
+ max_retry_delay: float = DEFAULT_MAX_RETRY_DELAY,
167
+ default_headers: Mapping[str, str] | None = None,
168
+ http_client: httpx.AsyncClient | None = None,
169
+ ) -> None:
170
+ """Same arguments as :class:`Leadsets`; ``http_client`` is an ``httpx.AsyncClient``."""
171
+ super().__init__(
172
+ api_key,
173
+ base_url=base_url,
174
+ max_retries=max_retries,
175
+ timeout=timeout,
176
+ max_retry_delay=max_retry_delay,
177
+ default_headers=default_headers,
178
+ )
179
+ self._owns_client = http_client is None
180
+ self._client = http_client or httpx.AsyncClient(timeout=timeout)
181
+ self.people = AsyncPeople(self)
182
+ self.companies = AsyncCompanies(self)
183
+ self.emails = AsyncEmails(self)
184
+ self.scrape = AsyncScrape(self)
185
+ self.jobs = AsyncJobs(self)
186
+ self.usage = AsyncUsageResource(self)
187
+
188
+ async def request(
189
+ self,
190
+ method: Method,
191
+ path: str,
192
+ cls: type[M],
193
+ *,
194
+ body: Mapping[str, Any] | None = None,
195
+ idempotent: bool = False,
196
+ idempotency_key: str | None = None,
197
+ timeout: float | httpx.Timeout | None = None,
198
+ max_retries: int | None = None,
199
+ extra_headers: Mapping[str, str] | None = None,
200
+ ) -> M:
201
+ key = idempotency_key or (new_idempotency_key() if idempotent else None)
202
+ headers = self._headers(has_body=body is not None, idempotency_key=key, extra=extra_headers)
203
+ retries = self.max_retries if max_retries is None else max(0, max_retries)
204
+ t = self.timeout if timeout is None else timeout
205
+ for attempt in range(retries + 1):
206
+ try:
207
+ res = await self._client.request(method, self._url(path), json=body, headers=headers, timeout=t)
208
+ except httpx.TimeoutException as e:
209
+ if attempt < retries:
210
+ await _async_sleep(self._retry_delay(None, attempt) or 0)
211
+ continue
212
+ raise APITimeoutError() from e
213
+ except httpx.TransportError as e:
214
+ if attempt < retries:
215
+ await _async_sleep(self._retry_delay(None, attempt) or 0)
216
+ continue
217
+ raise APIConnectionError() from e
218
+ if res.is_success:
219
+ return self._parse(cls, res, key, attempt + 1)
220
+ err: APIError = self._error(res)
221
+ delay = self._retry_delay(err, attempt)
222
+ if attempt < retries and delay is not None:
223
+ await _async_sleep(delay)
224
+ continue
225
+ raise err
226
+ raise AssertionError("unreachable") # pragma: no cover
227
+
228
+ async def aclose(self) -> None:
229
+ """Close the underlying HTTP client (if the SDK created it)."""
230
+ if self._owns_client:
231
+ await self._client.aclose()
232
+
233
+ async def __aenter__(self) -> AsyncLeadsets:
234
+ return self
235
+
236
+ async def __aexit__(
237
+ self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
238
+ ) -> None:
239
+ await self.aclose()