feat: sync sdk contact book support (#373)

This commit is contained in:
KM Koushik
2026-03-08 00:59:40 +11:00
committed by GitHub
parent 33acd09d77
commit 83cb0b24f7
11 changed files with 3577 additions and 2945 deletions
+2 -1
View File
@@ -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()`
+2 -1
View File
@@ -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"]
+138
View File
@@ -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"]}
+4
View File
@@ -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
+50
View File
@@ -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]]:
+76
View File
@@ -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]
+3
View File
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "usesend-js",
"version": "1.6.2",
"version": "1.6.3",
"description": "",
"repository": {
"type": "git",
+569 -388
View File
File diff suppressed because it is too large Load Diff