feat: sync sdk contact book support (#373)
This commit is contained in:
@@ -100,7 +100,8 @@ It is published as `usesend` on PyPI.
|
||||
## Available Resources
|
||||
|
||||
- **Emails**: `client.emails.send()`, `client.emails.get()`
|
||||
- **Contacts**: `client.contacts.create()`, `client.contacts.get()`, `client.contacts.list()`
|
||||
- **ContactBooks**: `client.contact_books.list()`, `client.contact_books.create()`, `client.contact_books.get()`, `client.contact_books.update()`
|
||||
- **Contacts**: `client.contacts.create()`, `client.contacts.list()`, `client.contacts.get()`, `client.contacts.bulk_create()`, `client.contacts.bulk_delete()`
|
||||
- **Domains**: `client.domains.create()`, `client.domains.get()`, `client.domains.verify()`
|
||||
- **Campaigns**: `client.campaigns.create()`, `client.campaigns.get()`, `client.campaigns.schedule()`, `client.campaigns.pause()`, `client.campaigns.resume()`
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "usesend"
|
||||
version = "0.2.9"
|
||||
version = "0.2.10"
|
||||
description = "Python SDK for the UseSend API"
|
||||
authors = ["UseSend"]
|
||||
license = "MIT"
|
||||
@@ -14,6 +14,7 @@ requests = "^2.32.0"
|
||||
typing_extensions = ">=4.7"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
pytest = "^8.3.5"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from usesend import UseSend
|
||||
|
||||
|
||||
class MockResponse:
|
||||
def __init__(self, payload: Dict[str, Any], ok: bool = True, reason: str = "OK") -> None:
|
||||
self._payload = payload
|
||||
self.ok = ok
|
||||
self.reason = reason
|
||||
self.status_code = 200 if ok else 400
|
||||
|
||||
def json(self) -> Dict[str, Any]:
|
||||
return self._payload
|
||||
|
||||
|
||||
class MockSession:
|
||||
def __init__(self, responses: List[MockResponse]) -> None:
|
||||
self._responses = responses
|
||||
self.calls: List[Dict[str, Any]] = []
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
json: Optional[Any] = None,
|
||||
) -> MockResponse:
|
||||
self.calls.append(
|
||||
{
|
||||
"method": method,
|
||||
"url": url,
|
||||
"headers": headers,
|
||||
"json": json,
|
||||
}
|
||||
)
|
||||
return self._responses.pop(0)
|
||||
|
||||
|
||||
def test_contact_books_list_uses_expected_path_and_returns_data() -> None:
|
||||
session = MockSession(
|
||||
[
|
||||
MockResponse(
|
||||
[
|
||||
{
|
||||
"id": "cb_123",
|
||||
"name": "Newsletter Subscribers",
|
||||
"teamId": 1,
|
||||
"properties": {},
|
||||
"variables": ["company"],
|
||||
"emoji": "📙",
|
||||
"doubleOptInEnabled": True,
|
||||
"doubleOptInFrom": "Newsletter <hello@example.com>",
|
||||
"doubleOptInSubject": "Please confirm your subscription",
|
||||
"doubleOptInContent": "{}",
|
||||
"createdAt": "2026-03-01T00:00:00.000Z",
|
||||
"updatedAt": "2026-03-01T00:00:00.000Z",
|
||||
"_count": {"contacts": 12},
|
||||
}
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
client = UseSend("us_test", session=session)
|
||||
|
||||
data, err = client.contact_books.list()
|
||||
|
||||
assert err is None
|
||||
assert data is not None
|
||||
assert data[0]["variables"] == ["company"]
|
||||
assert session.calls[0]["method"] == "GET"
|
||||
assert session.calls[0]["url"].endswith("/api/v1/contactBooks")
|
||||
|
||||
|
||||
def test_contact_books_alias_matches_js_style_client() -> None:
|
||||
session = MockSession([MockResponse({"id": "cb_123", "name": "Book"})])
|
||||
client = UseSend("us_test", session=session)
|
||||
|
||||
data, err = client.contactBooks.get("cb_123")
|
||||
|
||||
assert err is None
|
||||
assert data is not None
|
||||
assert data["id"] == "cb_123"
|
||||
assert session.calls[0]["url"].endswith("/api/v1/contactBooks/cb_123")
|
||||
|
||||
|
||||
def test_contacts_list_encodes_query_params() -> None:
|
||||
session = MockSession([MockResponse([])])
|
||||
client = UseSend("us_test", session=session)
|
||||
|
||||
data, err = client.contacts.list(
|
||||
"cb_123",
|
||||
emails="a@example.com,b@example.com",
|
||||
page=2,
|
||||
limit=50,
|
||||
ids="ct_1,ct_2",
|
||||
)
|
||||
|
||||
assert err is None
|
||||
assert data == []
|
||||
assert session.calls[0]["method"] == "GET"
|
||||
assert session.calls[0]["url"].endswith(
|
||||
"/api/v1/contactBooks/cb_123/contacts?emails=a%40example.com%2Cb%40example.com&page=2&limit=50&ids=ct_1%2Cct_2"
|
||||
)
|
||||
|
||||
|
||||
def test_contacts_bulk_methods_use_expected_payloads() -> None:
|
||||
session = MockSession(
|
||||
[
|
||||
MockResponse({"message": "Contacts imported", "count": 2}),
|
||||
MockResponse({"success": True, "count": 2}),
|
||||
]
|
||||
)
|
||||
client = UseSend("us_test", session=session)
|
||||
|
||||
create_data, create_err = client.contacts.bulk_create(
|
||||
"cb_123",
|
||||
[
|
||||
{"email": "a@example.com"},
|
||||
{"email": "b@example.com", "firstName": "B"},
|
||||
],
|
||||
)
|
||||
delete_data, delete_err = client.contacts.bulk_delete(
|
||||
"cb_123",
|
||||
{"contactIds": ["ct_1", "ct_2"]},
|
||||
)
|
||||
|
||||
assert create_err is None
|
||||
assert create_data == {"message": "Contacts imported", "count": 2}
|
||||
assert delete_err is None
|
||||
assert delete_data == {"success": True, "count": 2}
|
||||
assert session.calls[0]["method"] == "POST"
|
||||
assert session.calls[0]["json"] == [
|
||||
{"email": "a@example.com"},
|
||||
{"email": "b@example.com", "firstName": "B"},
|
||||
]
|
||||
assert session.calls[1]["method"] == "DELETE"
|
||||
assert session.calls[1]["json"] == {"contactIds": ["ct_1", "ct_2"]}
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Python client for the UseSend API."""
|
||||
|
||||
from .usesend import UseSend, UseSendHTTPError
|
||||
from .contacts import Contacts # type: ignore
|
||||
from .contact_books import ContactBooks # type: ignore
|
||||
from .domains import Domains # type: ignore
|
||||
from .campaigns import Campaigns # type: ignore
|
||||
from .webhooks import (
|
||||
@@ -17,6 +19,8 @@ __all__ = [
|
||||
"UseSend",
|
||||
"UseSendHTTPError",
|
||||
"types",
|
||||
"Contacts",
|
||||
"ContactBooks",
|
||||
"Domains",
|
||||
"Campaigns",
|
||||
"Webhooks",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Contact book resource client using TypedDict shapes (no Pydantic)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, Tuple, List
|
||||
|
||||
from .types import (
|
||||
APIError,
|
||||
ContactBook,
|
||||
ContactBookCreate,
|
||||
ContactBookCreateResponse,
|
||||
ContactBookDeleteResponse,
|
||||
ContactBookUpdate,
|
||||
ContactBookUpdateResponse,
|
||||
)
|
||||
|
||||
|
||||
class ContactBooks:
|
||||
"""Client for `/contactBooks` endpoints."""
|
||||
|
||||
def __init__(self, usesend: "UseSend") -> None:
|
||||
self.usesend = usesend
|
||||
|
||||
def list(self) -> Tuple[Optional[List[ContactBook]], Optional[APIError]]:
|
||||
data, err = self.usesend.get("/contactBooks")
|
||||
return (data, err) # type: ignore[return-value]
|
||||
|
||||
def create(
|
||||
self, payload: ContactBookCreate
|
||||
) -> Tuple[Optional[ContactBookCreateResponse], Optional[APIError]]:
|
||||
data, err = self.usesend.post("/contactBooks", payload)
|
||||
return (data, err) # type: ignore[return-value]
|
||||
|
||||
def get(self, contact_book_id: str) -> Tuple[Optional[ContactBook], Optional[APIError]]:
|
||||
data, err = self.usesend.get(f"/contactBooks/{contact_book_id}")
|
||||
return (data, err) # type: ignore[return-value]
|
||||
|
||||
def update(
|
||||
self, contact_book_id: str, payload: ContactBookUpdate
|
||||
) -> Tuple[Optional[ContactBookUpdateResponse], Optional[APIError]]:
|
||||
data, err = self.usesend.patch(f"/contactBooks/{contact_book_id}", payload)
|
||||
return (data, err) # type: ignore[return-value]
|
||||
|
||||
def delete(
|
||||
self, contact_book_id: str
|
||||
) -> Tuple[Optional[ContactBookDeleteResponse], Optional[APIError]]:
|
||||
data, err = self.usesend.delete(f"/contactBooks/{contact_book_id}")
|
||||
return (data, err) # type: ignore[return-value]
|
||||
|
||||
|
||||
from .usesend import UseSend # noqa: E402 pylint: disable=wrong-import-position
|
||||
@@ -2,11 +2,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from .types import (
|
||||
APIError,
|
||||
ContactDeleteResponse,
|
||||
Contact,
|
||||
ContactBulkCreate,
|
||||
ContactBulkCreateResponse,
|
||||
ContactBulkDelete,
|
||||
ContactBulkDeleteResponse,
|
||||
ContactList,
|
||||
ContactUpdate,
|
||||
ContactUpdateResponse,
|
||||
ContactUpsert,
|
||||
@@ -31,6 +37,32 @@ class Contacts:
|
||||
)
|
||||
return (data, err) # type: ignore[return-value]
|
||||
|
||||
def list(
|
||||
self,
|
||||
book_id: str,
|
||||
*,
|
||||
emails: Optional[str] = None,
|
||||
page: Optional[int] = None,
|
||||
limit: Optional[int] = None,
|
||||
ids: Optional[str] = None,
|
||||
) -> Tuple[Optional[ContactList], Optional[APIError]]:
|
||||
query: Dict[str, Any] = {}
|
||||
if emails is not None:
|
||||
query["emails"] = emails
|
||||
if page is not None:
|
||||
query["page"] = page
|
||||
if limit is not None:
|
||||
query["limit"] = limit
|
||||
if ids is not None:
|
||||
query["ids"] = ids
|
||||
|
||||
path = f"/contactBooks/{book_id}/contacts"
|
||||
if query:
|
||||
path = f"{path}?{urlencode(query)}"
|
||||
|
||||
data, err = self.usesend.get(path)
|
||||
return (data, err) # type: ignore[return-value]
|
||||
|
||||
def get(
|
||||
self, book_id: str, contact_id: str
|
||||
) -> Tuple[Optional[Contact], Optional[APIError]]:
|
||||
@@ -57,6 +89,24 @@ class Contacts:
|
||||
)
|
||||
return (data, err) # type: ignore[return-value]
|
||||
|
||||
def bulk_create(
|
||||
self, book_id: str, payload: ContactBulkCreate
|
||||
) -> Tuple[Optional[ContactBulkCreateResponse], Optional[APIError]]:
|
||||
data, err = self.usesend.post(
|
||||
f"/contactBooks/{book_id}/contacts/bulk",
|
||||
payload,
|
||||
)
|
||||
return (data, err) # type: ignore[return-value]
|
||||
|
||||
def bulk_delete(
|
||||
self, book_id: str, payload: ContactBulkDelete
|
||||
) -> Tuple[Optional[ContactBulkDeleteResponse], Optional[APIError]]:
|
||||
data, err = self.usesend.delete(
|
||||
f"/contactBooks/{book_id}/contacts/bulk",
|
||||
payload,
|
||||
)
|
||||
return (data, err) # type: ignore[return-value]
|
||||
|
||||
def delete(
|
||||
self, *, book_id: str, contact_id: str
|
||||
) -> Tuple[Optional[ContactDeleteResponse], Optional[APIError]]:
|
||||
|
||||
@@ -271,6 +271,65 @@ class EmailCancelResponse(TypedDict, total=False):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ContactBookCounts(TypedDict, total=False):
|
||||
contacts: int
|
||||
|
||||
|
||||
class ContactBook(TypedDict, total=False):
|
||||
id: str
|
||||
name: str
|
||||
teamId: float
|
||||
properties: Dict[str, str]
|
||||
variables: List[str]
|
||||
emoji: str
|
||||
doubleOptInEnabled: Optional[bool]
|
||||
doubleOptInFrom: Optional[str]
|
||||
doubleOptInSubject: Optional[str]
|
||||
doubleOptInContent: Optional[str]
|
||||
createdAt: str
|
||||
updatedAt: str
|
||||
_count: ContactBookCounts
|
||||
|
||||
|
||||
ContactBookList = List[ContactBook]
|
||||
|
||||
|
||||
class ContactBookCreate(TypedDict, total=False):
|
||||
name: str
|
||||
emoji: Optional[str]
|
||||
properties: Optional[Dict[str, str]]
|
||||
doubleOptInEnabled: Optional[bool]
|
||||
doubleOptInFrom: Optional[str]
|
||||
doubleOptInSubject: Optional[str]
|
||||
doubleOptInContent: Optional[str]
|
||||
variables: Optional[List[str]]
|
||||
|
||||
|
||||
class ContactBookCreateResponse(ContactBook, total=False):
|
||||
pass
|
||||
|
||||
|
||||
class ContactBookUpdate(TypedDict, total=False):
|
||||
name: Optional[str]
|
||||
emoji: Optional[str]
|
||||
properties: Optional[Dict[str, str]]
|
||||
doubleOptInEnabled: Optional[bool]
|
||||
doubleOptInFrom: Optional[str]
|
||||
doubleOptInSubject: Optional[str]
|
||||
doubleOptInContent: Optional[str]
|
||||
variables: Optional[List[str]]
|
||||
|
||||
|
||||
class ContactBookUpdateResponse(ContactBook, total=False):
|
||||
pass
|
||||
|
||||
|
||||
class ContactBookDeleteResponse(TypedDict):
|
||||
id: str
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
class ContactCreate(TypedDict, total=False):
|
||||
email: str
|
||||
firstName: Optional[str]
|
||||
@@ -298,6 +357,23 @@ class ContactListItem(TypedDict, total=False):
|
||||
ContactList = List[ContactListItem]
|
||||
|
||||
|
||||
ContactBulkCreate = List[ContactCreate]
|
||||
|
||||
|
||||
class ContactBulkCreateResponse(TypedDict):
|
||||
message: str
|
||||
count: float
|
||||
|
||||
|
||||
class ContactBulkDelete(TypedDict):
|
||||
contactIds: List[str]
|
||||
|
||||
|
||||
class ContactBulkDeleteResponse(TypedDict):
|
||||
success: bool
|
||||
count: float
|
||||
|
||||
|
||||
class ContactUpdate(TypedDict, total=False):
|
||||
firstName: Optional[str]
|
||||
lastName: Optional[str]
|
||||
|
||||
@@ -71,6 +71,8 @@ class UseSend:
|
||||
# Lazily initialise resource clients.
|
||||
self.emails = Emails(self)
|
||||
self.contacts = Contacts(self)
|
||||
self.contact_books = ContactBooks(self)
|
||||
self.contactBooks = self.contact_books
|
||||
self.domains = Domains(self)
|
||||
self.campaigns = Campaigns(self)
|
||||
|
||||
@@ -186,6 +188,7 @@ class UseSend:
|
||||
# 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
|
||||
from .contact_books import ContactBooks # noqa: E402 pylint: disable=wrong-import-position
|
||||
from .domains import Domains # type: ignore # noqa: E402
|
||||
from .campaigns import Campaigns # type: ignore # noqa: E402
|
||||
from .webhooks import Webhooks # noqa: E402 pylint: disable=wrong-import-position
|
||||
|
||||
Reference in New Issue
Block a user