generect 0.1.0__tar.gz

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.
@@ -0,0 +1,6 @@
1
+ node_modules/
2
+ dist/
3
+ __pycache__/
4
+ *.pyc
5
+ .pytest_cache/
6
+ *.egg-info/
@@ -0,0 +1,45 @@
1
+ Metadata-Version: 2.4
2
+ Name: generect
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the Generect Live API
5
+ Project-URL: Documentation, https://docs.generect.com
6
+ Project-URL: Repository, https://github.com/generect/generect-sdks
7
+ Author-email: Generect <support@generect.com>
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
13
+ Requires-Python: >=3.9
14
+ Requires-Dist: httpx<1,>=0.24
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest>=7; extra == 'dev'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # generect (Python SDK)
20
+
21
+ ```bash
22
+ pip install generect
23
+ ```
24
+
25
+ ```python
26
+ from generect import Generect
27
+
28
+ g = Generect(api_key="YOUR_API_TOKEN") # or env GENERECT_API_KEY
29
+
30
+ count = g.search.database_leads_count(job_titles=["marketing manager"], locations=["Germany"])
31
+ leads = g.search.database_leads(job_titles=["marketing manager"], locations=["Germany"], per_page=10)
32
+ print(len(leads.data), "leads for", leads.amount_charged, "USD")
33
+
34
+ email = g.email.find(lead_id=leads.data[0]["id"]) # billed only if a valid email is found
35
+
36
+ job = g.enrich.database_leads_bulk(items=[{"linkedin_url": u} for u in urls]) # ≤50 items
37
+ done = g.enrich.wait_leads_bulk(job.job_id) # polls until completed / error
38
+ ```
39
+
40
+ - Automatic retries with exponential backoff on `429`/`5xx` (configurable `max_retries`).
41
+ - Typed errors: `InsufficientBalanceError` (402), `RateLimitError` (429), `AuthenticationError` (401)…
42
+ - Every response exposes its real USD cost: `resp.amount_charged`.
43
+ - Any endpoint, even future ones: `g.request("POST", "/search/database/leads/", json={...})`.
44
+
45
+ Docs: https://docs.generect.com · Errors: https://docs.generect.com/api-reference/errors
@@ -0,0 +1,27 @@
1
+ # generect (Python SDK)
2
+
3
+ ```bash
4
+ pip install generect
5
+ ```
6
+
7
+ ```python
8
+ from generect import Generect
9
+
10
+ g = Generect(api_key="YOUR_API_TOKEN") # or env GENERECT_API_KEY
11
+
12
+ count = g.search.database_leads_count(job_titles=["marketing manager"], locations=["Germany"])
13
+ leads = g.search.database_leads(job_titles=["marketing manager"], locations=["Germany"], per_page=10)
14
+ print(len(leads.data), "leads for", leads.amount_charged, "USD")
15
+
16
+ email = g.email.find(lead_id=leads.data[0]["id"]) # billed only if a valid email is found
17
+
18
+ job = g.enrich.database_leads_bulk(items=[{"linkedin_url": u} for u in urls]) # ≤50 items
19
+ done = g.enrich.wait_leads_bulk(job.job_id) # polls until completed / error
20
+ ```
21
+
22
+ - Automatic retries with exponential backoff on `429`/`5xx` (configurable `max_retries`).
23
+ - Typed errors: `InsufficientBalanceError` (402), `RateLimitError` (429), `AuthenticationError` (401)…
24
+ - Every response exposes its real USD cost: `resp.amount_charged`.
25
+ - Any endpoint, even future ones: `g.request("POST", "/search/database/leads/", json={...})`.
26
+
27
+ Docs: https://docs.generect.com · Errors: https://docs.generect.com/api-reference/errors
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "generect"
7
+ version = "0.1.0"
8
+ description = "Official Python SDK for the Generect Live API"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "Generect", email = "support@generect.com" }]
13
+ dependencies = ["httpx>=0.24,<1"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Topic :: Software Development :: Libraries :: Python Modules",
19
+ ]
20
+
21
+ [project.urls]
22
+ Documentation = "https://docs.generect.com"
23
+ Repository = "https://github.com/generect/generect-sdks"
24
+
25
+ [project.optional-dependencies]
26
+ dev = ["pytest>=7"]
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/generect"]
@@ -0,0 +1,22 @@
1
+ from .client import ApiResponse, Generect
2
+ from .errors import (
3
+ APIError,
4
+ AuthenticationError,
5
+ BadRequestError,
6
+ ForbiddenError,
7
+ GenerectError,
8
+ InsufficientBalanceError,
9
+ JobFailedError,
10
+ JobTimeoutError,
11
+ NotFoundError,
12
+ RateLimitError,
13
+ ServerError,
14
+ )
15
+
16
+ __version__ = "0.1.0"
17
+ __all__ = [
18
+ "Generect", "ApiResponse", "GenerectError", "APIError", "BadRequestError",
19
+ "AuthenticationError", "InsufficientBalanceError", "ForbiddenError",
20
+ "NotFoundError", "RateLimitError", "ServerError", "JobFailedError",
21
+ "JobTimeoutError", "__version__",
22
+ ]
@@ -0,0 +1,332 @@
1
+ """Thin, dependency-light client for the Generect Live API.
2
+
3
+ Design notes
4
+ ------------
5
+ - Every method mirrors one documented endpoint 1:1 (paths pinned to the
6
+ OpenAPI spec in ``openapi/openapi.yaml``); filters are passed as keyword
7
+ arguments and sent verbatim, so new API fields work without an SDK update.
8
+ - Responses come back as :class:`ApiResponse` — a ``dict`` of the raw body
9
+ with ``.data``, ``.meta`` and ``.amount_charged`` conveniences.
10
+ - ``429`` and ``5xx`` are retried with exponential backoff + jitter;
11
+ other ``4xx`` raise immediately (the request itself must change).
12
+ - Bulk endpoints return a job; ``wait()`` helpers poll until
13
+ ``completed`` / ``error`` (see docs: data stays null until completed).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import random
20
+ import time
21
+ from typing import Any, Optional
22
+
23
+ import httpx
24
+
25
+ from .errors import JobFailedError, JobTimeoutError, error_for
26
+
27
+ DEFAULT_BASE_URL = "https://api.generect.com/api/v1"
28
+ _TERMINAL_OK = "completed"
29
+ _TERMINAL_FAIL = ("error", "failed")
30
+
31
+
32
+ class ApiResponse(dict):
33
+ """Raw response body with typed accessors for the ``data``/``meta`` envelope."""
34
+
35
+ @property
36
+ def data(self) -> Any:
37
+ return self.get("data")
38
+
39
+ @property
40
+ def meta(self) -> dict:
41
+ return self.get("meta") or {}
42
+
43
+ @property
44
+ def amount_charged(self) -> Optional[float]:
45
+ """Real USD cost of this call, as billed (``meta.amount_charged``)."""
46
+ return self.meta.get("amount_charged")
47
+
48
+ @property
49
+ def job_id(self) -> Optional[str]:
50
+ return self.meta.get("job_id") or self.get("job_id")
51
+
52
+ @property
53
+ def status(self) -> Optional[str]:
54
+ return self.meta.get("status") or self.get("status")
55
+
56
+
57
+ class Generect:
58
+ """Synchronous Generect API client.
59
+
60
+ >>> g = Generect(api_key="...") # or env GENERECT_API_KEY
61
+ >>> g.search.database_leads(job_titles=["cto"], locations=["Germany"], per_page=5)
62
+ """
63
+
64
+ def __init__(
65
+ self,
66
+ api_key: Optional[str] = None,
67
+ *,
68
+ base_url: str = DEFAULT_BASE_URL,
69
+ timeout: float = 300.0,
70
+ max_retries: int = 3,
71
+ backoff_base: float = 1.0,
72
+ backoff_cap: float = 30.0,
73
+ transport: Optional[httpx.BaseTransport] = None,
74
+ ):
75
+ api_key = api_key or os.environ.get("GENERECT_API_KEY")
76
+ if not api_key:
77
+ raise ValueError("api_key is required (or set GENERECT_API_KEY)")
78
+ self._max_retries = max_retries
79
+ self._backoff_base = backoff_base
80
+ self._backoff_cap = backoff_cap
81
+ self._http = httpx.Client(
82
+ base_url=base_url.rstrip("/"),
83
+ timeout=httpx.Timeout(timeout, connect=10.0),
84
+ headers={
85
+ "Authorization": f"Token {api_key}",
86
+ "User-Agent": "generect-python/0.1.0",
87
+ },
88
+ transport=transport,
89
+ )
90
+ self.search = _Search(self)
91
+ self.enrich = _Enrich(self)
92
+ self.email = _Email(self)
93
+ self.phone = _Phone(self)
94
+ self.preview = _Preview(self)
95
+ self.accounts = _Accounts(self)
96
+ self.webhooks = _Webhooks(self)
97
+
98
+ def close(self) -> None:
99
+ self._http.close()
100
+
101
+ def __enter__(self) -> "Generect":
102
+ return self
103
+
104
+ def __exit__(self, *exc) -> None:
105
+ self.close()
106
+
107
+ # -- core ---------------------------------------------------------------
108
+
109
+ def request(self, method: str, path: str, json: Optional[dict] = None,
110
+ params: Optional[dict] = None) -> ApiResponse:
111
+ """Escape hatch: call any endpoint. ``path`` must end with ``/``."""
112
+ if json is not None:
113
+ json = {k: v for k, v in json.items() if v is not None}
114
+ attempt = 0
115
+ while True:
116
+ try:
117
+ resp = self._http.request(method, path, json=json, params=params)
118
+ except httpx.TransportError:
119
+ if attempt >= self._max_retries:
120
+ raise
121
+ self._sleep(attempt, None)
122
+ attempt += 1
123
+ continue
124
+ if resp.status_code < 400:
125
+ try:
126
+ body = resp.json()
127
+ except ValueError:
128
+ body = {}
129
+ return ApiResponse(body if isinstance(body, dict) else {"data": body})
130
+ retryable = resp.status_code == 429 or resp.status_code >= 500
131
+ if retryable and attempt < self._max_retries:
132
+ self._sleep(attempt, resp.headers.get("Retry-After"))
133
+ attempt += 1
134
+ continue
135
+ try:
136
+ body = resp.json()
137
+ except ValueError:
138
+ body = {}
139
+ detail = body.get("detail") if isinstance(body, dict) else None
140
+ raise error_for(resp.status_code, detail, body if isinstance(body, dict) else None)
141
+
142
+ def _sleep(self, attempt: int, retry_after: Optional[str]) -> None:
143
+ if retry_after:
144
+ try:
145
+ time.sleep(min(float(retry_after), self._backoff_cap))
146
+ return
147
+ except ValueError:
148
+ pass
149
+ delay = min(self._backoff_base * (2 ** attempt), self._backoff_cap)
150
+ time.sleep(delay + random.uniform(0, delay / 4))
151
+
152
+ def _wait_job(self, poll_path: str, job_id: str, *, wait_timeout: float,
153
+ poll_interval: float) -> ApiResponse:
154
+ deadline = time.monotonic() + wait_timeout
155
+ interval = poll_interval
156
+ while True:
157
+ body = self.request("GET", poll_path)
158
+ status = body.status
159
+ if status == _TERMINAL_OK:
160
+ return body
161
+ if status in _TERMINAL_FAIL:
162
+ raise JobFailedError(job_id, body)
163
+ if time.monotonic() >= deadline:
164
+ raise JobTimeoutError(f"job {job_id} still '{status}' after {wait_timeout}s")
165
+ time.sleep(interval)
166
+ interval = min(interval * 1.5, 10.0)
167
+
168
+
169
+ class _Namespace:
170
+ def __init__(self, client: Generect):
171
+ self._c = client
172
+
173
+
174
+ class _Search(_Namespace):
175
+ """POST /search/{database|realtime}/... — database counts are free,
176
+ realtime counts are billed (see docs: Database vs Real-time)."""
177
+
178
+ def database_leads(self, **filters) -> ApiResponse:
179
+ return self._c.request("POST", "/search/database/leads/", json=filters)
180
+
181
+ def database_leads_count(self, **filters) -> ApiResponse:
182
+ return self._c.request("POST", "/search/database/leads/count/", json=filters)
183
+
184
+ def realtime_leads(self, **filters) -> ApiResponse:
185
+ return self._c.request("POST", "/search/realtime/leads/", json=filters)
186
+
187
+ def realtime_leads_count(self, **filters) -> ApiResponse:
188
+ return self._c.request("POST", "/search/realtime/leads/count/", json=filters)
189
+
190
+ def database_companies(self, **filters) -> ApiResponse:
191
+ return self._c.request("POST", "/search/database/companies/", json=filters)
192
+
193
+ def database_companies_count(self, **filters) -> ApiResponse:
194
+ return self._c.request("POST", "/search/database/companies/count/", json=filters)
195
+
196
+ def realtime_companies(self, **filters) -> ApiResponse:
197
+ return self._c.request("POST", "/search/realtime/companies/", json=filters)
198
+
199
+ def realtime_companies_count(self, **filters) -> ApiResponse:
200
+ return self._c.request("POST", "/search/realtime/companies/count/", json=filters)
201
+
202
+ def database_company_leads(self, **filters) -> ApiResponse:
203
+ return self._c.request("POST", "/search/database/company-leads/", json=filters)
204
+
205
+ def database_company_leads_count(self, **filters) -> ApiResponse:
206
+ return self._c.request("POST", "/search/database/company-leads/count/", json=filters)
207
+
208
+ def realtime_company_leads(self, **filters) -> ApiResponse:
209
+ return self._c.request("POST", "/search/realtime/company-leads/", json=filters)
210
+
211
+
212
+ class _Enrich(_Namespace):
213
+ """POST /enrich/... — No Data No Charge: a not-found enrich is free."""
214
+
215
+ def database_lead(self, **kwargs) -> ApiResponse:
216
+ return self._c.request("POST", "/enrich/database/lead/", json=kwargs)
217
+
218
+ def realtime_lead(self, **kwargs) -> ApiResponse:
219
+ return self._c.request("POST", "/enrich/realtime/lead/", json=kwargs)
220
+
221
+ def database_company(self, **kwargs) -> ApiResponse:
222
+ return self._c.request("POST", "/enrich/database/company/", json=kwargs)
223
+
224
+ def realtime_company(self, **kwargs) -> ApiResponse:
225
+ return self._c.request("POST", "/enrich/realtime/company/", json=kwargs)
226
+
227
+ # bulk (max 50 items per request)
228
+ def database_leads_bulk(self, items: list, **kwargs) -> ApiResponse:
229
+ return self._c.request("POST", "/enrich/database/leads/bulk/", json={"items": items, **kwargs})
230
+
231
+ def realtime_leads_bulk(self, items: list, **kwargs) -> ApiResponse:
232
+ return self._c.request("POST", "/enrich/realtime/leads/bulk/", json={"items": items, **kwargs})
233
+
234
+ def database_companies_bulk(self, items: list, **kwargs) -> ApiResponse:
235
+ return self._c.request("POST", "/enrich/database/companies/bulk/", json={"items": items, **kwargs})
236
+
237
+ def realtime_companies_bulk(self, items: list, **kwargs) -> ApiResponse:
238
+ return self._c.request("POST", "/enrich/realtime/companies/bulk/", json={"items": items, **kwargs})
239
+
240
+ def leads_bulk_status(self, job_id: str) -> ApiResponse:
241
+ return self._c.request("GET", f"/enrich/leads/bulk/{job_id}/")
242
+
243
+ def companies_bulk_status(self, job_id: str) -> ApiResponse:
244
+ return self._c.request("GET", f"/enrich/companies/bulk/{job_id}/")
245
+
246
+ def wait_leads_bulk(self, job_id: str, *, wait_timeout: float = 600.0,
247
+ poll_interval: float = 2.0) -> ApiResponse:
248
+ return self._c._wait_job(f"/enrich/leads/bulk/{job_id}/", job_id,
249
+ wait_timeout=wait_timeout, poll_interval=poll_interval)
250
+
251
+ def wait_companies_bulk(self, job_id: str, *, wait_timeout: float = 600.0,
252
+ poll_interval: float = 2.0) -> ApiResponse:
253
+ return self._c._wait_job(f"/enrich/companies/bulk/{job_id}/", job_id,
254
+ wait_timeout=wait_timeout, poll_interval=poll_interval)
255
+
256
+
257
+ class _Email(_Namespace):
258
+ """Email Finder bills only valid found emails; validation bills per email."""
259
+
260
+ def find(self, **kwargs) -> ApiResponse:
261
+ return self._c.request("POST", "/email/find/", json=kwargs)
262
+
263
+ def find_bulk(self, leads: list, **kwargs) -> ApiResponse:
264
+ return self._c.request("POST", "/email/find/bulk/", json={"leads": leads, **kwargs})
265
+
266
+ def find_bulk_status(self, job_id: str) -> ApiResponse:
267
+ return self._c.request("GET", f"/email/find/bulk/{job_id}/")
268
+
269
+ def wait_find_bulk(self, job_id: str, *, wait_timeout: float = 600.0,
270
+ poll_interval: float = 2.0) -> ApiResponse:
271
+ return self._c._wait_job(f"/email/find/bulk/{job_id}/", job_id,
272
+ wait_timeout=wait_timeout, poll_interval=poll_interval)
273
+
274
+ def validate(self, emails: list, **kwargs) -> ApiResponse:
275
+ return self._c.request("POST", "/email/validate/", json={"emails": emails, **kwargs})
276
+
277
+
278
+ class _Phone(_Namespace):
279
+ """Phone Finder — billed per found phone only."""
280
+
281
+ def find(self, **kwargs) -> ApiResponse:
282
+ return self._c.request("POST", "/phone/find/", json=kwargs)
283
+
284
+ def find_bulk(self, leads: list, **kwargs) -> ApiResponse:
285
+ return self._c.request("POST", "/phone/find/bulk/", json={"leads": leads, **kwargs})
286
+
287
+ def find_bulk_status(self, job_id: str) -> ApiResponse:
288
+ return self._c.request("GET", f"/phone/find/bulk/{job_id}/")
289
+
290
+ def wait_find_bulk(self, job_id: str, *, wait_timeout: float = 600.0,
291
+ poll_interval: float = 2.0) -> ApiResponse:
292
+ return self._c._wait_job(f"/phone/find/bulk/{job_id}/", job_id,
293
+ wait_timeout=wait_timeout, poll_interval=poll_interval)
294
+
295
+
296
+ class _Preview(_Namespace):
297
+ def leads(self, **filters) -> ApiResponse:
298
+ return self._c.request("POST", "/preview/leads/", json=filters)
299
+
300
+ def leads_count(self, **filters) -> ApiResponse:
301
+ return self._c.request("POST", "/preview/leads/count/", json=filters)
302
+
303
+
304
+ class _Accounts(_Namespace):
305
+ def me(self) -> ApiResponse:
306
+ return self._c.request("GET", "/accounts/me/")
307
+
308
+ def usage(self, **params) -> ApiResponse:
309
+ return self._c.request("GET", "/accounts/usage/", params=params or None)
310
+
311
+ def transactions(self, **params) -> ApiResponse:
312
+ return self._c.request("GET", "/accounts/transactions/", params=params or None)
313
+
314
+
315
+ class _Webhooks(_Namespace):
316
+ def list(self) -> ApiResponse:
317
+ return self._c.request("GET", "/webhooks/")
318
+
319
+ def create(self, **kwargs) -> ApiResponse:
320
+ return self._c.request("POST", "/webhooks/", json=kwargs)
321
+
322
+ def get(self, webhook_id: str) -> ApiResponse:
323
+ return self._c.request("GET", f"/webhooks/{webhook_id}/")
324
+
325
+ def update(self, webhook_id: str, **kwargs) -> ApiResponse:
326
+ return self._c.request("PATCH", f"/webhooks/{webhook_id}/", json=kwargs)
327
+
328
+ def delete(self, webhook_id: str) -> ApiResponse:
329
+ return self._c.request("DELETE", f"/webhooks/{webhook_id}/")
330
+
331
+ def test(self, webhook_id: str) -> ApiResponse:
332
+ return self._c.request("POST", f"/webhooks/{webhook_id}/test/")
@@ -0,0 +1,81 @@
1
+ """Exception hierarchy mirroring the Generect API error envelope.
2
+
3
+ Every API error is `{"status": "error", "status_code": <int>, "detail": <str|dict>}`
4
+ (see https://docs.generect.com/api-reference/errors). The client raises the most
5
+ specific subclass for the HTTP status and keeps the parsed body on the exception.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Optional
11
+
12
+
13
+ class GenerectError(Exception):
14
+ """Base class for all SDK errors."""
15
+
16
+
17
+ class APIError(GenerectError):
18
+ """An HTTP error response from the API."""
19
+
20
+ def __init__(self, status_code: int, detail: Any = None, body: Optional[dict] = None):
21
+ self.status_code = status_code
22
+ self.detail = detail
23
+ self.body = body or {}
24
+ super().__init__(f"HTTP {status_code}: {detail}")
25
+
26
+
27
+ class BadRequestError(APIError):
28
+ """400 — invalid parameters, or a lookup that matched no record."""
29
+
30
+
31
+ class AuthenticationError(APIError):
32
+ """401 — missing or invalid API token (remember the `Token ` prefix)."""
33
+
34
+
35
+ class InsufficientBalanceError(APIError):
36
+ """402 — balance too low; top up or enable auto top-up."""
37
+
38
+
39
+ class ForbiddenError(APIError):
40
+ """403 — the account or key is not entitled to this endpoint."""
41
+
42
+
43
+ class NotFoundError(APIError):
44
+ """404 — resource does not exist (e.g. lead not found by email, expired job)."""
45
+
46
+
47
+ class RateLimitError(APIError):
48
+ """429 — parallel-request or capacity limit hit; the client retries these
49
+ automatically with exponential backoff before raising."""
50
+
51
+
52
+ class ServerError(APIError):
53
+ """5xx — transient server failure; retried automatically before raising."""
54
+
55
+
56
+ class JobFailedError(GenerectError):
57
+ """A bulk job finished with status ``error``."""
58
+
59
+ def __init__(self, job_id: str, body: Optional[dict] = None):
60
+ self.job_id = job_id
61
+ self.body = body or {}
62
+ super().__init__(f"Bulk job {job_id} finished with status 'error'")
63
+
64
+
65
+ class JobTimeoutError(GenerectError):
66
+ """A bulk job did not finish within ``wait_timeout`` seconds."""
67
+
68
+
69
+ ERROR_BY_STATUS = {
70
+ 400: BadRequestError,
71
+ 401: AuthenticationError,
72
+ 402: InsufficientBalanceError,
73
+ 403: ForbiddenError,
74
+ 404: NotFoundError,
75
+ 429: RateLimitError,
76
+ }
77
+
78
+
79
+ def error_for(status_code: int, detail: Any, body: Optional[dict]) -> APIError:
80
+ cls = ERROR_BY_STATUS.get(status_code, ServerError if status_code >= 500 else APIError)
81
+ return cls(status_code, detail, body)
@@ -0,0 +1,102 @@
1
+ """Core behavior: auth header, retries, error mapping, envelope accessors, job polling."""
2
+
3
+ import httpx
4
+ import pytest
5
+
6
+ from generect import (
7
+ Generect,
8
+ InsufficientBalanceError,
9
+ JobFailedError,
10
+ RateLimitError,
11
+ )
12
+
13
+
14
+ def make_client(handler, **kw):
15
+ kw.setdefault("backoff_base", 0.0) # no real sleeping in tests
16
+ return Generect(api_key="test-key", transport=httpx.MockTransport(handler), **kw)
17
+
18
+
19
+ def test_sends_token_auth_and_correct_url():
20
+ seen = {}
21
+
22
+ def handler(request):
23
+ seen["auth"] = request.headers["Authorization"]
24
+ seen["url"] = str(request.url)
25
+ seen["json"] = request.read()
26
+ return httpx.Response(200, json={"data": [], "meta": {"amount_charged": 0.0}})
27
+
28
+ g = make_client(handler)
29
+ g.search.database_leads(job_titles=["cto"], locations=None, per_page=5)
30
+ assert seen["auth"] == "Token test-key"
31
+ assert seen["url"] == "https://api.generect.com/api/v1/search/database/leads/"
32
+ assert b"locations" not in seen["json"] # None fields dropped
33
+ assert b"job_titles" in seen["json"]
34
+
35
+
36
+ def test_retries_429_then_succeeds():
37
+ calls = {"n": 0}
38
+
39
+ def handler(request):
40
+ calls["n"] += 1
41
+ if calls["n"] == 1:
42
+ return httpx.Response(429, json={"status": "error", "status_code": 429,
43
+ "detail": "Too Many Requests"})
44
+ return httpx.Response(200, json={"data": [{"id": "x"}], "meta": {"amount_charged": 0.0067}})
45
+
46
+ g = make_client(handler)
47
+ resp = g.search.database_leads(job_titles=["cto"])
48
+ assert calls["n"] == 2
49
+ assert resp.data == [{"id": "x"}]
50
+ assert resp.amount_charged == 0.0067
51
+
52
+
53
+ def test_429_raises_after_retries_exhausted():
54
+ def handler(request):
55
+ return httpx.Response(429, json={"status": "error", "status_code": 429,
56
+ "detail": "Too Many Requests"})
57
+
58
+ g = make_client(handler, max_retries=1)
59
+ with pytest.raises(RateLimitError) as e:
60
+ g.enrich.realtime_lead(linkedin_url="https://linkedin.com/in/x")
61
+ assert e.value.status_code == 429
62
+
63
+
64
+ def test_402_maps_to_insufficient_balance_and_no_retry():
65
+ calls = {"n": 0}
66
+
67
+ def handler(request):
68
+ calls["n"] += 1
69
+ return httpx.Response(402, json={"status": "error", "status_code": 402,
70
+ "detail": "Insufficient funds in the account."})
71
+
72
+ g = make_client(handler)
73
+ with pytest.raises(InsufficientBalanceError) as e:
74
+ g.email.find(lead_id="abc")
75
+ assert calls["n"] == 1 # 4xx (non-429) must not retry
76
+ assert "Insufficient funds" in str(e.value.detail)
77
+
78
+
79
+ def test_wait_bulk_polls_until_completed():
80
+ states = iter([
81
+ {"data": None, "meta": {"status": "pending"}},
82
+ {"data": None, "meta": {"status": "processing"}},
83
+ {"data": [{"valid_email": "a@b.co"}], "meta": {"status": "completed", "amount_charged": 0.02}},
84
+ ])
85
+
86
+ def handler(request):
87
+ assert str(request.url).endswith("/email/find/bulk/j1/")
88
+ return httpx.Response(200, json=next(states))
89
+
90
+ g = make_client(handler)
91
+ resp = g.email.wait_find_bulk("j1", wait_timeout=5, poll_interval=0.0)
92
+ assert resp.status == "completed"
93
+ assert resp.data[0]["valid_email"] == "a@b.co"
94
+
95
+
96
+ def test_wait_bulk_raises_on_error_status():
97
+ def handler(request):
98
+ return httpx.Response(200, json={"data": None, "meta": {"status": "error"}})
99
+
100
+ g = make_client(handler)
101
+ with pytest.raises(JobFailedError):
102
+ g.enrich.wait_leads_bulk("j2", wait_timeout=5, poll_interval=0.0)