feat: add typed Python SDK (#213)
This commit is contained in:
21
packages/python-sdk/LICENSE
Normal file
21
packages/python-sdk/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 UseSend
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
61
packages/python-sdk/README.md
Normal file
61
packages/python-sdk/README.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# UseSend Python SDK
|
||||
|
||||
A minimal Python SDK for the [UseSend](https://usesend.com) API, mirroring the structure of the JavaScript SDK.
|
||||
|
||||
## Installation
|
||||
|
||||
Install via pip or Poetry:
|
||||
|
||||
```
|
||||
pip install usesend
|
||||
# or
|
||||
poetry add usesend
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
from usesend import UseSend, types
|
||||
|
||||
# By default: raises UseSendHTTPError on non-2xx.
|
||||
client = UseSend("us_123")
|
||||
|
||||
# 1) TypedDict payload (autocomplete in IDEs). Use dict to pass 'from'.
|
||||
payload: types.EmailCreate = {
|
||||
"to": "test@example.com",
|
||||
"from": "no-reply@example.com",
|
||||
"subject": "Hello",
|
||||
"html": "<strong>Hi!</strong>",
|
||||
}
|
||||
resp, _ = client.emails.send(payload=payload)
|
||||
|
||||
# 2) Or pass a plain dict (supports 'from')
|
||||
resp, _ = client.emails.send(payload={
|
||||
"to": "test@example.com",
|
||||
"from": "no-reply@example.com",
|
||||
"subject": "Hello",
|
||||
"html": "<strong>Hi!</strong>",
|
||||
})
|
||||
|
||||
# Toggle behavior if desired:
|
||||
# - raise_on_error=False: return (None, error_dict) instead of raising
|
||||
# No model parsing occurs; methods return plain dicts following the typed shapes.
|
||||
client = UseSend("us_123", raise_on_error=False)
|
||||
raw, err = client.emails.get(email_id="email_123")
|
||||
if err:
|
||||
print("error:", err)
|
||||
else:
|
||||
print("ok:", raw)
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
This package is managed with Poetry. Models are maintained in-repo under
|
||||
`usesend/types.py` (readable names). Update this file as the API evolves.
|
||||
|
||||
It is published as `usesend` on PyPI.
|
||||
|
||||
Notes
|
||||
|
||||
- Human-friendly models are available under `usesend.types` (e.g., `EmailCreate`, `Contact`, `APIError`).
|
||||
- Endpoint methods accept TypedDict payloads or plain dicts via the `payload=` keyword.
|
1139
packages/python-sdk/poetry.lock
generated
Normal file
1139
packages/python-sdk/poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
packages/python-sdk/pyproject.toml
Normal file
20
packages/python-sdk/pyproject.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
[tool.poetry]
|
||||
name = "usesend"
|
||||
version = "0.2.3"
|
||||
description = "Python SDK for the UseSend API"
|
||||
authors = ["UseSend"]
|
||||
license = "MIT"
|
||||
readme = "README.md"
|
||||
packages = [{ include = "usesend" }]
|
||||
include = ["usesend/py.typed"]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = ">=3.8,<4.0"
|
||||
requests = "^2.32.0"
|
||||
typing_extensions = ">=4.7"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
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