renidly 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.
- renidly/__init__.py +51 -0
- renidly/_batch.py +177 -0
- renidly/_client.py +122 -0
- renidly/_config.py +86 -0
- renidly/_errors.py +105 -0
- renidly/_models.py +73 -0
- renidly/_pagination.py +113 -0
- renidly/_ratelimit.py +167 -0
- renidly/_transport.py +217 -0
- renidly/_version.py +1 -0
- renidly/py.typed +0 -0
- renidly/resources/__init__.py +3 -0
- renidly/resources/_base.py +114 -0
- renidly/resources/_paginators.py +28 -0
- renidly/resources/account.py +131 -0
- renidly/resources/data.py +352 -0
- renidly/resources/emails.py +179 -0
- renidly/resources/live.py +515 -0
- renidly/types/__init__.py +1 -0
- renidly/types/params.py +283 -0
- renidly-0.1.0.dist-info/METADATA +429 -0
- renidly-0.1.0.dist-info/RECORD +23 -0
- renidly-0.1.0.dist-info/WHEEL +4 -0
renidly/__init__.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Renidly — the official Python SDK for the Renidly B2B professional data APIs.
|
|
2
|
+
|
|
3
|
+
from renidly import Renidly
|
|
4
|
+
|
|
5
|
+
r = Renidly("rnd_...")
|
|
6
|
+
for tier in r.account.tiers():
|
|
7
|
+
print(tier.name, tier.limit_per_minute)
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from ._client import AsyncRenidly, Renidly
|
|
12
|
+
from ._config import RenidlyConfig
|
|
13
|
+
from ._errors import (
|
|
14
|
+
APIConnectionError,
|
|
15
|
+
APIStatusError,
|
|
16
|
+
AuthenticationError,
|
|
17
|
+
InsufficientCreditsError,
|
|
18
|
+
InternalServerError,
|
|
19
|
+
InvalidRequestError,
|
|
20
|
+
NotFoundError,
|
|
21
|
+
PermissionDeniedError,
|
|
22
|
+
RateLimitError,
|
|
23
|
+
RenidlyError,
|
|
24
|
+
ServiceUnavailableError,
|
|
25
|
+
)
|
|
26
|
+
from ._models import APIResponse, LastResponse, RenidlyModel
|
|
27
|
+
from ._pagination import AsyncRenidlyList, RenidlyList
|
|
28
|
+
from ._version import __version__
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"Renidly",
|
|
32
|
+
"AsyncRenidly",
|
|
33
|
+
"RenidlyConfig",
|
|
34
|
+
"RenidlyModel",
|
|
35
|
+
"APIResponse",
|
|
36
|
+
"LastResponse",
|
|
37
|
+
"RenidlyList",
|
|
38
|
+
"AsyncRenidlyList",
|
|
39
|
+
"RenidlyError",
|
|
40
|
+
"APIConnectionError",
|
|
41
|
+
"APIStatusError",
|
|
42
|
+
"AuthenticationError",
|
|
43
|
+
"PermissionDeniedError",
|
|
44
|
+
"InvalidRequestError",
|
|
45
|
+
"InsufficientCreditsError",
|
|
46
|
+
"NotFoundError",
|
|
47
|
+
"RateLimitError",
|
|
48
|
+
"ServiceUnavailableError",
|
|
49
|
+
"InternalServerError",
|
|
50
|
+
"__version__",
|
|
51
|
+
]
|
renidly/_batch.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Batch job handles for the async enrichment / verify / find endpoints.
|
|
2
|
+
|
|
3
|
+
``submit`` returns a job id immediately; the handle then lets you either block
|
|
4
|
+
with ``.wait()`` or consume results lazily with ``.stream()`` (a generator that
|
|
5
|
+
yields items as pages resolve). Results page by an integer ``after`` cursor until
|
|
6
|
+
``next_cursor`` stops advancing and ``status`` is terminal.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any, Dict, Iterator, List, Optional, Type
|
|
13
|
+
|
|
14
|
+
from ._models import RenidlyModel
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BatchResult:
|
|
18
|
+
"""Everything a finished (or drained) job produced."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, status: str, total: int, resolved: int, errors: int, results: List[Any], not_found: List[str]):
|
|
21
|
+
self.status = status
|
|
22
|
+
self.total = total
|
|
23
|
+
self.resolved = resolved
|
|
24
|
+
self.errors = errors
|
|
25
|
+
self.results = results
|
|
26
|
+
self.not_found = not_found
|
|
27
|
+
|
|
28
|
+
def __repr__(self) -> str: # pragma: no cover
|
|
29
|
+
return f"BatchResult(status={self.status!r}, resolved={self.resolved}/{self.total}, errors={self.errors})"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _rows(data: Dict[str, Any], results_key: str, keyed: bool, model: Type[RenidlyModel]) -> List[Any]:
|
|
33
|
+
container = data.get(results_key)
|
|
34
|
+
out: List[Any] = []
|
|
35
|
+
if keyed and isinstance(container, dict):
|
|
36
|
+
for k, v in container.items():
|
|
37
|
+
payload = {**v, "matched_input": k} if isinstance(v, dict) else {"value": v, "matched_input": k}
|
|
38
|
+
out.append(model._build(payload, None))
|
|
39
|
+
elif isinstance(container, list):
|
|
40
|
+
for item in container:
|
|
41
|
+
out.append(model._build(item, None))
|
|
42
|
+
return out
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _BaseJob:
|
|
46
|
+
"""Shared state + cursor helpers for sync/async batch job handles."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, client, service, path, job_id, *, results_key, keyed, model, options=None):
|
|
49
|
+
self._client = client
|
|
50
|
+
self._service = service
|
|
51
|
+
self._path = path
|
|
52
|
+
self.id = job_id
|
|
53
|
+
self._results_key = results_key
|
|
54
|
+
self._keyed = keyed
|
|
55
|
+
self._model = model
|
|
56
|
+
self._options = options
|
|
57
|
+
|
|
58
|
+
def _params(self, after: int) -> Dict[str, Any]:
|
|
59
|
+
return {"job_id": self.id, "after": after}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class BatchJob(_BaseJob):
|
|
63
|
+
"""A handle to an async batch job. Collect results with :meth:`wait` or :meth:`stream`."""
|
|
64
|
+
|
|
65
|
+
def _poll(self, after: int) -> Dict[str, Any]:
|
|
66
|
+
r = self._client._transport.request(
|
|
67
|
+
"GET", self._service, self._path, params=self._params(after), options=self._options
|
|
68
|
+
)
|
|
69
|
+
if r.error is not None:
|
|
70
|
+
raise r.error
|
|
71
|
+
return r.data or {}
|
|
72
|
+
|
|
73
|
+
def status(self) -> Dict[str, Any]:
|
|
74
|
+
"""Current job meta (status / total / resolved / errors) without collecting."""
|
|
75
|
+
d = self._poll(0)
|
|
76
|
+
return {k: d.get(k) for k in ("status", "total", "resolved", "errors")}
|
|
77
|
+
|
|
78
|
+
def stream(self, *, poll_interval: float = 1.5, timeout: Optional[float] = None) -> Iterator[Any]:
|
|
79
|
+
"""Yield each result as it resolves (a generator).
|
|
80
|
+
|
|
81
|
+
Polls the job and yields rows as pages complete, returning when the job
|
|
82
|
+
is finished. Use this to process results progressively instead of
|
|
83
|
+
blocking for the whole job with :meth:`wait`.
|
|
84
|
+
|
|
85
|
+
Example::
|
|
86
|
+
|
|
87
|
+
for row in client.emails.verify_batch(emails).stream():
|
|
88
|
+
print(row.email, row.deliverable)
|
|
89
|
+
"""
|
|
90
|
+
after = 0
|
|
91
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
92
|
+
not_found: List[str] = []
|
|
93
|
+
while True:
|
|
94
|
+
d = self._poll(after)
|
|
95
|
+
for row in _rows(d, self._results_key, self._keyed, self._model):
|
|
96
|
+
yield row
|
|
97
|
+
for nf in d.get("not_found") or []:
|
|
98
|
+
not_found.append(nf)
|
|
99
|
+
self._last_meta = d
|
|
100
|
+
nc = d.get("next_cursor")
|
|
101
|
+
if nc is not None and nc != after:
|
|
102
|
+
after = nc
|
|
103
|
+
continue
|
|
104
|
+
if d.get("status") in ("completed", "error"):
|
|
105
|
+
self._not_found = not_found
|
|
106
|
+
return
|
|
107
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
108
|
+
self._not_found = not_found
|
|
109
|
+
return
|
|
110
|
+
time.sleep(poll_interval)
|
|
111
|
+
|
|
112
|
+
def wait(self, *, poll_interval: float = 1.5, timeout: Optional[float] = None, on_progress=None) -> BatchResult:
|
|
113
|
+
"""Block until the job finishes, returning all results."""
|
|
114
|
+
results: List[Any] = []
|
|
115
|
+
for row in self.stream(poll_interval=poll_interval, timeout=timeout):
|
|
116
|
+
results.append(row)
|
|
117
|
+
if on_progress is not None:
|
|
118
|
+
on_progress(len(results))
|
|
119
|
+
m = getattr(self, "_last_meta", {})
|
|
120
|
+
return BatchResult(
|
|
121
|
+
m.get("status", "unknown"), m.get("total", 0), m.get("resolved", len(results)),
|
|
122
|
+
m.get("errors", 0), results, getattr(self, "_not_found", []),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class AsyncBatchJob(_BaseJob):
|
|
127
|
+
"""Async handle to a batch job. See :class:`BatchJob`."""
|
|
128
|
+
|
|
129
|
+
async def _poll(self, after: int) -> Dict[str, Any]:
|
|
130
|
+
r = await self._client._transport.request(
|
|
131
|
+
"GET", self._service, self._path, params=self._params(after), options=self._options
|
|
132
|
+
)
|
|
133
|
+
if r.error is not None:
|
|
134
|
+
raise r.error
|
|
135
|
+
return r.data or {}
|
|
136
|
+
|
|
137
|
+
async def status(self) -> Dict[str, Any]:
|
|
138
|
+
"""Current job meta (status/total/resolved/errors). See :meth:`BatchJob.status`."""
|
|
139
|
+
d = await self._poll(0)
|
|
140
|
+
return {k: d.get(k) for k in ("status", "total", "resolved", "errors")}
|
|
141
|
+
|
|
142
|
+
async def stream(self, *, poll_interval: float = 1.5, timeout: Optional[float] = None):
|
|
143
|
+
"""Async generator yielding results as they resolve. See :meth:`BatchJob.stream`."""
|
|
144
|
+
after = 0
|
|
145
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
146
|
+
not_found: List[str] = []
|
|
147
|
+
while True:
|
|
148
|
+
d = await self._poll(after)
|
|
149
|
+
for row in _rows(d, self._results_key, self._keyed, self._model):
|
|
150
|
+
yield row
|
|
151
|
+
for nf in d.get("not_found") or []:
|
|
152
|
+
not_found.append(nf)
|
|
153
|
+
self._last_meta = d
|
|
154
|
+
nc = d.get("next_cursor")
|
|
155
|
+
if nc is not None and nc != after:
|
|
156
|
+
after = nc
|
|
157
|
+
continue
|
|
158
|
+
if d.get("status") in ("completed", "error"):
|
|
159
|
+
self._not_found = not_found
|
|
160
|
+
return
|
|
161
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
162
|
+
self._not_found = not_found
|
|
163
|
+
return
|
|
164
|
+
await asyncio.sleep(poll_interval)
|
|
165
|
+
|
|
166
|
+
async def wait(self, *, poll_interval: float = 1.5, timeout: Optional[float] = None, on_progress=None) -> BatchResult:
|
|
167
|
+
"""Await until the job finishes, returning all results. See :meth:`BatchJob.wait`."""
|
|
168
|
+
results: List[Any] = []
|
|
169
|
+
async for row in self.stream(poll_interval=poll_interval, timeout=timeout):
|
|
170
|
+
results.append(row)
|
|
171
|
+
if on_progress is not None:
|
|
172
|
+
on_progress(len(results))
|
|
173
|
+
m = getattr(self, "_last_meta", {})
|
|
174
|
+
return BatchResult(
|
|
175
|
+
m.get("status", "unknown"), m.get("total", 0), m.get("resolved", len(results)),
|
|
176
|
+
m.get("errors", 0), results, getattr(self, "_not_found", []),
|
|
177
|
+
)
|
renidly/_client.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""The public clients: ``Renidly`` (sync) and ``AsyncRenidly`` (async).
|
|
2
|
+
|
|
3
|
+
Same surface, same method names; the async client just needs ``await``. Product
|
|
4
|
+
namespaces hang off the client: ``r.data``, ``r.live``, ``r.emails``, ``r.account``.
|
|
5
|
+
|
|
6
|
+
Configuration goes through one object, :class:`RenidlyConfig`, so there is a
|
|
7
|
+
single place (with autocomplete) for every option::
|
|
8
|
+
|
|
9
|
+
from renidly import Renidly, RenidlyConfig
|
|
10
|
+
client = Renidly("rnd-...", config=RenidlyConfig(timeout=10, auto_rate_limit=True))
|
|
11
|
+
|
|
12
|
+
``api_key`` may be passed positionally for convenience; it overrides
|
|
13
|
+
``config.api_key`` and falls back to the ``RENIDLY_API_KEY`` env var.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import replace
|
|
18
|
+
from typing import Any, Dict, Optional
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from ._config import RenidlyConfig
|
|
23
|
+
from ._models import APIResponse
|
|
24
|
+
from ._ratelimit import build_async_limiter, build_sync_limiter
|
|
25
|
+
from ._transport import AsyncTransport, SyncTransport
|
|
26
|
+
from .resources.account import Account, AsyncAccount
|
|
27
|
+
from .resources.data import AsyncData, Data
|
|
28
|
+
from .resources.emails import AsyncEmails, Emails
|
|
29
|
+
from .resources.live import AsyncLive, Live
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _resolve(api_key: Optional[str], config: Optional[RenidlyConfig]) -> RenidlyConfig:
|
|
33
|
+
cfg = replace(config) if config is not None else RenidlyConfig() # copy; never mutate the caller's
|
|
34
|
+
if api_key is not None:
|
|
35
|
+
cfg.api_key = api_key
|
|
36
|
+
return cfg
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Renidly:
|
|
40
|
+
"""Synchronous Renidly client. See :class:`RenidlyConfig` for all options."""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
api_key: Optional[str] = None,
|
|
45
|
+
*,
|
|
46
|
+
config: Optional[RenidlyConfig] = None,
|
|
47
|
+
http_client: Optional[httpx.Client] = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
self._config = _resolve(api_key, config)
|
|
50
|
+
self._transport = SyncTransport(self._config, client=http_client)
|
|
51
|
+
self._transport._limiter = build_sync_limiter(self._config, self._transport)
|
|
52
|
+
self.account = Account(self)
|
|
53
|
+
self.data = Data(self)
|
|
54
|
+
self.emails = Emails(self)
|
|
55
|
+
self.live = Live(self)
|
|
56
|
+
|
|
57
|
+
def raw_request(
|
|
58
|
+
self,
|
|
59
|
+
method: str,
|
|
60
|
+
path: str,
|
|
61
|
+
*,
|
|
62
|
+
service: str = "data",
|
|
63
|
+
params: Optional[Dict[str, Any]] = None,
|
|
64
|
+
json: Optional[Dict[str, Any]] = None,
|
|
65
|
+
options: Optional[Dict[str, Any]] = None,
|
|
66
|
+
) -> APIResponse:
|
|
67
|
+
"""Escape hatch: call any endpoint directly; returns the raw envelope."""
|
|
68
|
+
r = self._transport.request(method, service, path, params=params, json=json, options=options)
|
|
69
|
+
return APIResponse._build(r.envelope, r.last_response)
|
|
70
|
+
|
|
71
|
+
def close(self) -> None:
|
|
72
|
+
"""Close the underlying HTTP client and release its connections."""
|
|
73
|
+
self._transport.close()
|
|
74
|
+
|
|
75
|
+
def __enter__(self) -> "Renidly":
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
def __exit__(self, *exc: Any) -> None:
|
|
79
|
+
self.close()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class AsyncRenidly:
|
|
83
|
+
"""Asynchronous Renidly client. See :class:`RenidlyConfig` for all options."""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
api_key: Optional[str] = None,
|
|
88
|
+
*,
|
|
89
|
+
config: Optional[RenidlyConfig] = None,
|
|
90
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
91
|
+
) -> None:
|
|
92
|
+
self._config = _resolve(api_key, config)
|
|
93
|
+
self._transport = AsyncTransport(self._config, client=http_client)
|
|
94
|
+
self._transport._limiter = build_async_limiter(self._config, self._transport)
|
|
95
|
+
self.account = AsyncAccount(self)
|
|
96
|
+
self.data = AsyncData(self)
|
|
97
|
+
self.emails = AsyncEmails(self)
|
|
98
|
+
self.live = AsyncLive(self)
|
|
99
|
+
|
|
100
|
+
async def raw_request(
|
|
101
|
+
self,
|
|
102
|
+
method: str,
|
|
103
|
+
path: str,
|
|
104
|
+
*,
|
|
105
|
+
service: str = "data",
|
|
106
|
+
params: Optional[Dict[str, Any]] = None,
|
|
107
|
+
json: Optional[Dict[str, Any]] = None,
|
|
108
|
+
options: Optional[Dict[str, Any]] = None,
|
|
109
|
+
) -> APIResponse:
|
|
110
|
+
"""Async escape hatch — call any endpoint directly. See :meth:`Renidly.raw_request`."""
|
|
111
|
+
r = await self._transport.request(method, service, path, params=params, json=json, options=options)
|
|
112
|
+
return APIResponse._build(r.envelope, r.last_response)
|
|
113
|
+
|
|
114
|
+
async def close(self) -> None:
|
|
115
|
+
"""Close the underlying async HTTP client."""
|
|
116
|
+
await self._transport.close()
|
|
117
|
+
|
|
118
|
+
async def __aenter__(self) -> "AsyncRenidly":
|
|
119
|
+
return self
|
|
120
|
+
|
|
121
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
122
|
+
await self.close()
|
renidly/_config.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Client configuration and the service routing table.
|
|
2
|
+
|
|
3
|
+
``RenidlyConfig`` is the public, user-facing config object — build it with just
|
|
4
|
+
the fields you care about and pass ``Renidly(config=...)``. The four products
|
|
5
|
+
live under one host but different path prefixes, and the Account product uses a
|
|
6
|
+
*different* auth header (``X-AUTHAPI-Key``); the SDK hides that entirely.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Dict, Optional
|
|
13
|
+
|
|
14
|
+
DEFAULT_BASE_URL = "https://renidly.com"
|
|
15
|
+
DATA_HEADER = "X-renidly-apikey"
|
|
16
|
+
ACCOUNT_HEADER = "X-AUTHAPI-Key"
|
|
17
|
+
|
|
18
|
+
# service -> (path prefix, auth header)
|
|
19
|
+
SERVICES: Dict[str, tuple] = {
|
|
20
|
+
"data": ("/api/data/v1", DATA_HEADER),
|
|
21
|
+
"live": ("/api/v2", DATA_HEADER),
|
|
22
|
+
"emails": ("/api/emails/v1", DATA_HEADER),
|
|
23
|
+
"account": ("/api/panel", ACCOUNT_HEADER),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class RenidlyConfig:
|
|
29
|
+
"""Client configuration. Every field is optional and has a sensible default.
|
|
30
|
+
|
|
31
|
+
Build only what you need::
|
|
32
|
+
|
|
33
|
+
from renidly import Renidly, RenidlyConfig
|
|
34
|
+
cfg = RenidlyConfig(timeout=10, max_retries=5, auto_rate_limit=True)
|
|
35
|
+
client = Renidly("rnd-...", config=cfg)
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
api_key: Optional[str] = None
|
|
39
|
+
"""API key. Falls back to the ``RENIDLY_API_KEY`` env var. The client's
|
|
40
|
+
positional ``api_key`` argument, if given, takes precedence over this."""
|
|
41
|
+
timeout: float = 30.0
|
|
42
|
+
"""Per-request timeout in seconds."""
|
|
43
|
+
max_retries: int = 2
|
|
44
|
+
"""Auto-retries on 429 / 503 / connection errors (exponential backoff + jitter)."""
|
|
45
|
+
backoff_factor: float = 0.5
|
|
46
|
+
"""Base seconds for the exponential backoff between retries."""
|
|
47
|
+
base_url: str = DEFAULT_BASE_URL
|
|
48
|
+
"""Override the API host (e.g. a staging environment)."""
|
|
49
|
+
proxy: Optional[str] = None
|
|
50
|
+
"""HTTP(S) proxy URL."""
|
|
51
|
+
default_headers: Dict[str, str] = field(default_factory=dict)
|
|
52
|
+
"""Extra headers sent on every request."""
|
|
53
|
+
|
|
54
|
+
# ── Behavior flags ──
|
|
55
|
+
unwrap_data_obj: bool = True
|
|
56
|
+
"""Return the unwrapped ``data`` model. If False, return the full envelope."""
|
|
57
|
+
raise_on_not_found: bool = False
|
|
58
|
+
"""Single lookups: raise ``NotFoundError`` instead of returning ``None`` when empty."""
|
|
59
|
+
raise_on_api_error: bool = True
|
|
60
|
+
"""Map ``success:false`` responses to typed exceptions."""
|
|
61
|
+
|
|
62
|
+
# ── Built-in client-side rate limiting ──
|
|
63
|
+
auto_rate_limit: bool = False
|
|
64
|
+
"""Throttle requests to stay under your per-minute limit automatically."""
|
|
65
|
+
rate_limit_per_minute: Optional[int] = None
|
|
66
|
+
"""Fixed per-minute limit. REQUIRED for enterprise keys; optional override otherwise."""
|
|
67
|
+
rate_limit_safety: float = 1.0
|
|
68
|
+
"""Fraction of the limit to target (e.g. 0.9 leaves headroom for clock skew)."""
|
|
69
|
+
rate_limit_refresh: float = 300.0
|
|
70
|
+
"""Seconds between tier re-fetches for auto-fetched (non-fixed) limits."""
|
|
71
|
+
|
|
72
|
+
def __post_init__(self) -> None:
|
|
73
|
+
if self.api_key is None:
|
|
74
|
+
self.api_key = os.environ.get("RENIDLY_API_KEY")
|
|
75
|
+
self.base_url = self.base_url.rstrip("/")
|
|
76
|
+
|
|
77
|
+
def _url_for(self, service: str, path: str) -> str:
|
|
78
|
+
prefix, _ = SERVICES[service]
|
|
79
|
+
return f"{self.base_url}{prefix}{path}"
|
|
80
|
+
|
|
81
|
+
def _auth_header_for(self, service: str) -> str:
|
|
82
|
+
_, header = SERVICES[service]
|
|
83
|
+
return header
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
Config = RenidlyConfig # internal alias
|
renidly/_errors.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Typed exception hierarchy, mapped from the shared response envelope.
|
|
2
|
+
|
|
3
|
+
Every request that fails raises a subclass of :class:`RenidlyError`. Catch the
|
|
4
|
+
specific class you care about, or ``RenidlyError`` for all of them.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any, Dict, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RenidlyError(Exception):
|
|
12
|
+
"""Base class for every error raised by the SDK."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
message: str,
|
|
17
|
+
*,
|
|
18
|
+
status_code: Optional[int] = None,
|
|
19
|
+
error_code: Optional[str] = None,
|
|
20
|
+
errors: Optional[Dict[str, Any]] = None,
|
|
21
|
+
request_id: Optional[str] = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
#: The clean, top-level server message (e.g. "Validation failed").
|
|
24
|
+
self.message = message
|
|
25
|
+
self.status_code = status_code
|
|
26
|
+
self.error_code = error_code
|
|
27
|
+
#: Field-level detail (``{field: reason}``) — the *why* behind the message.
|
|
28
|
+
self.errors = errors or {}
|
|
29
|
+
self.request_id = request_id
|
|
30
|
+
# The exception's str() includes everything, so an uncaught traceback (or
|
|
31
|
+
# a plain print/log) shows exactly what went wrong — not just "Validation
|
|
32
|
+
# failed". Example: "Validation failed — name: must be at least 3 characters
|
|
33
|
+
# (VALIDATION_ERROR, request_id=req_1)".
|
|
34
|
+
super().__init__(self._full_message())
|
|
35
|
+
|
|
36
|
+
def _full_message(self) -> str:
|
|
37
|
+
detail = self.message
|
|
38
|
+
if self.errors:
|
|
39
|
+
fields = "; ".join(f"{k}: {v}" for k, v in self.errors.items())
|
|
40
|
+
detail = f"{detail} — {fields}" if fields else detail
|
|
41
|
+
meta = []
|
|
42
|
+
if self.error_code:
|
|
43
|
+
meta.append(str(self.error_code))
|
|
44
|
+
if self.status_code:
|
|
45
|
+
meta.append(f"HTTP {self.status_code}")
|
|
46
|
+
if self.request_id:
|
|
47
|
+
meta.append(f"request_id={self.request_id}")
|
|
48
|
+
return f"{detail} ({', '.join(meta)})" if meta else detail
|
|
49
|
+
|
|
50
|
+
def __repr__(self) -> str: # pragma: no cover - debug aid
|
|
51
|
+
return (
|
|
52
|
+
f"{type(self).__name__}(message={self.message!r}, "
|
|
53
|
+
f"status_code={self.status_code!r}, error_code={self.error_code!r})"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class APIConnectionError(RenidlyError):
|
|
58
|
+
"""The request never reached the API (network failure, timeout, DNS…)."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class APIStatusError(RenidlyError):
|
|
62
|
+
"""The API returned a non-success response. Base of all HTTP-status errors."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class AuthenticationError(APIStatusError):
|
|
66
|
+
"""Missing or invalid API key (HTTP 401 / 403)."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class PermissionDeniedError(APIStatusError):
|
|
70
|
+
"""Key is valid but not allowed here — premium-gated or opted-out (HTTP 403)."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class InvalidRequestError(APIStatusError):
|
|
74
|
+
"""Request failed validation (HTTP 400 / 422). See :attr:`field_errors`."""
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def field_errors(self) -> Dict[str, Any]:
|
|
78
|
+
return self.errors
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class InsufficientCreditsError(APIStatusError):
|
|
82
|
+
"""Not enough credits for the request (HTTP 402 / 403)."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class NotFoundError(APIStatusError):
|
|
86
|
+
"""A lookup resolved nothing, or a batch job was not found / expired."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class RateLimitError(APIStatusError):
|
|
90
|
+
"""Per-minute rate limit for your tier exceeded (HTTP 429)."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, message: str, **kwargs: Any) -> None:
|
|
93
|
+
super().__init__(message, **kwargs)
|
|
94
|
+
meta = self.errors or {}
|
|
95
|
+
self.tier: Optional[str] = meta.get("current_tier") or meta.get("tier")
|
|
96
|
+
self.limit: Optional[int] = meta.get("current_limit") or meta.get("limit")
|
|
97
|
+
self.retry_after: Optional[float] = meta.get("retry_after")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ServiceUnavailableError(APIStatusError):
|
|
101
|
+
"""Temporarily unavailable — safe to retry shortly (HTTP 503)."""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class InternalServerError(APIStatusError):
|
|
105
|
+
"""Unexpected server error (HTTP 5xx)."""
|
renidly/_models.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Base response model and envelope wrapper.
|
|
2
|
+
|
|
3
|
+
Models are permissive (``extra="allow"``) so every field the API returns is
|
|
4
|
+
accessible as an attribute even before a typed model is generated for it — the
|
|
5
|
+
generated subclasses in ``renidly.types`` add precise typing + autocomplete on
|
|
6
|
+
top of the same base.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Dict, Optional, Type, TypeVar
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, PrivateAttr
|
|
13
|
+
|
|
14
|
+
T = TypeVar("T", bound="RenidlyModel")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LastResponse:
|
|
18
|
+
"""HTTP metadata from the call that produced an object."""
|
|
19
|
+
|
|
20
|
+
__slots__ = ("status_code", "headers", "request_id")
|
|
21
|
+
|
|
22
|
+
def __init__(self, status_code: int, headers: Dict[str, str], request_id: Optional[str]) -> None:
|
|
23
|
+
self.status_code = status_code
|
|
24
|
+
self.headers = headers
|
|
25
|
+
self.request_id = request_id
|
|
26
|
+
|
|
27
|
+
def __repr__(self) -> str: # pragma: no cover
|
|
28
|
+
return f"LastResponse(status_code={self.status_code}, request_id={self.request_id!r})"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RenidlyModel(BaseModel):
|
|
32
|
+
"""Base for every response object. Dynamic + typed."""
|
|
33
|
+
|
|
34
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True, arbitrary_types_allowed=True)
|
|
35
|
+
|
|
36
|
+
_last_response: Optional[LastResponse] = PrivateAttr(default=None)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def last_response(self) -> Optional[LastResponse]:
|
|
40
|
+
"""HTTP status/headers/request-id of the response that produced this object."""
|
|
41
|
+
return self._last_response
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def _coerce(cls, v: Any) -> Any:
|
|
45
|
+
"""Recursively wrap nested dicts/lists so ``obj.a.b`` works dynamically.
|
|
46
|
+
|
|
47
|
+
Nested values become the base ``RenidlyModel`` (typed subclasses declare
|
|
48
|
+
their own nested fields and validate natively, bypassing this)."""
|
|
49
|
+
if isinstance(v, dict):
|
|
50
|
+
return RenidlyModel.model_validate({k: RenidlyModel._coerce(val) for k, val in v.items()})
|
|
51
|
+
if isinstance(v, list):
|
|
52
|
+
return [RenidlyModel._coerce(x) for x in v]
|
|
53
|
+
return v
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def _build(cls: Type[T], data: Any, last_response: Optional[LastResponse]) -> T:
|
|
57
|
+
if isinstance(data, dict):
|
|
58
|
+
obj = cls.model_validate({k: RenidlyModel._coerce(v) for k, v in data.items()})
|
|
59
|
+
else:
|
|
60
|
+
obj = cls.model_validate({"value": data})
|
|
61
|
+
obj._last_response = last_response
|
|
62
|
+
return obj
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class APIResponse(RenidlyModel):
|
|
66
|
+
"""The full shared envelope, returned when ``unwrap_data_obj=False``."""
|
|
67
|
+
|
|
68
|
+
success: bool
|
|
69
|
+
status_code: Optional[int] = None
|
|
70
|
+
message: Optional[str] = None
|
|
71
|
+
error_code: Optional[str] = None
|
|
72
|
+
errors: Optional[Any] = None
|
|
73
|
+
data: Optional[Any] = None
|