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.
- sendara-0.2.0/.gitignore +4 -0
- sendara-0.2.0/PKG-INFO +217 -0
- sendara-0.2.0/README.md +194 -0
- sendara-0.2.0/pyproject.toml +40 -0
- sendara-0.2.0/sendara/__init__.py +79 -0
- sendara-0.2.0/sendara/_async_http.py +84 -0
- sendara-0.2.0/sendara/_config.py +26 -0
- sendara-0.2.0/sendara/_params.py +72 -0
- sendara-0.2.0/sendara/_sync_http.py +88 -0
- sendara-0.2.0/sendara/_transport.py +161 -0
- sendara-0.2.0/sendara/async_client.py +100 -0
- sendara-0.2.0/sendara/async_resources.py +576 -0
- sendara-0.2.0/sendara/client.py +100 -0
- sendara-0.2.0/sendara/errors.py +111 -0
- sendara-0.2.0/sendara/models.py +237 -0
- sendara-0.2.0/sendara/py.typed +0 -0
- sendara-0.2.0/sendara/resources.py +546 -0
- sendara-0.2.0/sendara/webhooks.py +19 -0
- sendara-0.2.0/sendara/webhooks_verify.py +92 -0
- sendara-0.2.0/tests/conftest.py +88 -0
- sendara-0.2.0/tests/test_async.py +85 -0
- sendara-0.2.0/tests/test_errors_and_retry.py +106 -0
- sendara-0.2.0/tests/test_pagination.py +43 -0
- sendara-0.2.0/tests/test_requests.py +247 -0
- sendara-0.2.0/tests/test_webhooks.py +99 -0
sendara-0.2.0/.gitignore
ADDED
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.
|
sendara-0.2.0/README.md
ADDED
|
@@ -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
|
+
)
|