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 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
+ ]
@@ -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))