couryo 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.
- couryo/__init__.py +48 -0
- couryo/_async_client.py +398 -0
- couryo/_base.py +223 -0
- couryo/_client.py +397 -0
- couryo/_version.py +1 -0
- couryo/django.py +249 -0
- couryo/errors.py +169 -0
- couryo/py.typed +0 -0
- couryo/types.py +611 -0
- couryo/webhooks.py +126 -0
- couryo-0.1.0.dist-info/METADATA +309 -0
- couryo-0.1.0.dist-info/RECORD +14 -0
- couryo-0.1.0.dist-info/WHEEL +4 -0
- couryo-0.1.0.dist-info/licenses/LICENSE +21 -0
couryo/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Official Python SDK for the Couryo email API.
|
|
2
|
+
|
|
3
|
+
.. code-block:: python
|
|
4
|
+
|
|
5
|
+
from couryo import Couryo
|
|
6
|
+
|
|
7
|
+
couryo = Couryo() # reads COURYO_API_KEY
|
|
8
|
+
couryo.emails.send({"from": "Shop <hi@example.com>", "to": "ana@example.com", "subject": "Hi", "text": "Hello!"})
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from ._async_client import AsyncCouryo
|
|
12
|
+
from ._base import DEFAULT_BASE_URL, SDK_VERSION
|
|
13
|
+
from ._client import Couryo
|
|
14
|
+
from ._version import __version__
|
|
15
|
+
from .errors import (
|
|
16
|
+
APIError,
|
|
17
|
+
AuthenticationError,
|
|
18
|
+
ConflictError,
|
|
19
|
+
CouryoError,
|
|
20
|
+
InvalidRequestError,
|
|
21
|
+
NetworkError,
|
|
22
|
+
NotFoundError,
|
|
23
|
+
PermissionDeniedError,
|
|
24
|
+
RateLimitError,
|
|
25
|
+
TierLimitError,
|
|
26
|
+
)
|
|
27
|
+
from .webhooks import WebhookVerificationError, sign_webhook, verify_webhook
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"__version__",
|
|
31
|
+
"Couryo",
|
|
32
|
+
"AsyncCouryo",
|
|
33
|
+
"DEFAULT_BASE_URL",
|
|
34
|
+
"SDK_VERSION",
|
|
35
|
+
"CouryoError",
|
|
36
|
+
"InvalidRequestError",
|
|
37
|
+
"AuthenticationError",
|
|
38
|
+
"PermissionDeniedError",
|
|
39
|
+
"NotFoundError",
|
|
40
|
+
"ConflictError",
|
|
41
|
+
"RateLimitError",
|
|
42
|
+
"TierLimitError",
|
|
43
|
+
"APIError",
|
|
44
|
+
"NetworkError",
|
|
45
|
+
"WebhookVerificationError",
|
|
46
|
+
"verify_webhook",
|
|
47
|
+
"sign_webhook",
|
|
48
|
+
]
|
couryo/_async_client.py
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
"""Asynchronous client: :class:`AsyncCouryo`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import datetime as _dt
|
|
7
|
+
import inspect
|
|
8
|
+
from typing import TYPE_CHECKING, Any, AsyncIterator, Callable, Dict, List, Mapping, Optional, Sequence, Union
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from ._base import BaseClient, Flow, enc, to_iso
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from types import TracebackType
|
|
16
|
+
|
|
17
|
+
from .types import (
|
|
18
|
+
Audience,
|
|
19
|
+
AudienceCount,
|
|
20
|
+
BatchSendResponse,
|
|
21
|
+
Campaign,
|
|
22
|
+
CampaignList,
|
|
23
|
+
CampaignPreflight,
|
|
24
|
+
CampaignStats,
|
|
25
|
+
CampaignStatus,
|
|
26
|
+
CheckEmailResponse,
|
|
27
|
+
Contact,
|
|
28
|
+
ContactList,
|
|
29
|
+
CreateCampaignRequest,
|
|
30
|
+
CreateTemplateRequest,
|
|
31
|
+
CreateWebhookRequest,
|
|
32
|
+
Domain,
|
|
33
|
+
DomainHealth,
|
|
34
|
+
DomainList,
|
|
35
|
+
Email,
|
|
36
|
+
EmailEvent,
|
|
37
|
+
EmailEventList,
|
|
38
|
+
EmailEventType,
|
|
39
|
+
EmailList,
|
|
40
|
+
EmailStatus,
|
|
41
|
+
Period,
|
|
42
|
+
PreviewTemplateResponse,
|
|
43
|
+
SendEmailRequest,
|
|
44
|
+
SendEmailResponse,
|
|
45
|
+
Suppression,
|
|
46
|
+
SuppressionList,
|
|
47
|
+
SuppressionReason,
|
|
48
|
+
SuppressionStream,
|
|
49
|
+
Template,
|
|
50
|
+
TemplateList,
|
|
51
|
+
TemplateVersionList,
|
|
52
|
+
TestCampaignResponse,
|
|
53
|
+
TrackEventRequest,
|
|
54
|
+
TrackEventResponse,
|
|
55
|
+
UpdateCampaignRequest,
|
|
56
|
+
UpdateContactRequest,
|
|
57
|
+
UpdateTemplateRequest,
|
|
58
|
+
UpdateWebhookRequest,
|
|
59
|
+
UpsertContactRequest,
|
|
60
|
+
Webhook,
|
|
61
|
+
WebhookList,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
__all__ = ["AsyncCouryo"]
|
|
65
|
+
|
|
66
|
+
DateLike = Union[str, _dt.datetime]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _to_list(value: Union[str, Sequence[str]]) -> List[str]:
|
|
70
|
+
return [value] if isinstance(value, str) else list(value)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class _Resource:
|
|
74
|
+
def __init__(self, client: "AsyncCouryo") -> None:
|
|
75
|
+
self._c = client
|
|
76
|
+
|
|
77
|
+
async def _paginate(self, path: str, query: Dict[str, Any]) -> AsyncIterator[Any]:
|
|
78
|
+
"""Walks every page (cursor pagination, newest first)."""
|
|
79
|
+
after: Optional[str] = None
|
|
80
|
+
while True:
|
|
81
|
+
page = await self._c._request("GET", path, query={**query, "starting_after": after})
|
|
82
|
+
data = (page or {}).get("data") or []
|
|
83
|
+
for item in data:
|
|
84
|
+
yield item
|
|
85
|
+
if not page or not page.get("has_more") or not data:
|
|
86
|
+
return
|
|
87
|
+
after = data[-1]["id"]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Emails(_Resource):
|
|
91
|
+
async def send(self, body: "SendEmailRequest", *, idempotency_key: Optional[str] = None) -> "SendEmailResponse":
|
|
92
|
+
"""Queues one email (202). A POST retried by the SDK reuses the same Idempotency-Key."""
|
|
93
|
+
return await self._c._request("POST", "/emails", body=body, idempotency_key=idempotency_key)
|
|
94
|
+
|
|
95
|
+
async def batch(self, emails: Sequence["SendEmailRequest"], *, idempotency_key: Optional[str] = None) -> "BatchSendResponse":
|
|
96
|
+
"""Up to 100 emails; one result per item, in order."""
|
|
97
|
+
return await self._c._request("POST", "/emails/batch", body={"emails": list(emails)}, idempotency_key=idempotency_key)
|
|
98
|
+
|
|
99
|
+
async def check(self, body: "SendEmailRequest", *, idempotency_key: Optional[str] = None) -> "CheckEmailResponse":
|
|
100
|
+
"""Checks content and sender before sending (nothing is sent)."""
|
|
101
|
+
return await self._c._request("POST", "/emails/check", body=body, idempotency_key=idempotency_key)
|
|
102
|
+
|
|
103
|
+
async def get(self, id: str) -> "Email":
|
|
104
|
+
return await self._c._request("GET", f"/emails/{enc(id)}")
|
|
105
|
+
|
|
106
|
+
async def list(
|
|
107
|
+
self,
|
|
108
|
+
*,
|
|
109
|
+
limit: Optional[int] = None,
|
|
110
|
+
starting_after: Optional[str] = None,
|
|
111
|
+
status: Optional["EmailStatus"] = None,
|
|
112
|
+
recipient: Optional[str] = None,
|
|
113
|
+
tag: Optional[str] = None,
|
|
114
|
+
period: Optional["Period"] = None,
|
|
115
|
+
) -> "EmailList":
|
|
116
|
+
query = {"limit": limit, "starting_after": starting_after, "status": status, "recipient": recipient, "tag": tag, "period": period}
|
|
117
|
+
return await self._c._request("GET", "/emails", query=query)
|
|
118
|
+
|
|
119
|
+
def iterate(
|
|
120
|
+
self,
|
|
121
|
+
*,
|
|
122
|
+
status: Optional["EmailStatus"] = None,
|
|
123
|
+
recipient: Optional[str] = None,
|
|
124
|
+
tag: Optional[str] = None,
|
|
125
|
+
period: Optional["Period"] = None,
|
|
126
|
+
limit: int = 100,
|
|
127
|
+
) -> AsyncIterator["Email"]:
|
|
128
|
+
"""``async for email in couryo.emails.iterate(status="bounced"): ...``"""
|
|
129
|
+
return self._paginate("/emails", {"limit": limit, "status": status, "recipient": recipient, "tag": tag, "period": period})
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class Domains(_Resource):
|
|
133
|
+
async def list(self) -> "DomainList":
|
|
134
|
+
return await self._c._request("GET", "/domains")
|
|
135
|
+
|
|
136
|
+
async def create(self, name: str, *, idempotency_key: Optional[str] = None) -> "Domain":
|
|
137
|
+
return await self._c._request("POST", "/domains", body={"name": name}, idempotency_key=idempotency_key)
|
|
138
|
+
|
|
139
|
+
async def get(self, id: str) -> "Domain":
|
|
140
|
+
return await self._c._request("GET", f"/domains/{enc(id)}")
|
|
141
|
+
|
|
142
|
+
async def delete(self, id: str) -> None:
|
|
143
|
+
return await self._c._request("DELETE", f"/domains/{enc(id)}")
|
|
144
|
+
|
|
145
|
+
async def verify(self, id: str, *, idempotency_key: Optional[str] = None) -> "Domain":
|
|
146
|
+
return await self._c._request("POST", f"/domains/{enc(id)}/verify", idempotency_key=idempotency_key)
|
|
147
|
+
|
|
148
|
+
async def health(self, id: str, *, refresh: bool = False) -> "DomainHealth":
|
|
149
|
+
"""Deliverability diagnosis (read only). ``refresh=True`` runs every check now."""
|
|
150
|
+
return await self._c._request("GET", f"/domains/{enc(id)}/health", query={"refresh": 1} if refresh else None)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class Webhooks(_Resource):
|
|
154
|
+
async def list(self) -> "WebhookList":
|
|
155
|
+
return await self._c._request("GET", "/webhooks")
|
|
156
|
+
|
|
157
|
+
async def create(self, body: "CreateWebhookRequest", *, idempotency_key: Optional[str] = None) -> "Webhook":
|
|
158
|
+
"""The secret (whsec_...) comes back only here: store it."""
|
|
159
|
+
return await self._c._request("POST", "/webhooks", body=body, idempotency_key=idempotency_key)
|
|
160
|
+
|
|
161
|
+
async def get(self, id: str) -> "Webhook":
|
|
162
|
+
return await self._c._request("GET", f"/webhooks/{enc(id)}")
|
|
163
|
+
|
|
164
|
+
async def update(self, id: str, body: "UpdateWebhookRequest") -> "Webhook":
|
|
165
|
+
return await self._c._request("PATCH", f"/webhooks/{enc(id)}", body=body)
|
|
166
|
+
|
|
167
|
+
async def delete(self, id: str) -> None:
|
|
168
|
+
return await self._c._request("DELETE", f"/webhooks/{enc(id)}")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class Suppressions(_Resource):
|
|
172
|
+
async def list(
|
|
173
|
+
self,
|
|
174
|
+
*,
|
|
175
|
+
limit: Optional[int] = None,
|
|
176
|
+
starting_after: Optional[str] = None,
|
|
177
|
+
q: Optional[str] = None,
|
|
178
|
+
reason: Optional["SuppressionReason"] = None,
|
|
179
|
+
) -> "SuppressionList":
|
|
180
|
+
return await self._c._request("GET", "/suppressions", query={"limit": limit, "starting_after": starting_after, "q": q, "reason": reason})
|
|
181
|
+
|
|
182
|
+
async def add(self, email: str, *, stream: "SuppressionStream" = "all", idempotency_key: Optional[str] = None) -> "Suppression":
|
|
183
|
+
return await self._c._request("POST", "/suppressions", body={"email": email, "stream": stream}, idempotency_key=idempotency_key)
|
|
184
|
+
|
|
185
|
+
async def remove(self, email: str) -> None:
|
|
186
|
+
return await self._c._request("DELETE", f"/suppressions/{enc(email)}")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class Events(_Resource):
|
|
190
|
+
async def list(
|
|
191
|
+
self,
|
|
192
|
+
*,
|
|
193
|
+
limit: Optional[int] = None,
|
|
194
|
+
starting_after: Optional[str] = None,
|
|
195
|
+
type: Optional["EmailEventType"] = None,
|
|
196
|
+
email_id: Optional[str] = None,
|
|
197
|
+
) -> "EmailEventList":
|
|
198
|
+
"""Email delivery events (for whoever missed a webhook)."""
|
|
199
|
+
return await self._c._request("GET", "/events", query={"limit": limit, "starting_after": starting_after, "type": type, "email_id": email_id})
|
|
200
|
+
|
|
201
|
+
def iterate(self, *, type: Optional["EmailEventType"] = None, email_id: Optional[str] = None, limit: int = 100) -> AsyncIterator["EmailEvent"]:
|
|
202
|
+
return self._paginate("/events", {"limit": limit, "type": type, "email_id": email_id})
|
|
203
|
+
|
|
204
|
+
async def track(self, body: "TrackEventRequest", *, idempotency_key: Optional[str] = None) -> "TrackEventResponse":
|
|
205
|
+
"""Records a contact event (``user.signed_up``, ``order.paid``...): it can start sequences (201)."""
|
|
206
|
+
return await self._c._request("POST", "/events", body=body, idempotency_key=idempotency_key)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class Templates(_Resource):
|
|
210
|
+
async def list(self) -> "TemplateList":
|
|
211
|
+
return await self._c._request("GET", "/templates")
|
|
212
|
+
|
|
213
|
+
async def create(self, body: "CreateTemplateRequest", *, idempotency_key: Optional[str] = None) -> "Template":
|
|
214
|
+
return await self._c._request("POST", "/templates", body=body, idempotency_key=idempotency_key)
|
|
215
|
+
|
|
216
|
+
async def get(self, id_or_name: str) -> "Template":
|
|
217
|
+
"""By id (tpl_...) or by name."""
|
|
218
|
+
return await self._c._request("GET", f"/templates/{enc(id_or_name)}")
|
|
219
|
+
|
|
220
|
+
async def update(self, id_or_name: str, body: "UpdateTemplateRequest") -> "Template":
|
|
221
|
+
"""Content changes create a new version."""
|
|
222
|
+
return await self._c._request("PATCH", f"/templates/{enc(id_or_name)}", body=body)
|
|
223
|
+
|
|
224
|
+
async def delete(self, id_or_name: str) -> None:
|
|
225
|
+
return await self._c._request("DELETE", f"/templates/{enc(id_or_name)}")
|
|
226
|
+
|
|
227
|
+
async def versions(self, id_or_name: str) -> "TemplateVersionList":
|
|
228
|
+
return await self._c._request("GET", f"/templates/{enc(id_or_name)}/versions")
|
|
229
|
+
|
|
230
|
+
async def preview(
|
|
231
|
+
self,
|
|
232
|
+
id_or_name: str,
|
|
233
|
+
variables: Optional[Mapping[str, Any]] = None,
|
|
234
|
+
*,
|
|
235
|
+
version: Optional[int] = None,
|
|
236
|
+
idempotency_key: Optional[str] = None,
|
|
237
|
+
) -> "PreviewTemplateResponse":
|
|
238
|
+
"""Renders the template with ``variables`` (nothing is sent)."""
|
|
239
|
+
body: Dict[str, Any] = {"variables": dict(variables or {})}
|
|
240
|
+
if version is not None:
|
|
241
|
+
body["version"] = version
|
|
242
|
+
return await self._c._request("POST", f"/templates/{enc(id_or_name)}/preview", body=body, idempotency_key=idempotency_key)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class Contacts(_Resource):
|
|
246
|
+
async def create(self, body: "UpsertContactRequest", *, idempotency_key: Optional[str] = None) -> "Contact":
|
|
247
|
+
"""Creates the contact or merges the attributes of the existing one with the same email."""
|
|
248
|
+
return await self._c._request("POST", "/contacts", body=body, idempotency_key=idempotency_key)
|
|
249
|
+
|
|
250
|
+
upsert = create
|
|
251
|
+
|
|
252
|
+
async def list(self, *, limit: Optional[int] = None, starting_after: Optional[str] = None, q: Optional[str] = None) -> "ContactList":
|
|
253
|
+
return await self._c._request("GET", "/contacts", query={"limit": limit, "starting_after": starting_after, "q": q})
|
|
254
|
+
|
|
255
|
+
async def get(self, id_or_email: str) -> "Contact":
|
|
256
|
+
"""By id (con_...) or by email."""
|
|
257
|
+
return await self._c._request("GET", f"/contacts/{enc(id_or_email)}")
|
|
258
|
+
|
|
259
|
+
async def update(self, id_or_email: str, body: "UpdateContactRequest") -> "Contact":
|
|
260
|
+
return await self._c._request("PATCH", f"/contacts/{enc(id_or_email)}", body=body)
|
|
261
|
+
|
|
262
|
+
async def delete(self, id_or_email: str) -> None:
|
|
263
|
+
return await self._c._request("DELETE", f"/contacts/{enc(id_or_email)}")
|
|
264
|
+
|
|
265
|
+
def iterate(self, *, q: Optional[str] = None, limit: int = 100) -> AsyncIterator["Contact"]:
|
|
266
|
+
return self._paginate("/contacts", {"limit": limit, "q": q})
|
|
267
|
+
|
|
268
|
+
async def count(self, audience: "Audience", *, idempotency_key: Optional[str] = None) -> "AudienceCount":
|
|
269
|
+
"""How many contacts match an audience (all, subscribed, suppressed)."""
|
|
270
|
+
return await self._c._request("POST", "/contacts/count", body={"audience": audience}, idempotency_key=idempotency_key)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class Campaigns(_Resource):
|
|
274
|
+
async def create(self, body: "CreateCampaignRequest", *, idempotency_key: Optional[str] = None) -> "Campaign":
|
|
275
|
+
"""Creates a draft campaign."""
|
|
276
|
+
return await self._c._request("POST", "/campaigns", body=body, idempotency_key=idempotency_key)
|
|
277
|
+
|
|
278
|
+
async def list(self, *, limit: Optional[int] = None, starting_after: Optional[str] = None, status: Optional["CampaignStatus"] = None) -> "CampaignList":
|
|
279
|
+
return await self._c._request("GET", "/campaigns", query={"limit": limit, "starting_after": starting_after, "status": status})
|
|
280
|
+
|
|
281
|
+
async def get(self, id: str) -> "Campaign":
|
|
282
|
+
return await self._c._request("GET", f"/campaigns/{enc(id)}")
|
|
283
|
+
|
|
284
|
+
async def update(self, id: str, body: "UpdateCampaignRequest") -> "Campaign":
|
|
285
|
+
return await self._c._request("PATCH", f"/campaigns/{enc(id)}", body=body)
|
|
286
|
+
|
|
287
|
+
async def delete(self, id: str) -> None:
|
|
288
|
+
return await self._c._request("DELETE", f"/campaigns/{enc(id)}")
|
|
289
|
+
|
|
290
|
+
async def preflight(self, id: str, *, idempotency_key: Optional[str] = None) -> "CampaignPreflight":
|
|
291
|
+
"""Checks before sending: recipients, warnings and a content review."""
|
|
292
|
+
return await self._c._request("POST", f"/campaigns/{enc(id)}/preflight", idempotency_key=idempotency_key)
|
|
293
|
+
|
|
294
|
+
async def send(self, id: str, *, idempotency_key: Optional[str] = None) -> "Campaign":
|
|
295
|
+
return await self._c._request("POST", f"/campaigns/{enc(id)}/send", idempotency_key=idempotency_key)
|
|
296
|
+
|
|
297
|
+
async def schedule(self, id: str, scheduled_at: DateLike, *, idempotency_key: Optional[str] = None) -> "Campaign":
|
|
298
|
+
"""``scheduled_at``: ISO 8601 string or ``datetime`` (naive datetimes are treated as UTC)."""
|
|
299
|
+
return await self._c._request("POST", f"/campaigns/{enc(id)}/schedule", body={"scheduled_at": to_iso(scheduled_at)}, idempotency_key=idempotency_key)
|
|
300
|
+
|
|
301
|
+
async def pause(self, id: str, *, idempotency_key: Optional[str] = None) -> "Campaign":
|
|
302
|
+
return await self._c._request("POST", f"/campaigns/{enc(id)}/pause", idempotency_key=idempotency_key)
|
|
303
|
+
|
|
304
|
+
async def resume(self, id: str, *, idempotency_key: Optional[str] = None) -> "Campaign":
|
|
305
|
+
return await self._c._request("POST", f"/campaigns/{enc(id)}/resume", idempotency_key=idempotency_key)
|
|
306
|
+
|
|
307
|
+
async def cancel(self, id: str, *, idempotency_key: Optional[str] = None) -> "Campaign":
|
|
308
|
+
return await self._c._request("POST", f"/campaigns/{enc(id)}/cancel", idempotency_key=idempotency_key)
|
|
309
|
+
|
|
310
|
+
async def test(self, id: str, to: Optional[Union[str, Sequence[str]]] = None, *, idempotency_key: Optional[str] = None) -> "TestCampaignResponse":
|
|
311
|
+
"""Sends a test to people of your account (up to 5 addresses; default: you)."""
|
|
312
|
+
body = {"to": _to_list(to)} if to else {}
|
|
313
|
+
return await self._c._request("POST", f"/campaigns/{enc(id)}/test", body=body, idempotency_key=idempotency_key)
|
|
314
|
+
|
|
315
|
+
async def stats(self, id: str) -> "CampaignStats":
|
|
316
|
+
return await self._c._request("GET", f"/campaigns/{enc(id)}/stats")
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
class AsyncCouryo(BaseClient):
|
|
320
|
+
"""Couryo API client (asyncio), with the same resources and options as :class:`~couryo.Couryo`.
|
|
321
|
+
|
|
322
|
+
.. code-block:: python
|
|
323
|
+
|
|
324
|
+
from couryo import AsyncCouryo
|
|
325
|
+
|
|
326
|
+
async with AsyncCouryo() as couryo:
|
|
327
|
+
res = await couryo.emails.send({"from": "Shop <hi@example.com>", "to": "ana@example.com", "subject": "Hi", "text": "Hello!"})
|
|
328
|
+
|
|
329
|
+
``http_client`` takes your own ``httpx.AsyncClient`` (the SDK does not close it).
|
|
330
|
+
"""
|
|
331
|
+
|
|
332
|
+
_sleep: Callable[[float], Any] = staticmethod(asyncio.sleep)
|
|
333
|
+
|
|
334
|
+
def __init__(
|
|
335
|
+
self,
|
|
336
|
+
api_key: Optional[str] = None,
|
|
337
|
+
*,
|
|
338
|
+
base_url: Optional[str] = None,
|
|
339
|
+
max_retries: int = 2,
|
|
340
|
+
timeout: float = 30.0,
|
|
341
|
+
language: str = "pt-BR",
|
|
342
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
343
|
+
_sleep: Optional[Callable[[float], Any]] = None,
|
|
344
|
+
) -> None:
|
|
345
|
+
super().__init__(api_key, base_url=base_url, max_retries=max_retries, timeout=timeout, language=language, _sleep=_sleep)
|
|
346
|
+
self._owns_http = http_client is None
|
|
347
|
+
self._http = http_client if http_client is not None else httpx.AsyncClient()
|
|
348
|
+
self.emails = Emails(self)
|
|
349
|
+
self.domains = Domains(self)
|
|
350
|
+
self.webhooks = Webhooks(self)
|
|
351
|
+
self.suppressions = Suppressions(self)
|
|
352
|
+
self.events = Events(self)
|
|
353
|
+
self.templates = Templates(self)
|
|
354
|
+
self.contacts = Contacts(self)
|
|
355
|
+
self.campaigns = Campaigns(self)
|
|
356
|
+
|
|
357
|
+
async def _request(
|
|
358
|
+
self,
|
|
359
|
+
method: str,
|
|
360
|
+
path: str,
|
|
361
|
+
*,
|
|
362
|
+
body: Any = None,
|
|
363
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
364
|
+
idempotency_key: Optional[str] = None,
|
|
365
|
+
) -> Any:
|
|
366
|
+
flow: Flow = self._flow(method, path, body=body, query=query, idempotency_key=idempotency_key)
|
|
367
|
+
try:
|
|
368
|
+
kind, arg = next(flow)
|
|
369
|
+
while True:
|
|
370
|
+
if kind == "send":
|
|
371
|
+
try:
|
|
372
|
+
outcome: Any = await self._http.request(arg.method, arg.url, headers=arg.headers, content=arg.content, timeout=self.timeout)
|
|
373
|
+
except httpx.TransportError as exc: # connection errors and timeouts
|
|
374
|
+
outcome = exc
|
|
375
|
+
kind, arg = flow.send(outcome)
|
|
376
|
+
else:
|
|
377
|
+
waited = self._sleep(arg)
|
|
378
|
+
if inspect.isawaitable(waited):
|
|
379
|
+
await waited
|
|
380
|
+
kind, arg = flow.send(None)
|
|
381
|
+
except StopIteration as stop:
|
|
382
|
+
return stop.value
|
|
383
|
+
|
|
384
|
+
async def aclose(self) -> None:
|
|
385
|
+
"""Closes the HTTP connections (only when the SDK created the ``httpx.AsyncClient``)."""
|
|
386
|
+
if self._owns_http:
|
|
387
|
+
await self._http.aclose()
|
|
388
|
+
|
|
389
|
+
async def __aenter__(self) -> "AsyncCouryo":
|
|
390
|
+
return self
|
|
391
|
+
|
|
392
|
+
async def __aexit__(
|
|
393
|
+
self,
|
|
394
|
+
exc_type: Optional[type] = None,
|
|
395
|
+
exc: Optional[BaseException] = None,
|
|
396
|
+
tb: Optional["TracebackType"] = None,
|
|
397
|
+
) -> None:
|
|
398
|
+
await self.aclose()
|
couryo/_base.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Transport logic shared by the sync and async clients.
|
|
2
|
+
|
|
3
|
+
Request building, the retry decision, the backoff and the response parsing live here, as a
|
|
4
|
+
small sans-I/O state machine (:meth:`BaseClient._flow`). Each client only drives it: it performs
|
|
5
|
+
the HTTP call or the sleep the flow asks for and sends the outcome back.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import datetime as _dt
|
|
11
|
+
import json
|
|
12
|
+
import math
|
|
13
|
+
import os
|
|
14
|
+
import random
|
|
15
|
+
import uuid
|
|
16
|
+
from typing import Any, Callable, Dict, Generator, Mapping, Optional, Tuple, Union
|
|
17
|
+
from urllib.parse import quote, urlencode
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from ._version import __version__
|
|
22
|
+
from .errors import CouryoError, NetworkError, error_from_response
|
|
23
|
+
|
|
24
|
+
DEFAULT_BASE_URL = "https://api.couryo.com/v1"
|
|
25
|
+
SDK_VERSION = __version__
|
|
26
|
+
|
|
27
|
+
__all__ = ["DEFAULT_BASE_URL", "SDK_VERSION", "BaseClient", "PreparedRequest", "backoff", "enc", "to_iso"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def enc(value: str) -> str:
|
|
31
|
+
"""URL-encodes a path parameter (``a+b@x.com`` becomes ``a%2Bb%40x.com``)."""
|
|
32
|
+
return quote(str(value), safe="")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def to_iso(value: Union[str, _dt.datetime, _dt.date]) -> str:
|
|
36
|
+
"""ISO 8601 with offset. Naive datetimes are treated as UTC."""
|
|
37
|
+
if isinstance(value, _dt.datetime):
|
|
38
|
+
if value.tzinfo is None:
|
|
39
|
+
value = value.replace(tzinfo=_dt.timezone.utc)
|
|
40
|
+
return value.replace(microsecond=0).isoformat()
|
|
41
|
+
if isinstance(value, _dt.date):
|
|
42
|
+
return value.isoformat()
|
|
43
|
+
return str(value)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _json_default(value: Any) -> Any:
|
|
47
|
+
if isinstance(value, (_dt.datetime, _dt.date)):
|
|
48
|
+
return to_iso(value)
|
|
49
|
+
if isinstance(value, (set, frozenset, tuple)):
|
|
50
|
+
return list(value)
|
|
51
|
+
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _query_value(value: Any) -> str:
|
|
55
|
+
if isinstance(value, bool):
|
|
56
|
+
return "true" if value else "false"
|
|
57
|
+
if isinstance(value, (_dt.datetime, _dt.date)):
|
|
58
|
+
return to_iso(value)
|
|
59
|
+
return str(value)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def backoff(attempt: int, retry_after: Optional[float] = None) -> float:
|
|
63
|
+
"""Seconds to wait before retry ``attempt`` (1-based).
|
|
64
|
+
|
|
65
|
+
``Retry-After`` when given (at most 60 s), otherwise 0.5 s, 1 s, 2 s... with jitter, at most 8 s.
|
|
66
|
+
"""
|
|
67
|
+
if retry_after is not None and math.isfinite(retry_after):
|
|
68
|
+
return min(max(retry_after, 0.0), 60.0)
|
|
69
|
+
base = min(8.0, 0.5 * 2 ** (attempt - 1))
|
|
70
|
+
return base * (0.8 + random.random() * 0.4)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _retry_after(headers: Mapping[str, str]) -> Optional[float]:
|
|
74
|
+
raw = headers.get("retry-after")
|
|
75
|
+
if raw is None:
|
|
76
|
+
return None
|
|
77
|
+
try:
|
|
78
|
+
value = float(raw.strip())
|
|
79
|
+
except ValueError:
|
|
80
|
+
return None
|
|
81
|
+
return value if math.isfinite(value) else None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def is_retryable(err: CouryoError) -> bool:
|
|
85
|
+
"""Retry what can change with time; never a 4xx that would fail again (never ``idempotency_conflict``)."""
|
|
86
|
+
return (
|
|
87
|
+
err.status >= 500
|
|
88
|
+
or (err.status == 429 and err.code == "rate_limited")
|
|
89
|
+
or err.code == "idempotency_in_progress"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class PreparedRequest:
|
|
94
|
+
__slots__ = ("method", "url", "headers", "content")
|
|
95
|
+
|
|
96
|
+
def __init__(self, method: str, url: str, headers: Dict[str, str], content: Optional[bytes]) -> None:
|
|
97
|
+
self.method = method
|
|
98
|
+
self.url = url
|
|
99
|
+
self.headers = headers
|
|
100
|
+
self.content = content
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# Actions yielded by the flow: ("send", PreparedRequest) or ("sleep", seconds).
|
|
104
|
+
Action = Tuple[str, Any]
|
|
105
|
+
Flow = Generator[Action, Any, Any]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class BaseClient:
|
|
109
|
+
"""Configuration and transport logic common to :class:`~couryo.Couryo` and :class:`~couryo.AsyncCouryo`."""
|
|
110
|
+
|
|
111
|
+
base_url: str
|
|
112
|
+
max_retries: int
|
|
113
|
+
timeout: float
|
|
114
|
+
language: str
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
api_key: Optional[str] = None,
|
|
119
|
+
*,
|
|
120
|
+
base_url: Optional[str] = None,
|
|
121
|
+
max_retries: int = 2,
|
|
122
|
+
timeout: float = 30.0,
|
|
123
|
+
language: str = "pt-BR",
|
|
124
|
+
_sleep: Optional[Callable[[float], Any]] = None,
|
|
125
|
+
) -> None:
|
|
126
|
+
key = api_key or os.environ.get("COURYO_API_KEY")
|
|
127
|
+
if not key:
|
|
128
|
+
raise ValueError("Couryo: missing API key. Pass api_key=... or set COURYO_API_KEY.")
|
|
129
|
+
self._api_key = key
|
|
130
|
+
self.base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
131
|
+
self.max_retries = max_retries
|
|
132
|
+
self.timeout = timeout
|
|
133
|
+
self.language = language
|
|
134
|
+
if _sleep is not None:
|
|
135
|
+
self._sleep = _sleep
|
|
136
|
+
|
|
137
|
+
def __repr__(self) -> str:
|
|
138
|
+
return f"{self.__class__.__name__}(base_url={self.base_url!r})"
|
|
139
|
+
|
|
140
|
+
# Request building -------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
def _prepare(
|
|
143
|
+
self,
|
|
144
|
+
method: str,
|
|
145
|
+
path: str,
|
|
146
|
+
*,
|
|
147
|
+
body: Any = None,
|
|
148
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
149
|
+
idempotency_key: Optional[str] = None,
|
|
150
|
+
) -> PreparedRequest:
|
|
151
|
+
url = self.base_url + path
|
|
152
|
+
params = [(k, _query_value(v)) for k, v in (query or {}).items() if v is not None and v != ""]
|
|
153
|
+
if params:
|
|
154
|
+
url += "?" + urlencode(params)
|
|
155
|
+
headers = {
|
|
156
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
157
|
+
"Accept": "application/json",
|
|
158
|
+
"Accept-Language": self.language,
|
|
159
|
+
"User-Agent": f"couryo-python/{SDK_VERSION}",
|
|
160
|
+
}
|
|
161
|
+
content: Optional[bytes] = None
|
|
162
|
+
if body is not None:
|
|
163
|
+
headers["Content-Type"] = "application/json"
|
|
164
|
+
content = json.dumps(body, ensure_ascii=False, separators=(",", ":"), default=_json_default).encode("utf-8")
|
|
165
|
+
# Same key on every retry: a POST that timed out but went through is never sent twice.
|
|
166
|
+
if method == "POST":
|
|
167
|
+
headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4())
|
|
168
|
+
return PreparedRequest(method, url, headers, content)
|
|
169
|
+
|
|
170
|
+
# Response parsing -------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
@staticmethod
|
|
173
|
+
def _parse(response: httpx.Response) -> Tuple[bool, Any]:
|
|
174
|
+
"""``(True, data)`` for 2xx (``None`` for 204 or an empty body), ``(False, CouryoError)`` otherwise."""
|
|
175
|
+
if response.status_code == 204:
|
|
176
|
+
return True, None
|
|
177
|
+
text = response.text
|
|
178
|
+
try:
|
|
179
|
+
data = json.loads(text) if text else None
|
|
180
|
+
except ValueError:
|
|
181
|
+
data = None
|
|
182
|
+
if 200 <= response.status_code < 300:
|
|
183
|
+
return True, data
|
|
184
|
+
error = data.get("error") if isinstance(data, dict) else None
|
|
185
|
+
if not isinstance(error, dict):
|
|
186
|
+
error = {"message": text[:300] or f"HTTP {response.status_code}"}
|
|
187
|
+
return False, error_from_response(
|
|
188
|
+
response.status_code,
|
|
189
|
+
error,
|
|
190
|
+
retry_after=_retry_after(response.headers),
|
|
191
|
+
request_id=response.headers.get("request-id"),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# The flow ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
def _flow(
|
|
197
|
+
self,
|
|
198
|
+
method: str,
|
|
199
|
+
path: str,
|
|
200
|
+
*,
|
|
201
|
+
body: Any = None,
|
|
202
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
203
|
+
idempotency_key: Optional[str] = None,
|
|
204
|
+
) -> Flow:
|
|
205
|
+
"""Yields ``("send", request)`` (expects an ``httpx.Response`` or the transport exception back)
|
|
206
|
+
and ``("sleep", seconds)``; returns the decoded JSON or raises :class:`CouryoError`."""
|
|
207
|
+
request = self._prepare(method, path, body=body, query=query, idempotency_key=idempotency_key)
|
|
208
|
+
attempt = 0
|
|
209
|
+
while True:
|
|
210
|
+
outcome = yield ("send", request)
|
|
211
|
+
if isinstance(outcome, BaseException):
|
|
212
|
+
if attempt >= self.max_retries:
|
|
213
|
+
raise NetworkError(f"Network error: {outcome}") from outcome
|
|
214
|
+
attempt += 1
|
|
215
|
+
yield ("sleep", backoff(attempt))
|
|
216
|
+
continue
|
|
217
|
+
ok, result = self._parse(outcome)
|
|
218
|
+
if ok:
|
|
219
|
+
return result
|
|
220
|
+
if not is_retryable(result) or attempt >= self.max_retries:
|
|
221
|
+
raise result
|
|
222
|
+
attempt += 1
|
|
223
|
+
yield ("sleep", backoff(attempt, result.retry_after))
|