feat: add typed Python SDK (#213)
This commit is contained in:
6
packages/python-sdk/usesend/__init__.py
Normal file
6
packages/python-sdk/usesend/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Python client for the UseSend API."""
|
||||
|
||||
from .usesend import UseSend, UseSendHTTPError
|
||||
from . import types
|
||||
|
||||
__all__ = ["UseSend", "UseSendHTTPError", "types"]
|
69
packages/python-sdk/usesend/contacts.py
Normal file
69
packages/python-sdk/usesend/contacts.py
Normal 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
|
77
packages/python-sdk/usesend/emails.py
Normal file
77
packages/python-sdk/usesend/emails.py
Normal 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
|
1
packages/python-sdk/usesend/py.typed
Normal file
1
packages/python-sdk/usesend/py.typed
Normal file
@@ -0,0 +1 @@
|
||||
|
317
packages/python-sdk/usesend/types.py
Normal file
317
packages/python-sdk/usesend/types.py
Normal 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
|
||||
|
125
packages/python-sdk/usesend/usesend.py
Normal file
125
packages/python-sdk/usesend/usesend.py
Normal 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
|
Reference in New Issue
Block a user