Skip to content

Commit fa3495d

Browse files
committed
Implement async client for IndiePitcher API with contact and mailing list management features
1 parent bcc923f commit fa3495d

File tree

6 files changed

+420
-137
lines changed

6 files changed

+420
-137
lines changed

indiepitcher/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""IndiePitcher Python SDK for email marketing platform."""
22

3+
from .async_client import IndiePitcherAsyncClient
34
from .client import IndiePitcherClient
45
from .models import ( # Models; Response types; Enums
56
Contact,
@@ -30,4 +31,5 @@
3031
"SendEmailToMailingList",
3132
"UpdateContact",
3233
"IndiePitcherResponseError",
34+
"IndiePitcherAsyncClient",
3335
]

indiepitcher/async_client.py

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
from typing import List
2+
3+
import httpx
4+
5+
from .models import (
6+
BaseIndiePitcherModel,
7+
Contact,
8+
CreateContact,
9+
CreateMailingListPortalSession,
10+
DataResponse,
11+
EmptyResponse,
12+
IndiePitcherResponseError,
13+
MailingList,
14+
MailingListPortalSession,
15+
PagedDataResponse,
16+
SendEmail,
17+
SendEmailToContact,
18+
SendEmailToMailingList,
19+
UpdateContact,
20+
)
21+
22+
23+
class ErrorResponse(BaseIndiePitcherModel):
24+
reason: str
25+
26+
27+
def raise_for_invalid_status(response: httpx.Response) -> None:
28+
if response.status_code >= 400:
29+
decoded_response = ErrorResponse.model_validate_json(response.content)
30+
raise IndiePitcherResponseError(
31+
status_code=response.status_code, reason=decoded_response.reason
32+
)
33+
34+
35+
class IndiePitcherAsyncClient:
36+
"""Async client for interacting with the IndiePitcher API."""
37+
38+
def __init__(
39+
self, api_key: str, base_url: str = "https://api.indiepitcher.com/v1"
40+
) -> None:
41+
"""
42+
Initialize the IndiePitcher async API client.
43+
44+
Args:
45+
api_key: Your IndiePitcher API key
46+
base_url: Base URL for the IndiePitcher API (default: https://api.indiepitcher.com/v1)
47+
"""
48+
self.api_key = api_key
49+
self.base_url = base_url
50+
self.client = httpx.AsyncClient(
51+
headers={
52+
"Authorization": f"Bearer {api_key}",
53+
"Content-Type": "application/json",
54+
"User-Agent": "IndiePitcher-Python/0.1.0",
55+
},
56+
timeout=30.0, # Default timeout of 30 seconds
57+
)
58+
59+
async def __aenter__(self):
60+
"""Support async context manager protocol."""
61+
return self
62+
63+
async def __aexit__(self, exc_type, exc_val, exc_tb):
64+
"""Close the client when exiting context manager."""
65+
await self.close()
66+
67+
async def close(self):
68+
"""Close the underlying HTTP client."""
69+
await self.client.aclose()
70+
71+
# Contact Management
72+
73+
async def get_contact(self, email: str) -> DataResponse[Contact]:
74+
"""
75+
Find a contact by email.
76+
77+
Args:
78+
email: The email address of the contact to find
79+
80+
Returns:
81+
DataResponse[Contact]: The contact if found
82+
83+
Raises:
84+
indiepitcher.IndiePitcherResponseError: If the request fails
85+
"""
86+
response = await self.client.get(
87+
f"{self.base_url}/contacts/find", params={"email": email}
88+
)
89+
raise_for_invalid_status(response)
90+
return DataResponse[Contact].model_validate_json(response.content)
91+
92+
async def list_contacts(
93+
self, page: int = 1, per_page: int = 20
94+
) -> PagedDataResponse[Contact]:
95+
"""
96+
List contacts with pagination.
97+
98+
Args:
99+
page: Page number (default: 1)
100+
per_page: Number of contacts per page (default: 20)
101+
102+
Returns:
103+
PagedDataResponse[Contact]: Paginated list of contacts
104+
105+
Raises:
106+
indiepitcher.IndiePitcherResponseError: If the request fails
107+
"""
108+
response = await self.client.get(
109+
f"{self.base_url}/contacts", params={"page": page, "per": per_page}
110+
)
111+
raise_for_invalid_status(response)
112+
return PagedDataResponse[Contact].model_validate_json(response.content)
113+
114+
async def create_contact(self, contact: CreateContact) -> DataResponse[Contact]:
115+
"""
116+
Add a new contact.
117+
118+
Args:
119+
contact: Contact details to create
120+
121+
Returns:
122+
DataResponse[Contact]: The created contact
123+
124+
Raises:
125+
indiepitcher.IndiePitcherResponseError: If the request fails
126+
"""
127+
response = await self.client.post(
128+
f"{self.base_url}/contacts/create",
129+
json=contact.model_dump(by_alias=True, exclude_none=True),
130+
)
131+
raise_for_invalid_status(response)
132+
return DataResponse[Contact].model_validate_json(response.content)
133+
134+
async def create_contacts(
135+
self, contacts: List[CreateContact]
136+
) -> DataResponse[Contact]:
137+
"""
138+
Add multiple contacts in a single request.
139+
140+
Args:
141+
contacts: List of contacts to create (max 100)
142+
143+
Returns:
144+
DataResponse[Contact]: The created contacts
145+
146+
Raises:
147+
indiepitcher.IndiePitcherResponseError: If the request fails
148+
ValueError: If more than 100 contacts are provided
149+
"""
150+
151+
response = await self.client.post(
152+
f"{self.base_url}/contacts/create_many",
153+
json=[
154+
contact.model_dump(by_alias=True, exclude_none=True)
155+
for contact in contacts
156+
],
157+
)
158+
raise_for_invalid_status(response)
159+
return DataResponse[Contact].model_validate_json(response.content)
160+
161+
async def update_contact(self, contact: UpdateContact) -> DataResponse[Contact]:
162+
"""
163+
Update an existing contact.
164+
165+
Args:
166+
contact: Contact details to update
167+
168+
Returns:
169+
DataResponse[Contact]: The updated contact
170+
171+
Raises:
172+
indiepitcher.IndiePitcherResponseError: If the request fails
173+
"""
174+
response = await self.client.patch(
175+
f"{self.base_url}/contacts/update",
176+
json=contact.model_dump(by_alias=True, exclude_none=True),
177+
)
178+
raise_for_invalid_status(response)
179+
return DataResponse[Contact].model_validate_json(response.content)
180+
181+
async def delete_contact(self, email: str) -> EmptyResponse:
182+
"""
183+
Delete a contact by email.
184+
185+
Args:
186+
email: Email address of the contact to delete
187+
188+
Returns:
189+
EmptyResponse: Success response
190+
191+
Raises:
192+
indiepitcher.IndiePitcherResponseError: If the request fails
193+
"""
194+
response = await self.client.post(
195+
f"{self.base_url}/contacts/delete", json={"email": email}
196+
)
197+
raise_for_invalid_status(response)
198+
return EmptyResponse.model_validate_json(response.content)
199+
200+
# Mailing List Management
201+
202+
async def list_mailing_lists(
203+
self, page: int = 1, per_page: int = 10
204+
) -> PagedDataResponse[MailingList]:
205+
"""
206+
Get all mailing lists.
207+
208+
Args:
209+
page: Page number (default: 1)
210+
per_page: Number of lists per page (default: 10)
211+
212+
Returns:
213+
PagedDataResponse[MailingList]: Paginated list of mailing lists
214+
215+
Raises:
216+
indiepitcher.IndiePitcherResponseError: If the request fails
217+
"""
218+
response = await self.client.get(
219+
f"{self.base_url}/lists", params={"page": page, "per": per_page}
220+
)
221+
raise_for_invalid_status(response)
222+
return PagedDataResponse[MailingList].model_validate_json(response.content)
223+
224+
async def create_mailing_list_portal_session(
225+
self, session: CreateMailingListPortalSession
226+
) -> DataResponse[MailingListPortalSession]:
227+
"""
228+
Create a mailing list portal session.
229+
230+
Args:
231+
session: Portal session details
232+
233+
Returns:
234+
MailingListPortalSessionResponse: Portal session details with URL
235+
236+
Raises:
237+
indiepitcher.IndiePitcherResponseError: If the request fails
238+
"""
239+
240+
response = await self.client.post(
241+
f"{self.base_url}/lists/portal_session",
242+
json=session.model_dump(by_alias=True, exclude_none=True),
243+
)
244+
raise_for_invalid_status(response)
245+
return DataResponse[MailingListPortalSession].model_validate_json(
246+
response.content
247+
)
248+
249+
# Email Sending
250+
251+
async def send_email(self, email: SendEmail) -> EmptyResponse:
252+
"""
253+
Send a transactional email.
254+
255+
Args:
256+
email: Email details to send
257+
258+
Returns:
259+
EmptyResponse: Success response
260+
261+
Raises:
262+
indiepitcher.IndiePitcherResponseError: If the request fails
263+
"""
264+
response = await self.client.post(
265+
f"{self.base_url}/email/transactional",
266+
json=email.model_dump(by_alias=True, exclude_none=True),
267+
)
268+
raise_for_invalid_status(response)
269+
return EmptyResponse.model_validate_json(response.content)
270+
271+
async def send_email_to_contact(self, email: SendEmailToContact) -> EmptyResponse:
272+
"""
273+
Send an email to one or more contacts.
274+
275+
Args:
276+
email: Email details to send
277+
278+
Returns:
279+
EmptyResponse: Success response
280+
281+
Raises:
282+
indiepitcher.IndiePitcherResponseError: If the request fails
283+
"""
284+
response = await self.client.post(
285+
f"{self.base_url}/email/contact",
286+
json=email.model_dump(by_alias=True, exclude_none=True),
287+
)
288+
raise_for_invalid_status(response)
289+
return EmptyResponse.model_validate_json(response.content)
290+
291+
async def send_email_to_mailing_list(
292+
self, email: SendEmailToMailingList
293+
) -> EmptyResponse:
294+
"""
295+
Send an email to a mailing list.
296+
297+
Args:
298+
email: Email details to send
299+
300+
Returns:
301+
EmptyResponse: Success response
302+
303+
Raises:
304+
indiepitcher.IndiePitcherResponseError: If the request fails
305+
"""
306+
response = await self.client.post(
307+
f"{self.base_url}/email/list",
308+
json=email.model_dump(by_alias=True, exclude_none=True),
309+
)
310+
raise_for_invalid_status(response)
311+
return EmptyResponse.model_validate_json(response.content)

0 commit comments

Comments
 (0)