sendara 0.2.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,4 @@
1
+ __pycache__/
2
+ *.pyc
3
+ dist/
4
+ *.egg-info/
sendara-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,217 @@
1
+ Metadata-Version: 2.4
2
+ Name: sendara
3
+ Version: 0.2.0
4
+ Summary: Sendara — multi-channel messaging API (email, SMS, push, voice, webhooks) for Python.
5
+ Project-URL: Homepage, https://sendara.dev
6
+ Project-URL: Documentation, https://sendara.dev/docs
7
+ Author: Sendara
8
+ License: MIT
9
+ Keywords: api,email,messaging,otp,sendara,sms,transactional
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.9
18
+ Requires-Dist: httpx<1,>=0.24
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
21
+ Requires-Dist: pytest>=7; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # Sendara — Python SDK
25
+
26
+ The official Python client for the [Sendara](https://sendara.dev) messaging API:
27
+ email, SMS, broadcasts, contacts, templates, webhooks, and more — with sync and
28
+ async clients, typed models, a typed error hierarchy, automatic retries, and an
29
+ auto-paginating message iterator.
30
+
31
+ ```bash
32
+ pip install sendara
33
+ ```
34
+
35
+ Requires Python 3.9+. Runtime dependency: `httpx`.
36
+
37
+ ## Quickstart
38
+
39
+ ```python
40
+ import os
41
+ from sendara import Sendara, SendaraError
42
+
43
+ client = Sendara(os.environ["SENDARA_API_KEY"]) # sk_live_... or sk_test_...
44
+
45
+ # Send an email
46
+ result = client.emails.send(
47
+ from_="hello@acme.com",
48
+ to="user@example.com",
49
+ subject="Welcome to Acme",
50
+ html="<h1>Welcome 🎉</h1>",
51
+ )
52
+ print(result.id, result.status)
53
+
54
+ # Send an SMS / OTP
55
+ client.sms.send(to="+254712345678", body="Your Acme code is 481920")
56
+
57
+ # Paginate every message (cursor handled for you)
58
+ for message in client.messages.iter(channel="email", limit=100):
59
+ print(message.id, message.status)
60
+ ```
61
+
62
+ ### Async
63
+
64
+ ```python
65
+ import asyncio
66
+ from sendara import AsyncSendara
67
+
68
+ async def main():
69
+ async with AsyncSendara("sk_live_...") as client:
70
+ await client.emails.send(to="user@example.com", subject="Hi", html="<p>Hi</p>")
71
+ async for message in client.messages.iter(limit=100):
72
+ print(message.id)
73
+
74
+ asyncio.run(main())
75
+ ```
76
+
77
+ Both clients are context managers (`with` / `async with`) and accept
78
+ `base_url`, `timeout`, `max_retries`, and an optional `http_client`.
79
+
80
+ ```python
81
+ client = Sendara(
82
+ "sk_live_...",
83
+ base_url="https://api.sendara.dev",
84
+ timeout=30.0,
85
+ max_retries=3,
86
+ )
87
+ ```
88
+
89
+ ## Idempotency
90
+
91
+ Every send accepts an `idempotency_key`. The SDK generates one (UUIDv4)
92
+ automatically when you omit it, so retries are always safe. Pass your own to
93
+ deduplicate across processes:
94
+
95
+ ```python
96
+ client.emails.send(to="u@e.com", subject="s", text="t", idempotency_key="order-42")
97
+ ```
98
+
99
+ ## Retries
100
+
101
+ Idempotent requests (all GET/PUT/DELETE and sends) are retried automatically on
102
+ `429`, `409`, `5xx`, and network/timeout errors, using exponential backoff with
103
+ jitter. A `Retry-After` header is honored when present. Attempts are bounded by
104
+ `max_retries`.
105
+
106
+ ## Errors
107
+
108
+ Every failure raises a subclass of `SendaraError` carrying `status`, `code`,
109
+ `message`, and `request_id`:
110
+
111
+ | HTTP | Exception |
112
+ | ----------- | ---------------------- |
113
+ | 400 / 422 | `ValidationError` |
114
+ | 401 | `AuthenticationError` |
115
+ | 403 | `PermissionError_` |
116
+ | 404 | `NotFoundError` |
117
+ | 409 | `ConflictError` |
118
+ | 429 | `RateLimitError` (`.retry_after`) |
119
+ | 5xx | `ServerError` |
120
+ | network | `APIConnectionError` / `APITimeoutError` |
121
+
122
+ ```python
123
+ from sendara import RateLimitError, SendaraError
124
+
125
+ try:
126
+ client.emails.send(to="u@e.com", subject="s", html="<p>x</p>")
127
+ except RateLimitError as e:
128
+ print("retry after", e.retry_after)
129
+ except SendaraError as e:
130
+ print(e.status, e.code, e.message)
131
+ ```
132
+
133
+ ## Verifying webhooks
134
+
135
+ `webhooks.verify` recomputes `HMAC-SHA256(secret, "<timestamp>.<raw_body>")`
136
+ (hex) and checks it in constant time against the `Sendara-Signature` header.
137
+ Pass the **raw** request body, never a re-serialized object.
138
+
139
+ ```python
140
+ from sendara import webhooks
141
+
142
+ # Flask example
143
+ @app.post("/webhooks/sendara")
144
+ def hook():
145
+ event = webhooks.verify(
146
+ os.environ["SENDARA_WEBHOOK_SECRET"],
147
+ request.get_data(), # raw bytes
148
+ request.headers, # case-insensitive mapping
149
+ )
150
+ print(event["event_type"], event["message_id"])
151
+ return "", 200
152
+ ```
153
+
154
+ `verify` raises `WebhookVerificationError` on any mismatch (bad signature,
155
+ missing headers, or a timestamp outside the `tolerance` window, default 300s).
156
+
157
+ ## Method reference
158
+
159
+ ```text
160
+ client.emails.send(to, subject, html=, text=, from_=, message_type=,
161
+ template_id=, template_vars=, idempotency_key=, metadata=, test_send=)
162
+ client.emails.send_raw(request) # POST /v1/send (escape hatch)
163
+ client.emails.send_batch([req, ...]) # POST /v1/send/batch
164
+ client.emails.send_bulk(request) # POST /v1/send/bulk
165
+ client.sms.send(to, body, sender_id=, message_type=, idempotency_key=)
166
+
167
+ client.broadcasts.create(**params) / .list(limit=, offset=) / .get(id)
168
+ .send(id) / .cancel(id) / .delete(id)
169
+
170
+ client.messages.list(channel=, status=, from_=, to=, limit=, cursor=)
171
+ client.messages.iter(channel=, status=, from_=, to=, limit=) # auto-paginating
172
+ client.messages.get(id)
173
+
174
+ client.suppressions.list(channel=) / .create(channel, recipient, reason=)
175
+ .delete(channel, recipient)
176
+
177
+ client.domains.list() / .create(domain) / .get(domain) / .verify(domain)
178
+
179
+ client.api_keys.list() / .create(scope=, test_mode=) / .rotate(id) / .revoke(id)
180
+
181
+ client.usage.get(period=)
182
+ client.usage.set_spend_cap(key_id=, soft_limit_micros=, hard_limit_micros=)
183
+
184
+ client.billing.get() / .checkout(plan=) / .portal()
185
+
186
+ client.templates.create(**params) / .list() / .get(id) / .update(id, **params)
187
+ .delete(id) / .render(id, vars=)
188
+
189
+ client.contacts.create(**params) / .list(limit=, offset=) / .get(id)
190
+ .update(id, **params) / .delete(id) / .import_(s3_key=, format=)
191
+ client.lists.create(name, list_type=, segment_rules=) / .list() / .get(id)
192
+ .update(id, **params) / .delete(id)
193
+ .add_member(id, contact_id) / .remove_member(id, contact_id) / .members(id)
194
+
195
+ client.webhooks.create(endpoint_url, event_types=) / .list() / .get(id)
196
+ .update(id, **params) / .delete(id)
197
+ .deliveries(id, limit=) / .rotate_secret(id)
198
+
199
+ client.uploads.create(file, filename=, content_type=) # multipart image
200
+
201
+ client.test_recipients.list() / .create(email) / .resend(id) / .delete(id)
202
+
203
+ webhooks.verify(secret, payload, headers=, signature=, timestamp=, tolerance=300)
204
+ ```
205
+
206
+ The async client (`AsyncSendara`) exposes every method above with `await`;
207
+ `messages.iter` becomes an `async for`.
208
+
209
+ ## Development
210
+
211
+ ```bash
212
+ cd sdk/python
213
+ pip install -e '.[dev]'
214
+ python -m pytest
215
+ ```
216
+
217
+ MIT licensed.
@@ -0,0 +1,194 @@
1
+ # Sendara — Python SDK
2
+
3
+ The official Python client for the [Sendara](https://sendara.dev) messaging API:
4
+ email, SMS, broadcasts, contacts, templates, webhooks, and more — with sync and
5
+ async clients, typed models, a typed error hierarchy, automatic retries, and an
6
+ auto-paginating message iterator.
7
+
8
+ ```bash
9
+ pip install sendara
10
+ ```
11
+
12
+ Requires Python 3.9+. Runtime dependency: `httpx`.
13
+
14
+ ## Quickstart
15
+
16
+ ```python
17
+ import os
18
+ from sendara import Sendara, SendaraError
19
+
20
+ client = Sendara(os.environ["SENDARA_API_KEY"]) # sk_live_... or sk_test_...
21
+
22
+ # Send an email
23
+ result = client.emails.send(
24
+ from_="hello@acme.com",
25
+ to="user@example.com",
26
+ subject="Welcome to Acme",
27
+ html="<h1>Welcome 🎉</h1>",
28
+ )
29
+ print(result.id, result.status)
30
+
31
+ # Send an SMS / OTP
32
+ client.sms.send(to="+254712345678", body="Your Acme code is 481920")
33
+
34
+ # Paginate every message (cursor handled for you)
35
+ for message in client.messages.iter(channel="email", limit=100):
36
+ print(message.id, message.status)
37
+ ```
38
+
39
+ ### Async
40
+
41
+ ```python
42
+ import asyncio
43
+ from sendara import AsyncSendara
44
+
45
+ async def main():
46
+ async with AsyncSendara("sk_live_...") as client:
47
+ await client.emails.send(to="user@example.com", subject="Hi", html="<p>Hi</p>")
48
+ async for message in client.messages.iter(limit=100):
49
+ print(message.id)
50
+
51
+ asyncio.run(main())
52
+ ```
53
+
54
+ Both clients are context managers (`with` / `async with`) and accept
55
+ `base_url`, `timeout`, `max_retries`, and an optional `http_client`.
56
+
57
+ ```python
58
+ client = Sendara(
59
+ "sk_live_...",
60
+ base_url="https://api.sendara.dev",
61
+ timeout=30.0,
62
+ max_retries=3,
63
+ )
64
+ ```
65
+
66
+ ## Idempotency
67
+
68
+ Every send accepts an `idempotency_key`. The SDK generates one (UUIDv4)
69
+ automatically when you omit it, so retries are always safe. Pass your own to
70
+ deduplicate across processes:
71
+
72
+ ```python
73
+ client.emails.send(to="u@e.com", subject="s", text="t", idempotency_key="order-42")
74
+ ```
75
+
76
+ ## Retries
77
+
78
+ Idempotent requests (all GET/PUT/DELETE and sends) are retried automatically on
79
+ `429`, `409`, `5xx`, and network/timeout errors, using exponential backoff with
80
+ jitter. A `Retry-After` header is honored when present. Attempts are bounded by
81
+ `max_retries`.
82
+
83
+ ## Errors
84
+
85
+ Every failure raises a subclass of `SendaraError` carrying `status`, `code`,
86
+ `message`, and `request_id`:
87
+
88
+ | HTTP | Exception |
89
+ | ----------- | ---------------------- |
90
+ | 400 / 422 | `ValidationError` |
91
+ | 401 | `AuthenticationError` |
92
+ | 403 | `PermissionError_` |
93
+ | 404 | `NotFoundError` |
94
+ | 409 | `ConflictError` |
95
+ | 429 | `RateLimitError` (`.retry_after`) |
96
+ | 5xx | `ServerError` |
97
+ | network | `APIConnectionError` / `APITimeoutError` |
98
+
99
+ ```python
100
+ from sendara import RateLimitError, SendaraError
101
+
102
+ try:
103
+ client.emails.send(to="u@e.com", subject="s", html="<p>x</p>")
104
+ except RateLimitError as e:
105
+ print("retry after", e.retry_after)
106
+ except SendaraError as e:
107
+ print(e.status, e.code, e.message)
108
+ ```
109
+
110
+ ## Verifying webhooks
111
+
112
+ `webhooks.verify` recomputes `HMAC-SHA256(secret, "<timestamp>.<raw_body>")`
113
+ (hex) and checks it in constant time against the `Sendara-Signature` header.
114
+ Pass the **raw** request body, never a re-serialized object.
115
+
116
+ ```python
117
+ from sendara import webhooks
118
+
119
+ # Flask example
120
+ @app.post("/webhooks/sendara")
121
+ def hook():
122
+ event = webhooks.verify(
123
+ os.environ["SENDARA_WEBHOOK_SECRET"],
124
+ request.get_data(), # raw bytes
125
+ request.headers, # case-insensitive mapping
126
+ )
127
+ print(event["event_type"], event["message_id"])
128
+ return "", 200
129
+ ```
130
+
131
+ `verify` raises `WebhookVerificationError` on any mismatch (bad signature,
132
+ missing headers, or a timestamp outside the `tolerance` window, default 300s).
133
+
134
+ ## Method reference
135
+
136
+ ```text
137
+ client.emails.send(to, subject, html=, text=, from_=, message_type=,
138
+ template_id=, template_vars=, idempotency_key=, metadata=, test_send=)
139
+ client.emails.send_raw(request) # POST /v1/send (escape hatch)
140
+ client.emails.send_batch([req, ...]) # POST /v1/send/batch
141
+ client.emails.send_bulk(request) # POST /v1/send/bulk
142
+ client.sms.send(to, body, sender_id=, message_type=, idempotency_key=)
143
+
144
+ client.broadcasts.create(**params) / .list(limit=, offset=) / .get(id)
145
+ .send(id) / .cancel(id) / .delete(id)
146
+
147
+ client.messages.list(channel=, status=, from_=, to=, limit=, cursor=)
148
+ client.messages.iter(channel=, status=, from_=, to=, limit=) # auto-paginating
149
+ client.messages.get(id)
150
+
151
+ client.suppressions.list(channel=) / .create(channel, recipient, reason=)
152
+ .delete(channel, recipient)
153
+
154
+ client.domains.list() / .create(domain) / .get(domain) / .verify(domain)
155
+
156
+ client.api_keys.list() / .create(scope=, test_mode=) / .rotate(id) / .revoke(id)
157
+
158
+ client.usage.get(period=)
159
+ client.usage.set_spend_cap(key_id=, soft_limit_micros=, hard_limit_micros=)
160
+
161
+ client.billing.get() / .checkout(plan=) / .portal()
162
+
163
+ client.templates.create(**params) / .list() / .get(id) / .update(id, **params)
164
+ .delete(id) / .render(id, vars=)
165
+
166
+ client.contacts.create(**params) / .list(limit=, offset=) / .get(id)
167
+ .update(id, **params) / .delete(id) / .import_(s3_key=, format=)
168
+ client.lists.create(name, list_type=, segment_rules=) / .list() / .get(id)
169
+ .update(id, **params) / .delete(id)
170
+ .add_member(id, contact_id) / .remove_member(id, contact_id) / .members(id)
171
+
172
+ client.webhooks.create(endpoint_url, event_types=) / .list() / .get(id)
173
+ .update(id, **params) / .delete(id)
174
+ .deliveries(id, limit=) / .rotate_secret(id)
175
+
176
+ client.uploads.create(file, filename=, content_type=) # multipart image
177
+
178
+ client.test_recipients.list() / .create(email) / .resend(id) / .delete(id)
179
+
180
+ webhooks.verify(secret, payload, headers=, signature=, timestamp=, tolerance=300)
181
+ ```
182
+
183
+ The async client (`AsyncSendara`) exposes every method above with `await`;
184
+ `messages.iter` becomes an `async for`.
185
+
186
+ ## Development
187
+
188
+ ```bash
189
+ cd sdk/python
190
+ pip install -e '.[dev]'
191
+ python -m pytest
192
+ ```
193
+
194
+ MIT licensed.
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sendara"
7
+ version = "0.2.0"
8
+ description = "Sendara — multi-channel messaging API (email, SMS, push, voice, webhooks) for Python."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ keywords = ["sendara", "email", "sms", "messaging", "api", "transactional", "otp"]
13
+ authors = [{ name = "Sendara" }]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.9",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Typing :: Typed",
22
+ ]
23
+ dependencies = ["httpx>=0.24,<1"]
24
+
25
+ [project.optional-dependencies]
26
+ dev = [
27
+ "pytest>=7",
28
+ "pytest-asyncio>=0.21",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://sendara.dev"
33
+ Documentation = "https://sendara.dev/docs"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["sendara"]
37
+
38
+ [tool.pytest.ini_options]
39
+ asyncio_mode = "auto"
40
+ testpaths = ["tests"]
@@ -0,0 +1,79 @@
1
+ from . import models, webhooks
2
+ from .async_client import AsyncSendara
3
+ from .client import Sendara
4
+ from .errors import (
5
+ APIConnectionError,
6
+ APITimeoutError,
7
+ AuthenticationError,
8
+ ConflictError,
9
+ NotFoundError,
10
+ PermissionError_,
11
+ RateLimitError,
12
+ SendaraError,
13
+ ServerError,
14
+ ValidationError,
15
+ )
16
+ from .models import (
17
+ ApiKey,
18
+ BillingState,
19
+ BimiDmarc,
20
+ BimiRecord,
21
+ BimiStatus,
22
+ Broadcast,
23
+ Channel,
24
+ Contact,
25
+ ContactList,
26
+ Domain,
27
+ Message,
28
+ MessageType,
29
+ SendResult,
30
+ Suppression,
31
+ Template,
32
+ TestRecipient,
33
+ Upload,
34
+ UsageSummary,
35
+ WebhookDelivery,
36
+ WebhookSubscription,
37
+ )
38
+ from .webhooks_verify import WebhookVerificationError
39
+
40
+ __version__ = "0.2.0"
41
+
42
+ __all__ = [
43
+ "Sendara",
44
+ "AsyncSendara",
45
+ "SendaraError",
46
+ "APIConnectionError",
47
+ "APITimeoutError",
48
+ "AuthenticationError",
49
+ "PermissionError_",
50
+ "ValidationError",
51
+ "NotFoundError",
52
+ "ConflictError",
53
+ "RateLimitError",
54
+ "ServerError",
55
+ "WebhookVerificationError",
56
+ "models",
57
+ "webhooks",
58
+ "Channel",
59
+ "MessageType",
60
+ "SendResult",
61
+ "Message",
62
+ "Broadcast",
63
+ "Suppression",
64
+ "Domain",
65
+ "ApiKey",
66
+ "UsageSummary",
67
+ "BillingState",
68
+ "Template",
69
+ "Contact",
70
+ "ContactList",
71
+ "WebhookSubscription",
72
+ "WebhookDelivery",
73
+ "Upload",
74
+ "TestRecipient",
75
+ "BimiStatus",
76
+ "BimiRecord",
77
+ "BimiDmarc",
78
+ "__version__",
79
+ ]
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from typing import Any, Dict, Optional
5
+
6
+ import httpx
7
+
8
+ from ._config import ClientConfig
9
+ from ._transport import JsonBody, PreparedRequest, RequestBuilder, connection_error
10
+
11
+
12
+ class AsyncHTTP:
13
+ """Asynchronous transport over httpx.AsyncClient with retry/backoff."""
14
+
15
+ def __init__(
16
+ self,
17
+ config: ClientConfig,
18
+ http_client: Optional[httpx.AsyncClient] = None,
19
+ ) -> None:
20
+ self._builder = RequestBuilder(config)
21
+ self._owns_client = http_client is None
22
+ self._client = http_client or httpx.AsyncClient(
23
+ timeout=self._builder.config.timeout
24
+ )
25
+
26
+ @property
27
+ def config(self) -> ClientConfig:
28
+ return self._builder.config
29
+
30
+ async def request(
31
+ self,
32
+ method: str,
33
+ path: str,
34
+ *,
35
+ json_body: Optional[JsonBody] = None,
36
+ files: Optional[Dict[str, Any]] = None,
37
+ idempotent: Optional[bool] = None,
38
+ ) -> Any:
39
+ prepared = self._builder.prepare(
40
+ method, path, json_body=json_body, files=files, idempotent=idempotent
41
+ )
42
+ return await self._send(prepared)
43
+
44
+ async def _send(self, prepared: PreparedRequest) -> Any:
45
+ attempt = 0
46
+ while True:
47
+ try:
48
+ response = await self._client.request(
49
+ prepared.method,
50
+ prepared.url,
51
+ headers=prepared.headers,
52
+ json=prepared.json_body if prepared.files is None else None,
53
+ files=prepared.files,
54
+ timeout=self._builder.config.timeout,
55
+ )
56
+ except httpx.HTTPError as exc:
57
+ if prepared.idempotent and self._builder.should_retry(attempt, None):
58
+ await asyncio.sleep(self._builder.backoff_delay(attempt, None))
59
+ attempt += 1
60
+ continue
61
+ raise connection_error(exc) from None
62
+
63
+ headers = dict(response.headers)
64
+ if prepared.idempotent and self._builder.should_retry(
65
+ attempt, response.status_code
66
+ ):
67
+ retry_after = self._builder.retry_after_from_headers(headers)
68
+ await asyncio.sleep(self._builder.backoff_delay(attempt, retry_after))
69
+ attempt += 1
70
+ continue
71
+
72
+ return self._builder.interpret(
73
+ response.status_code, response.content, headers
74
+ )
75
+
76
+ async def aclose(self) -> None:
77
+ if self._owns_client:
78
+ await self._client.aclose()
79
+
80
+ async def __aenter__(self) -> "AsyncHTTP":
81
+ return self
82
+
83
+ async def __aexit__(self, *_exc: object) -> None:
84
+ await self.aclose()
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ DEFAULT_BASE_URL = "https://api.sendara.dev"
6
+ DEFAULT_TIMEOUT = 30.0
7
+ DEFAULT_MAX_RETRIES = 3
8
+ DEFAULT_USER_AGENT = "sendara-python/0.2.0"
9
+
10
+ RETRYABLE_STATUS = frozenset({408, 409, 429, 500, 502, 503, 504})
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class ClientConfig:
15
+ api_key: str
16
+ base_url: str = DEFAULT_BASE_URL
17
+ timeout: float = DEFAULT_TIMEOUT
18
+ max_retries: int = DEFAULT_MAX_RETRIES
19
+
20
+ def normalized(self) -> "ClientConfig":
21
+ return ClientConfig(
22
+ api_key=self.api_key,
23
+ base_url=self.base_url.rstrip("/"),
24
+ timeout=self.timeout,
25
+ max_retries=max(0, self.max_retries),
26
+ )