sendara 0.2.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.
sendara/__init__.py ADDED
@@ -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
+ ]
sendara/_async_http.py ADDED
@@ -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()
sendara/_config.py ADDED
@@ -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
+ )
sendara/_params.py ADDED
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Optional
4
+ from urllib.parse import quote, urlencode
5
+
6
+
7
+ def query(params: Dict[str, Any]) -> str:
8
+ clean = {k: v for k, v in params.items() if v is not None and v != ""}
9
+ return f"?{urlencode(clean)}" if clean else ""
10
+
11
+
12
+ def path_segment(value: str) -> str:
13
+ return quote(str(value), safe="")
14
+
15
+
16
+ def email_send_body(
17
+ *,
18
+ to: str,
19
+ subject: str,
20
+ html: Optional[str] = None,
21
+ text: Optional[str] = None,
22
+ from_: Optional[str] = None,
23
+ message_type: Optional[str] = None,
24
+ template_id: Optional[str] = None,
25
+ template_vars: Optional[Dict[str, Any]] = None,
26
+ idempotency_key: Optional[str] = None,
27
+ metadata: Optional[Dict[str, Any]] = None,
28
+ test_send: bool = False,
29
+ ) -> Dict[str, Any]:
30
+ meta: Dict[str, Any] = dict(metadata or {})
31
+ if from_:
32
+ meta["from_email"] = from_
33
+ body: Dict[str, Any] = {
34
+ "channel": "email",
35
+ "destination": {"email": to},
36
+ "payload": {"subject": subject, "body_html": html, "body_text": text},
37
+ }
38
+ if message_type is not None:
39
+ body["message_type"] = message_type
40
+ if template_id is not None:
41
+ body["template_id"] = template_id
42
+ if template_vars is not None:
43
+ body["template_vars"] = template_vars
44
+ if idempotency_key is not None:
45
+ body["idempotency_key"] = idempotency_key
46
+ if meta:
47
+ body["metadata"] = meta
48
+ if test_send:
49
+ body["test_send"] = True
50
+ return body
51
+
52
+
53
+ def sms_send_body(
54
+ *,
55
+ to: str,
56
+ body: str,
57
+ sender_id: Optional[str] = None,
58
+ message_type: Optional[str] = None,
59
+ idempotency_key: Optional[str] = None,
60
+ ) -> Dict[str, Any]:
61
+ payload: Dict[str, Any] = {
62
+ "channel": "sms",
63
+ "destination": {"phone_number": to},
64
+ "payload": {"body": body},
65
+ }
66
+ if message_type is not None:
67
+ payload["message_type"] = message_type
68
+ if idempotency_key is not None:
69
+ payload["idempotency_key"] = idempotency_key
70
+ if sender_id:
71
+ payload["metadata"] = {"sender_id": sender_id}
72
+ return payload
sendara/_sync_http.py ADDED
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ import time
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 SyncHTTP:
13
+ """Synchronous transport over httpx.Client with retry/backoff."""
14
+
15
+ def __init__(
16
+ self,
17
+ config: ClientConfig,
18
+ http_client: Optional[httpx.Client] = None,
19
+ ) -> None:
20
+ self._builder = RequestBuilder(config)
21
+ self._owns_client = http_client is None
22
+ self._client = http_client or httpx.Client(timeout=self._builder.config.timeout)
23
+
24
+ @property
25
+ def config(self) -> ClientConfig:
26
+ return self._builder.config
27
+
28
+ def request(
29
+ self,
30
+ method: str,
31
+ path: str,
32
+ *,
33
+ json_body: Optional[JsonBody] = None,
34
+ files: Optional[Dict[str, Any]] = None,
35
+ idempotent: Optional[bool] = None,
36
+ ) -> Any:
37
+ prepared = self._builder.prepare(
38
+ method, path, json_body=json_body, files=files, idempotent=idempotent
39
+ )
40
+ return self._send(prepared)
41
+
42
+ def _send(self, prepared: PreparedRequest) -> Any:
43
+ attempt = 0
44
+ while True:
45
+ try:
46
+ response = self._client.request(
47
+ prepared.method,
48
+ prepared.url,
49
+ headers=prepared.headers,
50
+ json=prepared.json_body if prepared.files is None else None,
51
+ files=prepared.files,
52
+ timeout=self._builder.config.timeout,
53
+ )
54
+ except httpx.TimeoutException as exc:
55
+ if prepared.idempotent and self._builder.should_retry(attempt, None):
56
+ time.sleep(self._builder.backoff_delay(attempt, None))
57
+ attempt += 1
58
+ continue
59
+ raise connection_error(exc) from None
60
+ except httpx.HTTPError as exc:
61
+ if prepared.idempotent and self._builder.should_retry(attempt, None):
62
+ time.sleep(self._builder.backoff_delay(attempt, None))
63
+ attempt += 1
64
+ continue
65
+ raise connection_error(exc) from None
66
+
67
+ headers = dict(response.headers)
68
+ if prepared.idempotent and self._builder.should_retry(
69
+ attempt, response.status_code
70
+ ):
71
+ retry_after = self._builder.retry_after_from_headers(headers)
72
+ time.sleep(self._builder.backoff_delay(attempt, retry_after))
73
+ attempt += 1
74
+ continue
75
+
76
+ return self._builder.interpret(
77
+ response.status_code, response.content, headers
78
+ )
79
+
80
+ def close(self) -> None:
81
+ if self._owns_client:
82
+ self._client.close()
83
+
84
+ def __enter__(self) -> "SyncHTTP":
85
+ return self
86
+
87
+ def __exit__(self, *_exc: object) -> None:
88
+ self.close()
sendara/_transport.py ADDED
@@ -0,0 +1,161 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import random
5
+ import uuid
6
+ from dataclasses import dataclass
7
+ from typing import Any, Dict, Optional, Tuple
8
+
9
+ from ._config import RETRYABLE_STATUS, DEFAULT_USER_AGENT, ClientConfig
10
+ from .errors import (
11
+ APIConnectionError,
12
+ SendaraError,
13
+ error_from_response,
14
+ )
15
+
16
+ JsonBody = Any
17
+ SAFE_METHODS = frozenset({"GET", "HEAD", "PUT", "DELETE"})
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class PreparedRequest:
22
+ method: str
23
+ url: str
24
+ headers: Dict[str, str]
25
+ json_body: Optional[JsonBody]
26
+ files: Optional[Dict[str, Any]]
27
+ idempotent: bool
28
+
29
+
30
+ def new_idempotency_key() -> str:
31
+ return str(uuid.uuid4())
32
+
33
+
34
+ def with_idempotency(body: Optional[Dict[str, Any]]) -> Dict[str, Any]:
35
+ payload = dict(body or {})
36
+ if not payload.get("idempotency_key"):
37
+ payload["idempotency_key"] = new_idempotency_key()
38
+ return payload
39
+
40
+
41
+ class RequestBuilder:
42
+ """Builds prepared requests and interprets responses. Transport-agnostic so
43
+ the sync and async clients share identical request shapes and error mapping.
44
+ """
45
+
46
+ def __init__(self, config: ClientConfig) -> None:
47
+ self._config = config.normalized()
48
+
49
+ @property
50
+ def config(self) -> ClientConfig:
51
+ return self._config
52
+
53
+ def prepare(
54
+ self,
55
+ method: str,
56
+ path: str,
57
+ *,
58
+ json_body: Optional[JsonBody] = None,
59
+ files: Optional[Dict[str, Any]] = None,
60
+ idempotent: Optional[bool] = None,
61
+ ) -> PreparedRequest:
62
+ headers = {
63
+ "Authorization": f"Bearer {self._config.api_key}",
64
+ "Accept": "application/json",
65
+ "User-Agent": DEFAULT_USER_AGENT,
66
+ }
67
+ if json_body is not None:
68
+ headers["Content-Type"] = "application/json"
69
+
70
+ is_idempotent = (
71
+ method.upper() in SAFE_METHODS if idempotent is None else idempotent
72
+ )
73
+ return PreparedRequest(
74
+ method=method.upper(),
75
+ url=f"{self._config.base_url}{path}",
76
+ headers=headers,
77
+ json_body=json_body,
78
+ files=files,
79
+ idempotent=is_idempotent,
80
+ )
81
+
82
+ def backoff_delay(self, attempt: int, retry_after: Optional[float]) -> float:
83
+ if retry_after is not None and retry_after >= 0:
84
+ return retry_after
85
+ base = min(0.5 * (2**attempt), 8.0)
86
+ return base + random.uniform(0, base * 0.25)
87
+
88
+ def should_retry(self, attempt: int, status: Optional[int]) -> bool:
89
+ if attempt >= self._config.max_retries:
90
+ return False
91
+ if status is None:
92
+ return True
93
+ return status in RETRYABLE_STATUS
94
+
95
+ def interpret(
96
+ self,
97
+ status: int,
98
+ body_bytes: bytes,
99
+ headers: Dict[str, str],
100
+ ) -> Any:
101
+ request_id = _header(headers, "x-request-id")
102
+ if 200 <= status < 300:
103
+ if status == 204 or not body_bytes:
104
+ return None
105
+ return json.loads(body_bytes)
106
+
107
+ code, message = _parse_error_envelope(body_bytes, status)
108
+ raise error_from_response(
109
+ status,
110
+ code,
111
+ message,
112
+ retry_after=_retry_after_seconds(headers),
113
+ request_id=request_id,
114
+ )
115
+
116
+ def retry_after_from_headers(self, headers: Dict[str, str]) -> Optional[float]:
117
+ return _retry_after_seconds(headers)
118
+
119
+
120
+ def _header(headers: Dict[str, str], name: str) -> Optional[str]:
121
+ lowered = name.lower()
122
+ for key, value in headers.items():
123
+ if key.lower() == lowered:
124
+ return value
125
+ return None
126
+
127
+
128
+ def _retry_after_seconds(headers: Dict[str, str]) -> Optional[float]:
129
+ raw = _header(headers, "retry-after")
130
+ if not raw:
131
+ return None
132
+ try:
133
+ return float(raw)
134
+ except ValueError:
135
+ return None
136
+
137
+
138
+ def _parse_error_envelope(body_bytes: bytes, status: int) -> Tuple[str, str]:
139
+ if not body_bytes:
140
+ return ("http_error", f"HTTP {status}")
141
+ try:
142
+ payload = json.loads(body_bytes)
143
+ except ValueError:
144
+ return (
145
+ "http_error",
146
+ body_bytes.decode("utf-8", "replace")[:500] or f"HTTP {status}",
147
+ )
148
+ if isinstance(payload, dict):
149
+ err = payload.get("error")
150
+ if isinstance(err, dict):
151
+ return (
152
+ str(err.get("code", "error")),
153
+ str(err.get("message", f"HTTP {status}")),
154
+ )
155
+ if "message" in payload:
156
+ return (str(payload.get("code", "error")), str(payload["message"]))
157
+ return ("http_error", f"HTTP {status}")
158
+
159
+
160
+ def connection_error(exc: Exception) -> SendaraError:
161
+ return APIConnectionError(f"Request failed: {exc}")
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, List, Optional
4
+
5
+ import httpx
6
+
7
+ from ._async_http import AsyncHTTP
8
+ from ._config import (
9
+ DEFAULT_BASE_URL,
10
+ DEFAULT_MAX_RETRIES,
11
+ DEFAULT_TIMEOUT,
12
+ ClientConfig,
13
+ )
14
+ from .async_resources import (
15
+ AsyncApiKeys,
16
+ AsyncBilling,
17
+ AsyncBroadcasts,
18
+ AsyncContacts,
19
+ AsyncDomains,
20
+ AsyncEmails,
21
+ AsyncLists,
22
+ AsyncMessages,
23
+ AsyncSms,
24
+ AsyncSuppressions,
25
+ AsyncTemplates,
26
+ AsyncTestRecipients,
27
+ AsyncUploads,
28
+ AsyncUsage,
29
+ AsyncWebhooks,
30
+ )
31
+ from .errors import SendaraError
32
+
33
+
34
+ class AsyncSendara:
35
+ """Asynchronous client for the Sendara messaging API (httpx.AsyncClient).
36
+
37
+ Example:
38
+ import asyncio
39
+ from sendara import AsyncSendara
40
+
41
+ async def main():
42
+ async with AsyncSendara("sk_live_...") as client:
43
+ await client.emails.send(to="user@example.com", subject="Hi",
44
+ html="<p>Hi</p>")
45
+
46
+ asyncio.run(main())
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ api_key: str,
52
+ *,
53
+ base_url: str = DEFAULT_BASE_URL,
54
+ timeout: float = DEFAULT_TIMEOUT,
55
+ max_retries: int = DEFAULT_MAX_RETRIES,
56
+ http_client: Optional[httpx.AsyncClient] = None,
57
+ ) -> None:
58
+ if not api_key:
59
+ raise SendaraError(0, "missing_api_key", "An API key is required")
60
+ config = ClientConfig(
61
+ api_key=api_key,
62
+ base_url=base_url,
63
+ timeout=timeout,
64
+ max_retries=max_retries,
65
+ )
66
+ self._http = AsyncHTTP(config, http_client=http_client)
67
+
68
+ self.emails = AsyncEmails(self._http)
69
+ self.sms = AsyncSms(self._http)
70
+ self.broadcasts = AsyncBroadcasts(self._http)
71
+ self.messages = AsyncMessages(self._http)
72
+ self.suppressions = AsyncSuppressions(self._http)
73
+ self.domains = AsyncDomains(self._http)
74
+ self.api_keys = AsyncApiKeys(self._http)
75
+ self.usage = AsyncUsage(self._http)
76
+ self.billing = AsyncBilling(self._http)
77
+ self.templates = AsyncTemplates(self._http)
78
+ self.contacts = AsyncContacts(self._http)
79
+ self.lists = AsyncLists(self._http)
80
+ self.webhooks = AsyncWebhooks(self._http)
81
+ self.uploads = AsyncUploads(self._http)
82
+ self.test_recipients = AsyncTestRecipients(self._http)
83
+
84
+ async def send(self, request: dict) -> Any:
85
+ return await self.emails.send_raw(request)
86
+
87
+ async def send_batch(self, requests: List[dict]) -> Any:
88
+ return await self.emails.send_batch(requests)
89
+
90
+ async def request(self, method: str, path: str, body: Optional[Any] = None) -> Any:
91
+ return await self._http.request(method, path, json_body=body)
92
+
93
+ async def aclose(self) -> None:
94
+ await self._http.aclose()
95
+
96
+ async def __aenter__(self) -> "AsyncSendara":
97
+ return self
98
+
99
+ async def __aexit__(self, *_exc: object) -> None:
100
+ await self.aclose()