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 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