feat: add typed Python SDK (#213)

This commit is contained in:
KM Koushik
2025-09-09 05:50:08 +10:00
committed by GitHub
parent 92f56f1ebf
commit 3158ddc51c
15 changed files with 2054 additions and 2 deletions

View File

@@ -0,0 +1,6 @@
"""Python client for the UseSend API."""
from .usesend import UseSend, UseSendHTTPError
from . import types
__all__ = ["UseSend", "UseSendHTTPError", "types"]

View File

@@ -0,0 +1,69 @@
"""Contact resource client using TypedDict shapes (no Pydantic)."""
from __future__ import annotations
from typing import Any, Dict, Optional, Tuple
from .types import (
APIError,
ContactDeleteResponse,
Contact,
ContactUpdate,
ContactUpdateResponse,
ContactUpsert,
ContactUpsertResponse,
ContactCreate,
ContactCreateResponse,
)
class Contacts:
"""Client for `/contactBooks` endpoints."""
def __init__(self, usesend: "UseSend") -> None:
self.usesend = usesend
def create(
self, book_id: str, payload: ContactCreate
) -> Tuple[Optional[ContactCreateResponse], Optional[APIError]]:
data, err = self.usesend.post(
f"/contactBooks/{book_id}/contacts",
payload,
)
return (data, err) # type: ignore[return-value]
def get(
self, book_id: str, contact_id: str
) -> Tuple[Optional[Contact], Optional[APIError]]:
data, err = self.usesend.get(
f"/contactBooks/{book_id}/contacts/{contact_id}"
)
return (data, err) # type: ignore[return-value]
def update(
self, book_id: str, contact_id: str, payload: ContactUpdate
) -> Tuple[Optional[ContactUpdateResponse], Optional[APIError]]:
data, err = self.usesend.patch(
f"/contactBooks/{book_id}/contacts/{contact_id}",
payload,
)
return (data, err) # type: ignore[return-value]
def upsert(
self, book_id: str, contact_id: str, payload: ContactUpsert
) -> Tuple[Optional[ContactUpsertResponse], Optional[APIError]]:
data, err = self.usesend.put(
f"/contactBooks/{book_id}/contacts/{contact_id}",
payload,
)
return (data, err) # type: ignore[return-value]
def delete(
self, *, book_id: str, contact_id: str
) -> Tuple[Optional[ContactDeleteResponse], Optional[APIError]]:
data, err = self.usesend.delete(
f"/contactBooks/{book_id}/contacts/{contact_id}"
)
return (data, err) # type: ignore[return-value]
from .usesend import UseSend # noqa: E402 pylint: disable=wrong-import-position

View File

@@ -0,0 +1,77 @@
"""Email resource client using TypedDict shapes (no Pydantic)."""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
from .types import (
APIError,
Attachment,
EmailBatchItem,
EmailBatchResponse,
EmailCancelResponse,
Email,
EmailUpdate,
EmailUpdateResponse,
EmailCreate,
EmailCreateResponse,
)
class Emails:
"""Client for `/emails` endpoints."""
def __init__(self, usesend: "UseSend") -> None:
self.usesend = usesend
# Basic operations -------------------------------------------------
def send(self, payload: EmailCreate) -> Tuple[Optional[EmailCreateResponse], Optional[APIError]]:
"""Alias for :meth:`create`."""
return self.create(payload)
def create(self, payload: Union[EmailCreate, Dict[str, Any]]) -> Tuple[Optional[EmailCreateResponse], Optional[APIError]]:
if isinstance(payload, dict):
payload = dict(payload)
# Normalize fields
body: Dict[str, Any] = dict(payload)
# Support accidental 'from_' usage
if "from_" in body and "from" not in body:
body["from"] = body.pop("from_")
# Convert scheduledAt to ISO 8601 if datetime
if isinstance(body.get("scheduledAt"), datetime):
body["scheduledAt"] = body["scheduledAt"].isoformat()
data, err = self.usesend.post("/emails", body)
return (data, err) # type: ignore[return-value]
def batch(self, payload: Sequence[Union[EmailBatchItem, Dict[str, Any]]]) -> Tuple[Optional[EmailBatchResponse], Optional[APIError]]:
items: List[Dict[str, Any]] = []
for item in payload:
d = dict(item)
if "from_" in d and "from" not in d:
d["from"] = d.pop("from_")
if isinstance(d.get("scheduledAt"), datetime):
d["scheduledAt"] = d["scheduledAt"].isoformat()
items.append(d)
data, err = self.usesend.post("/emails/batch", items)
return (data, err) # type: ignore[return-value]
def get(self, email_id: str) -> Tuple[Optional[Email], Optional[APIError]]:
data, err = self.usesend.get(f"/emails/{email_id}")
return (data, err) # type: ignore[return-value]
def update(self, email_id: str, payload: EmailUpdate) -> Tuple[Optional[EmailUpdateResponse], Optional[APIError]]:
body: Dict[str, Any] = dict(payload)
if isinstance(body.get("scheduledAt"), datetime):
body["scheduledAt"] = body["scheduledAt"].isoformat()
data, err = self.usesend.patch(f"/emails/{email_id}", body)
return (data, err) # type: ignore[return-value]
def cancel(self, email_id: str) -> Tuple[Optional[EmailCancelResponse], Optional[APIError]]:
data, err = self.usesend.post(f"/emails/{email_id}/cancel", {})
return (data, err) # type: ignore[return-value]
from .usesend import UseSend # noqa: E402 pylint: disable=wrong-import-position

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,317 @@
"""TypedDict models for the UseSend API.
Lightweight, Pydantic-free types for editor autocomplete and static checks.
At runtime these are plain dicts and lists.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional, Union, TypedDict
from typing_extensions import NotRequired, Required, Literal
# ---------------------------------------------------------------------------
# Domains
# ---------------------------------------------------------------------------
DomainStatus = Literal[
'NOT_STARTED',
'PENDING',
'SUCCESS',
'FAILED',
'TEMPORARY_FAILURE',
]
class Domain(TypedDict, total=False):
id: float
name: str
teamId: float
status: DomainStatus
region: str
clickTracking: bool
openTracking: bool
publicKey: str
dkimStatus: Optional[str]
spfDetails: Optional[str]
createdAt: str
updatedAt: str
dmarcAdded: bool
isVerifying: bool
errorMessage: Optional[str]
subdomain: Optional[str]
DomainList = List[Domain]
class DomainCreate(TypedDict):
name: str
region: str
class DomainCreateResponse(TypedDict, total=False):
id: float
name: str
teamId: float
status: DomainStatus
region: str
clickTracking: bool
openTracking: bool
publicKey: str
dkimStatus: Optional[str]
spfDetails: Optional[str]
createdAt: str
updatedAt: str
dmarcAdded: bool
isVerifying: bool
errorMessage: Optional[str]
subdomain: Optional[str]
class DomainVerifyResponse(TypedDict):
message: str
# ---------------------------------------------------------------------------
# Emails
# ---------------------------------------------------------------------------
EmailEventStatus = Literal[
'SCHEDULED',
'QUEUED',
'SENT',
'DELIVERY_DELAYED',
'BOUNCED',
'REJECTED',
'RENDERING_FAILURE',
'DELIVERED',
'OPENED',
'CLICKED',
'COMPLAINED',
'FAILED',
'CANCELLED',
]
class EmailEvent(TypedDict, total=False):
emailId: str
status: EmailEventStatus
createdAt: str
data: Optional[Any]
Email = TypedDict(
'Email',
{
'id': str,
'teamId': float,
'to': Union[str, List[str]],
'replyTo': NotRequired[Union[str, List[str]]],
'cc': NotRequired[Union[str, List[str]]],
'bcc': NotRequired[Union[str, List[str]]],
'from': str,
'subject': str,
'html': str,
'text': str,
'createdAt': str,
'updatedAt': str,
'emailEvents': List[EmailEvent],
}
)
class EmailUpdate(TypedDict):
# Accept datetime or ISO string; client will JSON-encode
scheduledAt: Union[datetime, str]
class EmailUpdateResponse(TypedDict, total=False):
emailId: Optional[str]
EmailLatestStatus = Literal[
'SCHEDULED',
'QUEUED',
'SENT',
'DELIVERY_DELAYED',
'BOUNCED',
'REJECTED',
'RENDERING_FAILURE',
'DELIVERED',
'OPENED',
'CLICKED',
'COMPLAINED',
'FAILED',
'CANCELLED',
]
EmailListItem = TypedDict(
'EmailListItem',
{
'id': str,
'to': Union[str, List[str]],
'replyTo': NotRequired[Union[str, List[str]]],
'cc': NotRequired[Union[str, List[str]]],
'bcc': NotRequired[Union[str, List[str]]],
'from': str,
'subject': str,
'html': str,
'text': str,
'createdAt': str,
'updatedAt': str,
'latestStatus': EmailLatestStatus,
'scheduledAt': str,
'domainId': float,
}
)
class EmailsList(TypedDict):
data: List[EmailListItem]
count: float
class Attachment(TypedDict):
filename: str
content: str
EmailCreate = TypedDict(
'EmailCreate',
{
'to': Required[Union[str, List[str]]],
'from': Required[str],
'subject': NotRequired[str],
'templateId': NotRequired[str],
'variables': NotRequired[Dict[str, str]],
'replyTo': NotRequired[Union[str, List[str]]],
'cc': NotRequired[Union[str, List[str]]],
'bcc': NotRequired[Union[str, List[str]]],
'text': NotRequired[str],
'html': NotRequired[str],
'attachments': NotRequired[List[Attachment]],
'scheduledAt': NotRequired[Union[datetime, str]],
'inReplyToId': NotRequired[str],
}
)
class EmailCreateResponse(TypedDict, total=False):
emailId: Optional[str]
EmailBatchItem = TypedDict(
'EmailBatchItem',
{
'to': Required[Union[str, List[str]]],
'from': Required[str],
'subject': NotRequired[str],
'templateId': NotRequired[str],
'variables': NotRequired[Dict[str, str]],
'replyTo': NotRequired[Union[str, List[str]]],
'cc': NotRequired[Union[str, List[str]]],
'bcc': NotRequired[Union[str, List[str]]],
'text': NotRequired[str],
'html': NotRequired[str],
'attachments': NotRequired[List[Attachment]],
'scheduledAt': NotRequired[Union[datetime, str]],
'inReplyToId': NotRequired[str],
}
)
EmailBatch = List[EmailBatchItem]
class EmailBatchResponseItem(TypedDict):
emailId: str
class EmailBatchResponse(TypedDict):
data: List[EmailBatchResponseItem]
class EmailCancelResponse(TypedDict, total=False):
emailId: Optional[str]
# ---------------------------------------------------------------------------
# Contacts
# ---------------------------------------------------------------------------
class ContactCreate(TypedDict, total=False):
email: str
firstName: Optional[str]
lastName: Optional[str]
properties: Optional[Dict[str, str]]
subscribed: Optional[bool]
class ContactCreateResponse(TypedDict, total=False):
contactId: Optional[str]
class ContactListItem(TypedDict, total=False):
id: str
firstName: Optional[str]
lastName: Optional[str]
email: str
subscribed: Optional[bool]
properties: Dict[str, str]
contactBookId: str
createdAt: str
updatedAt: str
ContactList = List[ContactListItem]
class ContactUpdate(TypedDict, total=False):
firstName: Optional[str]
lastName: Optional[str]
properties: Optional[Dict[str, str]]
subscribed: Optional[bool]
class ContactUpdateResponse(TypedDict, total=False):
contactId: Optional[str]
class Contact(TypedDict, total=False):
id: str
firstName: Optional[str]
lastName: Optional[str]
email: str
subscribed: Optional[bool]
properties: Dict[str, str]
contactBookId: str
createdAt: str
updatedAt: str
class ContactUpsert(TypedDict, total=False):
email: str
firstName: Optional[str]
lastName: Optional[str]
properties: Optional[Dict[str, str]]
subscribed: Optional[bool]
class ContactUpsertResponse(TypedDict):
contactId: str
class ContactDeleteResponse(TypedDict):
success: bool
# ---------------------------------------------------------------------------
# Common
# ---------------------------------------------------------------------------
class APIError(TypedDict):
code: str
message: str

View File

@@ -0,0 +1,125 @@
"""Core client for interacting with the UseSend API.
Enhancements:
- Optional ``raise_on_error`` to raise ``UseSendHTTPError`` on non-2xx.
- Reusable ``requests.Session`` support for connection reuse.
"""
from __future__ import annotations
import os
from typing import Any, Dict, Optional, Tuple
import requests
DEFAULT_BASE_URL = "https://app.usesend.com"
class UseSendHTTPError(Exception):
"""HTTP error raised when ``raise_on_error=True`` and a request fails."""
def __init__(self, status_code: int, error: Dict[str, Any], method: str, path: str) -> None:
self.status_code = status_code
self.error = error
self.method = method
self.path = path
super().__init__(self.__str__())
def __str__(self) -> str: # pragma: no cover - presentation only
code = self.error.get("code", "UNKNOWN_ERROR")
message = self.error.get("message", "")
return f"{self.method} {self.path} -> {self.status_code} {code}: {message}"
class UseSend:
"""UseSend API client.
Parameters
----------
key:
API key issued by UseSend. If not provided, the client attempts to
read ``USESEND_API_KEY`` or ``UNSEND_API_KEY`` from the environment.
url:
Optional base URL for the API (useful for testing).
"""
def __init__(
self,
key: Optional[str] = None,
url: Optional[str] = None,
*,
raise_on_error: bool = True,
session: Optional[requests.Session] = None,
) -> None:
self.key = key or os.getenv("USESEND_API_KEY") or os.getenv("UNSEND_API_KEY")
if not self.key:
raise ValueError("Missing API key. Pass it to UseSend('us_123')")
base = os.getenv("USESEND_BASE_URL") or os.getenv("UNSEND_BASE_URL") or DEFAULT_BASE_URL
if url:
base = url
self.url = f"{base}/api/v1"
self.headers = {
"Authorization": f"Bearer {self.key}",
"Content-Type": "application/json",
}
self.raise_on_error = raise_on_error
self._session = session or requests.Session()
# Lazily initialise resource clients.
self.emails = Emails(self)
self.contacts = Contacts(self)
# ------------------------------------------------------------------
# Internal request helper
# ------------------------------------------------------------------
def _request(
self, method: str, path: str, json: Optional[Any] = None
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
"""Perform an HTTP request and return ``(data, error)``."""
resp = self._session.request(
method, f"{self.url}{path}", headers=self.headers, json=json
)
default_error = {"code": "INTERNAL_SERVER_ERROR", "message": resp.reason}
if not resp.ok:
try:
payload = resp.json()
error = payload.get("error", default_error)
except Exception:
error = default_error
if self.raise_on_error:
raise UseSendHTTPError(resp.status_code, error, method, path)
return None, error
try:
return resp.json(), None
except Exception:
return None, default_error
# ------------------------------------------------------------------
# HTTP verb helpers
# ------------------------------------------------------------------
def post(self, path: str, body: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
return self._request("POST", path, json=body)
def get(self, path: str) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
return self._request("GET", path)
def put(self, path: str, body: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
return self._request("PUT", path, json=body)
def patch(self, path: str, body: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
return self._request("PATCH", path, json=body)
def delete(
self, path: str, body: Optional[Any] = None
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
return self._request("DELETE", path, json=body)
# Import here to avoid circular dependency during type checking
from .emails import Emails # noqa: E402 pylint: disable=wrong-import-position
from .contacts import Contacts # noqa: E402 pylint: disable=wrong-import-position