diff --git a/README.md b/README.md index f0d0b90..038fb26 100644 --- a/README.md +++ b/README.md @@ -55,3 +55,44 @@ params: resend.Emails.SendParams = { email: resend.Email = resend.Emails.send(params) print(email) ``` + +## Async Support + +The SDK supports async operations for improved performance in async applications. To use async features, you need to install the async dependencies: + +```bash +pip install resend[async] +``` + +### Async Example + +```py +import asyncio +import os +import resend + +resend.api_key = "re_yourkey" +# Set up async HTTP client +resend.default_http_client = resend.HTTPXClient() + +async def main(): + params: resend.Emails.SendParams = { + "from": "onboarding@resend.dev", + "to": ["delivered@resend.dev"], + "subject": "hi", + "html": "hello, world!", + "reply_to": "to@gmail.com", + "bcc": "bcc@resend.dev", + "cc": ["cc@resend.dev"], + "tags": [ + {"name": "tag1", "value": "tagvalue1"}, + {"name": "tag2", "value": "tagvalue2"}, + ], + } + + email: resend.Email = await resend.Emails.send_async(params) + print(email) + +if __name__ == "__main__": + asyncio.run(main()) +``` diff --git a/examples/api_keys_async.py b/examples/api_keys_async.py new file mode 100644 index 0000000..7388ab4 --- /dev/null +++ b/examples/api_keys_async.py @@ -0,0 +1,35 @@ +import asyncio +import os +from typing import List + +import resend + +if not os.environ["RESEND_API_KEY"]: + raise EnvironmentError("RESEND_API_KEY is missing") + +# Set up async HTTP client +resend.default_http_client = resend.HTTPXClient() + + +async def main() -> None: + create_params: resend.ApiKeys.CreateParams = { + "name": "example.com", + } + + key: resend.ApiKey = await resend.ApiKeys.create_async(params=create_params) + print("Created new api key") + print(f"Key id: {key['id']} and token: {key['token']}") + + keys: resend.ApiKeys.ListResponse = await resend.ApiKeys.list_async() + for key in keys["data"]: + print(key["id"]) + print(key["name"]) + print(key["created_at"]) + + if len(keys["data"]) > 0: + await resend.ApiKeys.remove_async(api_key_id=keys["data"][0]["id"]) + print(f"Removed api key: {keys['data'][0]['id']}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/audiences_async.py b/examples/audiences_async.py new file mode 100644 index 0000000..288dd70 --- /dev/null +++ b/examples/audiences_async.py @@ -0,0 +1,34 @@ +import asyncio +import os +from typing import List + +import resend + +if not os.environ["RESEND_API_KEY"]: + raise EnvironmentError("RESEND_API_KEY is missing") + +# Set up async HTTP client +resend.default_http_client = resend.HTTPXClient() + + +async def main() -> None: + create_params: resend.Audiences.CreateParams = { + "name": "New Audience from Python SDK (Async)", + } + audience: resend.Audience = await resend.Audiences.create_async(create_params) + print(f"Created audience: {audience['id']}") + print(audience) + + aud: resend.Audience = await resend.Audiences.get_async(audience["id"]) + print("Retrieved audience: ", aud) + + audiences: resend.Audiences.ListResponse = await resend.Audiences.list_async() + print("List of audiences:", [a["id"] for a in audiences["data"]]) + + rmed: resend.Audience = await resend.Audiences.remove_async(id=audience["id"]) + print(f"Deleted audience") + print(rmed) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/batch_email_send_async.py b/examples/batch_email_send_async.py new file mode 100644 index 0000000..1336791 --- /dev/null +++ b/examples/batch_email_send_async.py @@ -0,0 +1,62 @@ +import asyncio +import os +from typing import List + +import resend +import resend.exceptions + +if not os.environ["RESEND_API_KEY"]: + raise EnvironmentError("RESEND_API_KEY is missing") + +# Set up async HTTP client +resend.default_http_client = resend.HTTPXClient() + + +async def main() -> None: + params: List[resend.Emails.SendParams] = [ + { + "from": "onboarding@resend.dev", + "to": ["delivered@resend.dev"], + "subject": "hey", + "html": "hello, world!", + }, + { + "from": "onboarding@resend.dev", + "to": ["delivered@resend.dev"], + "subject": "hello", + "html": "hello, world!", + }, + ] + + try: + # Send batch emails + print("sending without idempotency_key") + emails: resend.Batch.SendResponse = await resend.Batch.send_async(params) + for email in emails["data"]: + print(f"Email id: {email['id']}") + except resend.exceptions.ResendError as err: + print("Failed to send batch emails") + print(f"Error: {err}") + exit(1) + + try: + # Send batch emails with idempotency_key + print("sending with idempotency_key") + + options: resend.Batch.SendOptions = { + "idempotency_key": "af477dc78aa9fa91fff3b8c0d4a2e1a5", + } + + e: resend.Batch.SendResponse = await resend.Batch.send_async( + params, options=options + ) + for email in e["data"]: + print(f"Email id: {email['id']}") + except resend.exceptions.ResendError as err: + print("Failed to send batch emails") + print(f"Error: {err}") + exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/broadcasts_async.py b/examples/broadcasts_async.py new file mode 100644 index 0000000..368e415 --- /dev/null +++ b/examples/broadcasts_async.py @@ -0,0 +1,77 @@ +import asyncio +import os +from typing import List + +import resend +import resend.broadcasts + +if not os.environ["RESEND_API_KEY"]: + raise EnvironmentError("RESEND_API_KEY is missing") + +# Set up async HTTP client +resend.default_http_client = resend.HTTPXClient() + +# replace with some existing audience id +audience_id: str = "78b8d3bc-a55a-45a3-aee6-6ec0a5e13d7e" + + +async def main() -> None: + create_params: resend.Broadcasts.CreateParams = { + "audience_id": audience_id, + "from": "onboarding@resend.dev", + "subject": "Hello, world! (Async)", + "html": "
Hello, world!
", + "text": "Hello, world!", + "reply_to": ["foo@resend.dev", "bar@resend.dev"], + "name": "Hello, world! (Async)", + } + + broadcast: resend.Broadcasts.CreateResponse = await resend.Broadcasts.create_async( + create_params + ) + print("Created broadcast !") + print(broadcast) + + update_params: resend.Broadcasts.UpdateParams = { + "broadcast_id": broadcast["id"], + "html": "Hello, world! Updated (Async)
", + "text": "Hello, world! Updated (Async)", + "name": "Hello, world! Updated (Async)", + } + + updated_broadcast: resend.Broadcasts.UpdateResponse = ( + await resend.Broadcasts.update_async(update_params) + ) + print("Updated broadcast!") + print(updated_broadcast) + + send_params: resend.Broadcasts.SendParams = { + "broadcast_id": broadcast["id"], + } + sent: resend.Broadcasts.SendResponse = await resend.Broadcasts.send_async( + send_params + ) + print("Sent broadcast !\n") + print(sent) + + retrieved: resend.Broadcast = await resend.Broadcasts.get_async(id=broadcast["id"]) + print("retrieved broadcast !\n") + print(retrieved) + + if retrieved["status"] == "draft": + removed: resend.Broadcasts.RemoveResponse = ( + await resend.Broadcasts.remove_async(id=broadcast["id"]) + ) + print("Removed broadcast !\n") + print(removed) + print("\n") + else: + print("Broadcast is not in draft status, cannot remove it.\n") + + list_response: resend.Broadcasts.ListResponse = await resend.Broadcasts.list_async() + print("List of broadcasts !\n") + print(list_response) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/contacts_async.py b/examples/contacts_async.py new file mode 100644 index 0000000..e9fa2af --- /dev/null +++ b/examples/contacts_async.py @@ -0,0 +1,73 @@ +import asyncio +import os +from typing import List + +import resend + +if not os.environ["RESEND_API_KEY"]: + raise EnvironmentError("RESEND_API_KEY is missing") + +# Set up async HTTP client +resend.default_http_client = resend.HTTPXClient() + +# replace with some audience id +audience_id: str = "ca4e37c5-a82a-4199-a3b8-bf912a6472aa" + + +async def main() -> None: + create_params: resend.Contacts.CreateParams = { + "audience_id": audience_id, + "email": "sw@exmple.com", + "first_name": "Steve", + "last_name": "Wozniak", + "unsubscribed": False, + } + + contact: resend.Contact = await resend.Contacts.create_async(create_params) + print("Created contact !") + print(contact) + + update_params: resend.Contacts.UpdateParams = { + "audience_id": audience_id, + "id": contact["id"], + "unsubscribed": False, + "first_name": "Steve (Async)", + } + + updated: resend.Contact = await resend.Contacts.update_async(update_params) + print("updated contact !") + print(updated) + + cont_by_id: resend.Contact = await resend.Contacts.get_async( + id=contact["id"], audience_id=audience_id + ) + print("Retrieved contact by ID") + print(cont_by_id) + + cont_by_email: resend.Contact = await resend.Contacts.get_async( + email="sw@exmple.com", audience_id=audience_id + ) + print("Retrieved contact by Email") + print(cont_by_email) + + contacts: resend.Contacts.ListResponse = await resend.Contacts.list_async( + audience_id=audience_id + ) + print("List of contacts") + for contact in contacts["data"]: + print(contact) + + # remove by email + rmed = await resend.Contacts.remove_async( + audience_id=audience_id, email=contact["email"] + ) + + # remove by id + # rmed: resend.Contact = await resend.Contacts.remove_async(audience_id=audience_id, id=cont["id"]) + + print(f"Removed contact") + print(rmed) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/domains_async.py b/examples/domains_async.py new file mode 100644 index 0000000..1acb6d2 --- /dev/null +++ b/examples/domains_async.py @@ -0,0 +1,54 @@ +import asyncio +import os + +import resend + +if not os.environ["RESEND_API_KEY"]: + raise EnvironmentError("RESEND_API_KEY is missing") + +# Set up async HTTP client +resend.default_http_client = resend.HTTPXClient() + + +async def main() -> None: + create_params: resend.Domains.CreateParams = { + "name": "example.com", + "region": "us-east-1", + "custom_return_path": "outbound", + } + domain: resend.Domain = await resend.Domains.create_async(params=create_params) + print(domain) + + retrieved: resend.Domain = await resend.Domains.get_async(domain_id=domain["id"]) + if retrieved["records"] is not None: + for record in retrieved["records"]: + print(record) + + update_params: resend.Domains.UpdateParams = { + "id": domain["id"], + "open_tracking": True, + "click_tracking": True, + "tls": "enforced", + } + + updated_domain: resend.Domain = await resend.Domains.update_async(update_params) + print(f"Updated domain: {updated_domain['id']}") + + domains: resend.Domains.ListResponse = await resend.Domains.list_async() + if not domains: + print("No domains found") + for domain in domains["data"]: + print(domain) + + verified_domain: resend.Domain = await resend.Domains.verify_async( + domain_id=domain["id"] + ) + print(f"Verified") + print(verified_domain) + + rm_domain: resend.Domain = await resend.Domains.remove_async(domain_id=domain["id"]) + print(rm_domain) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/simple_email_async.py b/examples/simple_email_async.py new file mode 100644 index 0000000..e75e74b --- /dev/null +++ b/examples/simple_email_async.py @@ -0,0 +1,55 @@ +import asyncio +import os + +import resend + +if not os.environ["RESEND_API_KEY"]: + raise EnvironmentError("RESEND_API_KEY is missing") + +# Set up async HTTP client +resend.default_http_client = resend.HTTPXClient() + +params: resend.Emails.SendParams = { + "from": "onboarding@resend.dev", + "to": ["delivered@resend.dev"], + "subject": "hi", + "html": "hello, world!", + "reply_to": "to@gmail.com", + "bcc": "delivered@resend.dev", + "cc": ["delivered@resend.dev"], + "tags": [ + {"name": "tag1", "value": "tagvalue1"}, + {"name": "tag2", "value": "tagvalue2"}, + ], +} + + +async def main() -> None: + # Without Idempotency Key + email_non_idempotent: resend.Email = await resend.Emails.send_async(params) + print(f"Sent email without idempotency key: {email_non_idempotent['id']}") + + # With Idempotency Key + options: resend.Emails.SendOptions = { + "idempotency_key": "44", + } + email_idempotent: resend.Email = await resend.Emails.send_async(params, options) + print(f"Sent email with idempotency key: {email_idempotent['id']}") + + email_resp: resend.Email = await resend.Emails.get_async( + email_id=email_non_idempotent["id"] + ) + print(f"Retrieved email: {email_resp['id']}") + print("Email ID: ", email_resp["id"]) + print("Email from: ", email_resp["from"]) + print("Email to: ", email_resp["to"]) + print("Email subject: ", email_resp["subject"]) + print("Email html: ", email_resp["html"]) + print("Email created_at: ", email_resp["created_at"]) + print("Email reply_to: ", email_resp["reply_to"]) + print("Email bcc: ", email_resp["bcc"]) + print("Email cc: ", email_resp["cc"]) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..f554545 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1 @@ +httpx>=0.24.0 diff --git a/resend/__init__.py b/resend/__init__.py index b1fcb7e..a0bb9cc 100644 --- a/resend/__init__.py +++ b/resend/__init__.py @@ -1,4 +1,5 @@ import os +from typing import Union from .api_keys._api_key import ApiKey from .api_keys._api_keys import ApiKeys @@ -16,16 +17,24 @@ from .emails._emails import Emails from .emails._tag import Tag from .http_client import HTTPClient +from .http_client_async import \ + AsyncHTTPClient # Okay to import AsyncHTTPClient since it is just an interface. from .http_client_requests import RequestsClient from .request import Request from .version import __version__, get_version +# Type for clients that support both sync and async +ResendHTTPClient = Union[HTTPClient, AsyncHTTPClient] + +# This is the client that is set by default HTTP Client +# But this can be overridden by the user and set to an async client. +default_http_client: ResendHTTPClient = RequestsClient() + + # Config vars api_key = os.environ.get("RESEND_API_KEY") api_url = os.environ.get("RESEND_API_URL", "https://api.resend.com") -# HTTP Client -default_http_client: HTTPClient = RequestsClient() # API resources from .emails._emails import Emails # noqa @@ -50,6 +59,17 @@ "Attachment", "Tag", "Broadcast", + # HTTP Clients + "HTTPClient", # Default HTTP Client "RequestsClient", ] + +# Add async exports if available +try: + from .async_request import AsyncRequest # noqa: F401 + from .http_client_httpx import HTTPXClient # noqa: F401 + + __all__.extend(["AsyncHTTPClient", "HTTPXClient", "AsyncRequest"]) +except ImportError: + pass diff --git a/resend/api_keys/_api_keys.py b/resend/api_keys/_api_keys.py index 60f0361..20b7c2e 100644 --- a/resend/api_keys/_api_keys.py +++ b/resend/api_keys/_api_keys.py @@ -5,6 +5,12 @@ from resend import request from resend.api_keys._api_key import ApiKey +# Async imports (optional - only available with pip install resend[async]) +try: + from resend.async_request import AsyncRequest +except ImportError: + pass + class _ListResponse(TypedDict): data: List[ApiKey] @@ -90,3 +96,54 @@ def remove(cls, api_key_id: str) -> None: # This would raise if failed request.Request[None](path=path, params={}, verb="delete").perform() return None + + @classmethod + async def create_async(cls, params: CreateParams) -> ApiKey: + """ + Add a new API key to authenticate communications with Resend (async). + see more: https://resend.com/docs/api-reference/api-keys/create-api-key + + Args: + params (CreateParams): The API key creation parameters + + Returns: + ApiKey: The new API key object + """ + path = "/api-keys" + resp = await AsyncRequest[ApiKey]( + path=path, params=cast(Dict[Any, Any], params), verb="post" + ).perform_with_content() + return resp + + @classmethod + async def list_async(cls) -> ListResponse: + """ + Retrieve a list of API keys for the authenticated user (async). + see more: https://resend.com/docs/api-reference/api-keys/list-api-keys + + Returns: + ListResponse: A list of API key objects + """ + path = "/api-keys" + resp = await AsyncRequest[_ListResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def remove_async(cls, api_key_id: str) -> None: + """ + Remove an existing API key (async). + see more: https://resend.com/docs/api-reference/api-keys/delete-api-key + + Args: + api_key_id (str): The ID of the API key to remove + + Returns: + None + """ + path = f"/api-keys/{api_key_id}" + + # This would raise if failed + await AsyncRequest[None](path=path, params={}, verb="delete").perform() + return None diff --git a/resend/async_request.py b/resend/async_request.py new file mode 100644 index 0000000..f45cead --- /dev/null +++ b/resend/async_request.py @@ -0,0 +1,111 @@ +import json +from typing import Any, Dict, Generic, List, Optional, Union, cast + +from typing_extensions import Literal, TypeVar + +import resend +from resend.exceptions import (NoContentError, ResendError, + raise_for_code_and_type) +from resend.http_client_async import AsyncHTTPClient +from resend.version import get_version + +RequestVerb = Literal["get", "post", "put", "patch", "delete"] +T = TypeVar("T") + +ParamsType = Union[Dict[str, Any], List[Dict[str, Any]]] +HeadersType = Dict[str, str] + + +class AsyncRequest(Generic[T]): + def __init__( + self, + path: str, + params: ParamsType, + verb: RequestVerb, + options: Optional[Dict[str, Any]] = None, + ): + self.path = path + self.params = params + self.verb = verb + self.options = options + + async def perform(self) -> Union[T, None]: + data = await self.make_request(url=f"{resend.api_url}{self.path}") + + if isinstance(data, dict) and data.get("statusCode") not in (None, 200): + raise_for_code_and_type( + code=data.get("statusCode") or 500, + message=data.get("message", "Unknown error"), + error_type=data.get("name", "InternalServerError"), + ) + + return cast(T, data) + + async def perform_with_content(self) -> T: + resp = await self.perform() + if resp is None: + raise NoContentError() + return resp + + def __get_headers(self) -> HeadersType: + headers: HeadersType = { + "Accept": "application/json", + "Authorization": f"Bearer {resend.api_key}", + "User-Agent": f"resend-python:{get_version()}", + } + + if self.verb == "post" and self.options and "idempotency_key" in self.options: + headers["Idempotency-Key"] = str(self.options["idempotency_key"]) + + return headers + + async def make_request(self, url: str) -> Union[Dict[str, Any], List[Any]]: + headers = self.__get_headers() + + if isinstance(self.params, dict): + json_params: Optional[Union[Dict[str, Any], List[Any]]] = { + str(k): v for k, v in self.params.items() + } + elif isinstance(self.params, list): + json_params = [dict(item) for item in self.params] + else: + json_params = None + + try: + # Cast to AsyncHTTPClient for type checking - user must set HTTPXClient + async_client = cast(AsyncHTTPClient, resend.default_http_client) + content, _status_code, resp_headers = await async_client.request( + method=self.verb, + url=url, + headers=headers, + json=json_params, + ) + + # Safety net around the HTTP Client + except Exception as e: + raise ResendError( + code=500, + message=str(e), + error_type="HttpClientError", + suggested_action="Request failed, please try again.", + ) + + content_type = {k.lower(): v for k, v in resp_headers.items()}.get( + "content-type", "" + ) + + if "application/json" not in content_type: + raise_for_code_and_type( + code=500, + message=f"Expected JSON response but got: {content_type}", + error_type="InternalServerError", + ) + + try: + return cast(Union[Dict[str, Any], List[Any]], json.loads(content)) + except json.JSONDecodeError: + raise_for_code_and_type( + code=500, + message="Failed to decode JSON response", + error_type="InternalServerError", + ) diff --git a/resend/audiences/_audiences.py b/resend/audiences/_audiences.py index cfff751..4beefdb 100644 --- a/resend/audiences/_audiences.py +++ b/resend/audiences/_audiences.py @@ -6,6 +6,12 @@ from ._audience import Audience +# Async imports (optional - only available with pip install resend[async]) +try: + from resend.async_request import AsyncRequest +except ImportError: + pass + class _ListResponse(TypedDict): data: List[Audience] @@ -98,3 +104,72 @@ def remove(cls, id: str) -> Audience: path=path, params={}, verb="delete" ).perform_with_content() return resp + + @classmethod + async def create_async(cls, params: CreateParams) -> Audience: + """ + Create a list of contacts (async). + see more: https://resend.com/docs/api-reference/audiences/create-audience + + Args: + params (CreateParams): The audience creation parameters + + Returns: + Audience: The new audience object + """ + path = "/audiences" + resp = await AsyncRequest[Audience]( + path=path, params=cast(Dict[Any, Any], params), verb="post" + ).perform_with_content() + return resp + + @classmethod + async def list_async(cls) -> ListResponse: + """ + Retrieve a list of audiences (async). + see more: https://resend.com/docs/api-reference/audiences/list-audiences + + Returns: + ListResponse: A list of audience objects + """ + path = "/audiences/" + resp = await AsyncRequest[_ListResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def get_async(cls, id: str) -> Audience: + """ + Retrieve a single audience (async). + see more: https://resend.com/docs/api-reference/audiences/get-audience + + Args: + id (str): The audience ID + + Returns: + Audience: The audience object + """ + path = f"/audiences/{id}" + resp = await AsyncRequest[Audience]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def remove_async(cls, id: str) -> Audience: + """ + Delete a single audience (async). + see more: https://resend.com/docs/api-reference/audiences/delete-audience + + Args: + id (str): The audience ID + + Returns: + Audience: The audience object + """ + path = f"/audiences/{id}" + resp = await AsyncRequest[Audience]( + path=path, params={}, verb="delete" + ).perform_with_content() + return resp diff --git a/resend/broadcasts/_broadcasts.py b/resend/broadcasts/_broadcasts.py index 9e4d6c4..003fae4 100644 --- a/resend/broadcasts/_broadcasts.py +++ b/resend/broadcasts/_broadcasts.py @@ -6,6 +6,12 @@ from ._broadcast import Broadcast +# Async imports (optional - only available with pip install resend[async]) +try: + from resend.async_request import AsyncRequest +except ImportError: + pass + # _CreateParamsFrom is declared with functional TypedDict syntax here because # "from" is a reserved keyword in Python, and this is the best way to # support type-checking for it. @@ -323,3 +329,108 @@ def remove(cls, id: str) -> RemoveResponse: path=path, params={}, verb="delete" ).perform_with_content() return resp + + @classmethod + async def create_async(cls, params: CreateParams) -> CreateResponse: + """ + Create a broadcast (async). + see more: https://resend.com/docs/api-reference/broadcasts/create-broadcast + + Args: + params (CreateParams): The broadcast creation parameters + + Returns: + CreateResponse: The new broadcast object response + """ + path = "/broadcasts" + resp = await AsyncRequest[_CreateResponse]( + path=path, params=cast(Dict[Any, Any], params), verb="post" + ).perform_with_content() + return resp + + @classmethod + async def update_async(cls, params: UpdateParams) -> UpdateResponse: + """ + Update a broadcast (async). + see more: https://resend.com/docs/api-reference/broadcasts/update-broadcast + + Args: + params (UpdateParams): The broadcast update parameters + + Returns: + UpdateResponse: The updated broadcast object response + """ + path = f"/broadcasts/{params['broadcast_id']}" + resp = await AsyncRequest[_UpdateResponse]( + path=path, params=cast(Dict[Any, Any], params), verb="patch" + ).perform_with_content() + return resp + + @classmethod + async def send_async(cls, params: SendParams) -> SendResponse: + """ + Sends a broadcast (async). + see more: https://resend.com/docs/api-reference/broadcasts/send-broadcast + + Args: + params (SendParams): The broadcast send parameters + + Returns: + SendResponse: The new broadcast object response + """ + path = f"/broadcasts/{params['broadcast_id']}/send" + resp = await AsyncRequest[_SendResponse]( + path=path, params=cast(Dict[Any, Any], params), verb="post" + ).perform_with_content() + return resp + + @classmethod + async def list_async(cls) -> ListResponse: + """ + Retrieve a list of broadcasts (async). + see more: https://resend.com/docs/api-reference/broadcasts/list-broadcasts + + Returns: + ListResponse: A list of broadcast objects + """ + path = "/broadcasts/" + resp = await AsyncRequest[_ListResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def get_async(cls, id: str) -> Broadcast: + """ + Retrieve a single broadcast (async). + see more: https://resend.com/docs/api-reference/broadcasts/get-broadcast + + Args: + id (str): The broadcast ID + + Returns: + Broadcast: The broadcast object + """ + path = f"/broadcasts/{id}" + resp = await AsyncRequest[Broadcast]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def remove_async(cls, id: str) -> RemoveResponse: + """ + Delete a single broadcast (async). + see more: https://resend.com/docs/api-reference/broadcasts/delete-broadcasts + + Args: + id (str): The broadcast ID + + Returns: + RemoveResponse: The remove response object + """ + path = f"/broadcasts/{id}" + resp = await AsyncRequest[_RemoveResponse]( + path=path, params={}, verb="delete" + ).perform_with_content() + return resp diff --git a/resend/contacts/_contacts.py b/resend/contacts/_contacts.py index aecc480..46cd1b0 100644 --- a/resend/contacts/_contacts.py +++ b/resend/contacts/_contacts.py @@ -6,6 +6,12 @@ from ._contact import Contact +# Async imports (optional - only available with pip install resend[async]) +try: + from resend.async_request import AsyncRequest +except ImportError: + pass + class _ListResponse(TypedDict): data: List[Contact] @@ -182,3 +188,114 @@ def remove( path=path, params={}, verb="delete" ).perform_with_content() return resp + + @classmethod + async def create_async(cls, params: CreateParams) -> Contact: + """ + Create a new contact (async). + see more: https://resend.com/docs/api-reference/contacts/create-contact + + Args: + params (CreateParams): The contact creation parameters + + Returns: + Contact: The new contact object + """ + path = f"/audiences/{params['audience_id']}/contacts" + resp = await AsyncRequest[Contact]( + path=path, params=cast(Dict[Any, Any], params), verb="post" + ).perform_with_content() + return resp + + @classmethod + async def update_async(cls, params: UpdateParams) -> Contact: + """ + Update an existing contact (async). + see more: https://resend.com/docs/api-reference/contacts/update-contact + + Args: + params (UpdateParams): The contact update parameters + + Returns: + Contact: The updated contact object + """ + if params.get("id") is None and params.get("email") is None: + raise ValueError("id or email must be provided") + + val = params.get("id") if params.get("id") is not None else params.get("email") + + path = f"/audiences/{params['audience_id']}/contacts/{val}" + resp = await AsyncRequest[Contact]( + path=path, params=cast(Dict[Any, Any], params), verb="patch" + ).perform_with_content() + return resp + + @classmethod + async def list_async(cls, audience_id: str) -> ListResponse: + """ + List all contacts for the provided audience (async). + see more: https://resend.com/docs/api-reference/contacts/list-contacts + + Args: + audience_id (str): The audience ID + + Returns: + ListResponse: A list of contact objects + """ + path = f"/audiences/{audience_id}/contacts" + resp = await AsyncRequest[_ListResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def get_async( + cls, audience_id: str, id: Optional[str] = None, email: Optional[str] = None + ) -> Contact: + """ + Get a contact (async). + see more: https://resend.com/docs/api-reference/contacts/get-contact + + Args: + audience_id (str): The audience ID + id (Optional[str]): The contact ID + email (Optional[str]): The contact email + + Returns: + Contact: The contact object + """ + contact = email if id is None else id + if contact is None: + raise ValueError("id or email must be provided") + + path = f"/audiences/{audience_id}/contacts/{contact}" + resp = await AsyncRequest[Contact]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def remove_async( + cls, audience_id: str, id: Optional[str] = None, email: Optional[str] = None + ) -> Contact: + """ + Remove a contact by ID or by Email (async). + see more: https://resend.com/docs/api-reference/contacts/delete-contact + + Args: + audience_id (str): The audience ID + id (Optional[str]): The contact ID + email (Optional[str]): The contact email + + Returns: + Contact: The removed contact object + """ + contact = email if id is None else id + if contact is None: + raise ValueError("id or email must be provided") + path = f"/audiences/{audience_id}/contacts/{contact}" + + resp = await AsyncRequest[Contact]( + path=path, params={}, verb="delete" + ).perform_with_content() + return resp diff --git a/resend/domains/_domains.py b/resend/domains/_domains.py index 052c210..feeb54c 100644 --- a/resend/domains/_domains.py +++ b/resend/domains/_domains.py @@ -5,6 +5,12 @@ from resend import request from resend.domains._domain import Domain +# Async imports (optional - only available with pip install resend[async]) +try: + from resend.async_request import AsyncRequest +except ImportError: + pass + TlsOptions = Literal["enforced", "opportunistic"] @@ -171,3 +177,108 @@ def verify(cls, domain_id: str) -> Domain: path=path, params={}, verb="post" ).perform_with_content() return resp + + @classmethod + async def create_async(cls, params: CreateParams) -> Domain: + """ + Create a domain through the Resend Email API (async). + see more: https://resend.com/docs/api-reference/domains/create-domain + + Args: + params (CreateParams): The domain creation parameters + + Returns: + Domain: The new domain object + """ + path = "/domains" + resp = await AsyncRequest[Domain]( + path=path, params=cast(Dict[Any, Any], params), verb="post" + ).perform_with_content() + return resp + + @classmethod + async def update_async(cls, params: UpdateParams) -> Domain: + """ + Update an existing domain (async). + see more: https://resend.com/docs/api-reference/domains/update-domain + + Args: + params (UpdateParams): The domain update parameters + + Returns: + Domain: The updated domain object + """ + path = f"/domains/{params['id']}" + resp = await AsyncRequest[Domain]( + path=path, params=cast(Dict[Any, Any], params), verb="patch" + ).perform_with_content() + return resp + + @classmethod + async def get_async(cls, domain_id: str) -> Domain: + """ + Retrieve a single domain for the authenticated user (async). + see more: https://resend.com/docs/api-reference/domains/get-domain + + Args: + domain_id (str): The domain ID + + Returns: + Domain: The domain object + """ + path = f"/domains/{domain_id}" + resp = await AsyncRequest[Domain]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def list_async(cls) -> ListResponse: + """ + Retrieve a list of domains for the authenticated user (async). + see more: https://resend.com/docs/api-reference/domains/list-domains + + Returns: + ListResponse: A list of domain objects + """ + path = "/domains" + resp = await AsyncRequest[_ListResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def remove_async(cls, domain_id: str) -> Domain: + """ + Remove an existing domain (async). + see more: https://resend.com/docs/api-reference/domains/delete-domain + + Args: + domain_id (str): The domain ID + + Returns: + Domain: The removed domain object + """ + path = f"/domains/{domain_id}" + resp = await AsyncRequest[Domain]( + path=path, params={}, verb="delete" + ).perform_with_content() + return resp + + @classmethod + async def verify_async(cls, domain_id: str) -> Domain: + """ + Verify an existing domain (async). + see more: https://resend.com/docs/api-reference/domains/verify-domain + + Args: + domain_id (str): The domain ID + + Returns: + Domain: The verified domain object + """ + path = f"/domains/{domain_id}/verify" + resp = await AsyncRequest[Domain]( + path=path, params={}, verb="post" + ).perform_with_content() + return resp diff --git a/resend/emails/_batch.py b/resend/emails/_batch.py index a79c2f5..e572773 100644 --- a/resend/emails/_batch.py +++ b/resend/emails/_batch.py @@ -7,6 +7,12 @@ from ._email import Email from ._emails import Emails +# Async imports (optional - only available with pip install resend[async]) +try: + from resend.async_request import AsyncRequest +except ImportError: + pass + class _SendOptions(TypedDict): idempotency_key: NotRequired[str] @@ -68,3 +74,28 @@ def send( options=cast(Dict[Any, Any], options), ).perform_with_content() return resp + + @classmethod + async def send_async( + cls, params: List[Emails.SendParams], options: Optional[SendOptions] = None + ) -> SendResponse: + """ + Trigger up to 100 batch emails at once (async). + see more: https://resend.com/docs/api-reference/emails/send-batch-emails + + Args: + params (List[Emails.SendParams]): The list of emails to send + options (Optional[SendOptions]): Batch options, ie: idempotency_key + + Returns: + SendResponse: A list of email objects + """ + path = "/emails/batch" + + resp = await AsyncRequest[_SendResponse]( + path=path, + params=cast(List[Dict[Any, Any]], params), + verb="post", + options=cast(Dict[Any, Any], options), + ).perform_with_content() + return resp diff --git a/resend/emails/_emails.py b/resend/emails/_emails.py index b5dacc5..84b70f8 100644 --- a/resend/emails/_emails.py +++ b/resend/emails/_emails.py @@ -7,6 +7,12 @@ from resend.emails._email import Email from resend.emails._tag import Tag +# Async imports (optional - only available with pip install resend[async]) +try: + from resend.async_request import AsyncRequest +except ImportError: + pass + class _SendOptions(TypedDict): idempotency_key: NotRequired[str] @@ -248,3 +254,87 @@ def update(cls, params: UpdateParams) -> UpdateEmailResponse: verb="patch", ).perform_with_content() return resp + + @classmethod + async def send_async( + cls, params: SendParams, options: Optional[SendOptions] = None + ) -> Email: + """ + Send an email through the Resend Email API (async version). + see more: https://resend.com/docs/api-reference/emails/send-email + + Args: + params (SendParams): The email parameters + options (SendOptions): The email options + + Returns: + Email: The email object that was sent + """ + path = "/emails" + resp = await AsyncRequest[Email]( + path=path, + params=cast(Dict[Any, Any], params), + verb="post", + options=cast(Dict[Any, Any], options), + ).perform_with_content() + return resp + + @classmethod + async def get_async(cls, email_id: str) -> Email: + """ + Retrieve a single email (async version). + see more: https://resend.com/docs/api-reference/emails/retrieve-email + + Args: + email_id (str): The ID of the email to retrieve + + Returns: + Email: The email object that was retrieved + """ + path = f"/emails/{email_id}" + resp = await AsyncRequest[Email]( + path=path, + params={}, + verb="get", + ).perform_with_content() + return resp + + @classmethod + async def cancel_async(cls, email_id: str) -> CancelScheduledEmailResponse: + """ + Cancel a scheduled email (async version). + see more: https://resend.com/docs/api-reference/emails/cancel-email + + Args: + email_id (str): The ID of the scheduled email to cancel + + Returns: + CancelScheduledEmailResponse: The response object that contains the ID of the scheduled email that was canceled + """ + path = f"/emails/{email_id}/cancel" + resp = await AsyncRequest[_CancelScheduledEmailResponse]( + path=path, + params={}, + verb="post", + ).perform_with_content() + return resp + + @classmethod + async def update_async(cls, params: UpdateParams) -> UpdateEmailResponse: + """ + Update an email (async version). + see more: https://resend.com/docs/api-reference/emails/update-email + + Args: + params (UpdateParams): The email parameters to update + + Returns: + Email: The email object that was updated + """ + path = f"/emails/{params['id']}" + resp = await AsyncRequest[_UpdateEmailResponse]( + path=path, + params=cast(Dict[Any, Any], params), + verb="patch", + ).perform_with_content() + return resp diff --git a/resend/http_client_async.py b/resend/http_client_async.py new file mode 100644 index 0000000..4df324d --- /dev/null +++ b/resend/http_client_async.py @@ -0,0 +1,20 @@ +from abc import ABC, abstractmethod +from typing import Dict, List, Mapping, Optional, Tuple, Union + + +class AsyncHTTPClient(ABC): + """ + Abstract base class for async HTTP clients. + This class defines the interface for making async HTTP requests. + Subclasses should implement the `request` method. + """ + + @abstractmethod + async def request( + self, + method: str, + url: str, + headers: Mapping[str, str], + json: Optional[Union[Dict[str, object], List[object]]] = None, + ) -> Tuple[bytes, int, Mapping[str, str]]: + pass diff --git a/resend/http_client_httpx.py b/resend/http_client_httpx.py new file mode 100644 index 0000000..451096c --- /dev/null +++ b/resend/http_client_httpx.py @@ -0,0 +1,40 @@ +from typing import Dict, List, Mapping, Optional, Tuple, Union + +from resend.http_client_async import AsyncHTTPClient + +try: + import httpx +except ImportError: + raise ImportError( + "httpx is required for async support. Install it with: pip install resend[async]" + ) + + +class HTTPXClient(AsyncHTTPClient): + """ + Async HTTP client implementation using the httpx library. + """ + + def __init__(self, timeout: int = 30): + self._timeout = timeout + + async def request( + self, + method: str, + url: str, + headers: Mapping[str, str], + json: Optional[Union[Dict[str, object], List[object]]] = None, + ) -> Tuple[bytes, int, Mapping[str, str]]: + try: + async with httpx.AsyncClient(timeout=self._timeout) as client: + resp = await client.request( + method=method, + url=url, + headers=headers, + json=json, + ) + return resp.content, resp.status_code, resp.headers + except httpx.RequestError as e: + # This gets caught by the async request.perform() method + # and raises a ResendError with the error type "HttpClientError" + raise RuntimeError(f"Request failed: {e}") from e diff --git a/resend/request.py b/resend/request.py index 623dd25..879e978 100644 --- a/resend/request.py +++ b/resend/request.py @@ -71,7 +71,12 @@ def make_request(self, url: str) -> Union[Dict[str, Any], List[Any]]: json_params = None try: - content, _status_code, resp_headers = resend.default_http_client.request( + # Cast to HTTPClient for type checking - sync context expects sync client + from resend.http_client import HTTPClient + + sync_client = cast(HTTPClient, resend.default_http_client) + + content, _status_code, resp_headers = sync_client.request( method=self.verb, url=url, headers=headers, diff --git a/setup.py b/setup.py index 53f0587..f004035 100644 --- a/setup.py +++ b/setup.py @@ -18,6 +18,9 @@ packages=find_packages(exclude=["tests", "tests.*"]), package_data={"resend": ["py.typed"]}, install_requires=install_requires, + extras_require={ + "async": ["httpx>=0.24.0"], + }, zip_safe=False, python_requires=">=3.7", keywords=["email", "email platform"], diff --git a/tests/api_keys_async_test.py b/tests/api_keys_async_test.py new file mode 100644 index 0000000..220b046 --- /dev/null +++ b/tests/api_keys_async_test.py @@ -0,0 +1,65 @@ +import resend +from resend.exceptions import NoContentError +from tests.conftest import ResendBaseTest + +# flake8: noqa + + +class TestResendApiKeysAsync(ResendBaseTest): + async def test_api_keys_create_async(self) -> None: + self.set_mock_json( + { + "id": "dacf4072-4119-4d88-932f-6202748ac7c8", + "token": "re_c1tpEyD8_NKFusih9vKVQknRAQfmFcWCv", + } + ) + + params: resend.ApiKeys.CreateParams = { + "name": "prod", + } + key: resend.ApiKey = await resend.ApiKeys.create_async(params) + assert key["id"] == "dacf4072-4119-4d88-932f-6202748ac7c8" + + async def test_should_create_api_key_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + params: resend.ApiKeys.CreateParams = { + "name": "prod", + } + with self.assertRaises(NoContentError): + _ = await resend.ApiKeys.create_async(params) + + async def test_api_keys_list_async(self) -> None: + self.set_mock_json( + { + "data": [ + { + "id": "91f3200a-df72-4654-b0cd-f202395f5354", + "name": "Production", + "created_at": "2023-04-08T00:11:13.110779+00:00", + } + ] + } + ) + + keys: resend.ApiKeys.ListResponse = await resend.ApiKeys.list_async() + for key in keys["data"]: + assert key["id"] == "91f3200a-df72-4654-b0cd-f202395f5354" + assert key["name"] == "Production" + assert key["created_at"] == "2023-04-08T00:11:13.110779+00:00" + + async def test_should_list_api_key_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = await resend.ApiKeys.list_async() + + async def test_api_keys_remove_async(self) -> None: + self.set_mock_text("") + + # Remove operation returns None, verify no exceptions raised + await resend.ApiKeys.remove_async( + api_key_id="4ef9a417-02e9-4d39-ad75-9611e0fcc33c", + ) diff --git a/tests/audiences_async_test.py b/tests/audiences_async_test.py new file mode 100644 index 0000000..454ed25 --- /dev/null +++ b/tests/audiences_async_test.py @@ -0,0 +1,108 @@ +import resend +from resend.exceptions import NoContentError +from tests.conftest import ResendBaseTest + +# flake8: noqa + + +class TestResendAudiencesAsync(ResendBaseTest): + async def test_audiences_create_async(self) -> None: + self.set_mock_json( + { + "object": "audience", + "id": "78261eea-8f8b-4381-83c6-79fa7120f1cf", + "name": "Registered Users", + } + ) + + params: resend.Audiences.CreateParams = { + "name": "Python SDK Audience", + } + audience = await resend.Audiences.create_async(params) + assert audience["id"] == "78261eea-8f8b-4381-83c6-79fa7120f1cf" + assert audience["name"] == "Registered Users" + + async def test_should_create_audiences_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + params: resend.Audiences.CreateParams = { + "name": "Python SDK Audience", + } + with self.assertRaises(NoContentError): + _ = await resend.Audiences.create_async(params) + + async def test_audiences_get_async(self) -> None: + self.set_mock_json( + { + "object": "audience", + "id": "78261eea-8f8b-4381-83c6-79fa7120f1cf", + "name": "Registered Users", + "created_at": "2023-10-06T22:59:55.977Z", + } + ) + + audience = await resend.Audiences.get_async( + id="78261eea-8f8b-4381-83c6-79fa7120f1cf" + ) + assert audience["id"] == "78261eea-8f8b-4381-83c6-79fa7120f1cf" + assert audience["name"] == "Registered Users" + assert audience["created_at"] == "2023-10-06T22:59:55.977Z" + + async def test_should_get_audiences_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = await resend.Audiences.get_async( + id="78261eea-8f8b-4381-83c6-79fa7120f1cf" + ) + + async def test_audiences_remove_async(self) -> None: + self.set_mock_json( + { + "object": "audience", + "id": "78261eea-8f8b-4381-83c6-79fa7120f1cf", + "deleted": True, + } + ) + + rmed = await resend.Audiences.remove_async( + "78261eea-8f8b-4381-83c6-79fa7120f1cf" + ) + assert rmed["id"] == "78261eea-8f8b-4381-83c6-79fa7120f1cf" + assert rmed["deleted"] is True + + async def test_should_remove_audiences_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = await resend.Audiences.remove_async( + id="78261eea-8f8b-4381-83c6-79fa7120f1cf" + ) + + async def test_audiences_list_async(self) -> None: + self.set_mock_json( + { + "object": "list", + "data": [ + { + "id": "78261eea-8f8b-4381-83c6-79fa7120f1cf", + "name": "Registered Users", + "created_at": "2023-10-06T22:59:55.977Z", + } + ], + } + ) + + audiences: resend.Audiences.ListResponse = await resend.Audiences.list_async() + assert audiences["data"][0]["id"] == "78261eea-8f8b-4381-83c6-79fa7120f1cf" + assert audiences["data"][0]["name"] == "Registered Users" + + async def test_should_list_audiences_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = await resend.Audiences.list_async() diff --git a/tests/batch_emails_async_test.py b/tests/batch_emails_async_test.py new file mode 100644 index 0000000..3dc21ac --- /dev/null +++ b/tests/batch_emails_async_test.py @@ -0,0 +1,96 @@ +from typing import List + +import resend +from resend.exceptions import NoContentError +from tests.conftest import ResendBaseTest + +# flake8: noqa + + +class TestResendBatchSendAsync(ResendBaseTest): + async def test_batch_email_send_async(self) -> None: + self.set_mock_json( + { + "data": [ + {"id": "ae2014de-c168-4c61-8267-70d2662a1ce1"}, + {"id": "faccb7a5-8a28-4e9a-ac64-8da1cc3bc1cb"}, + ] + } + ) + + params: List[resend.Emails.SendParams] = [ + { + "from": "from@resend.dev", + "to": ["to@resend.dev"], + "subject": "hey", + "html": "hello, world!", + }, + { + "from": "from@resend.dev", + "to": ["to@resend.dev"], + "subject": "hello", + "html": "hello, world!", + }, + ] + + emails: resend.Batch.SendResponse = await resend.Batch.send_async(params) + assert len(emails["data"]) == 2 + assert emails["data"][0]["id"] == "ae2014de-c168-4c61-8267-70d2662a1ce1" + assert emails["data"][1]["id"] == "faccb7a5-8a28-4e9a-ac64-8da1cc3bc1cb" + + async def test_batch_email_send_async_with_options(self) -> None: + self.set_mock_json( + { + "data": [ + {"id": "ae2014de-c168-4c61-8267-70d2662a1ce1"}, + {"id": "faccb7a5-8a28-4e9a-ac64-8da1cc3bc1cb"}, + ] + } + ) + + params: List[resend.Emails.SendParams] = [ + { + "from": "from@resend.dev", + "to": ["to@resend.dev"], + "subject": "hey", + "html": "hello, world!", + }, + { + "from": "from@resend.dev", + "to": ["to@resend.dev"], + "subject": "hello", + "html": "hello, world!", + }, + ] + + options: resend.Batch.SendOptions = { + "idempotency_key": "af477dc78aa9fa91fff3b8c0d4a2e1a5", + } + + emails: resend.Batch.SendResponse = await resend.Batch.send_async( + params, options=options + ) + assert len(emails["data"]) == 2 + assert emails["data"][0]["id"] == "ae2014de-c168-4c61-8267-70d2662a1ce1" + assert emails["data"][1]["id"] == "faccb7a5-8a28-4e9a-ac64-8da1cc3bc1cb" + + async def test_should_send_batch_email_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + params: List[resend.Emails.SendParams] = [ + { + "from": "from@resend.dev", + "to": ["to@resend.dev"], + "subject": "hey", + "html": "hello, world!", + }, + { + "from": "from@resend.dev", + "to": ["to@resend.dev"], + "subject": "hello", + "html": "hello, world!", + }, + ] + with self.assertRaises(NoContentError): + _ = await resend.Batch.send_async(params) diff --git a/tests/broadcasts_async_test.py b/tests/broadcasts_async_test.py new file mode 100644 index 0000000..f49d06b --- /dev/null +++ b/tests/broadcasts_async_test.py @@ -0,0 +1,200 @@ +import resend +from resend.exceptions import NoContentError +from tests.conftest import ResendBaseTest + +# flake8: noqa + + +class TestResendBroadcastsAsync(ResendBaseTest): + async def test_broadcasts_create_async(self) -> None: + self.set_mock_json({"id": "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794"}) + + params: resend.Broadcasts.CreateParams = { + "audience_id": "78b8d3bc-a55a-45a3-aee6-6ec0a5e13d7e", + "from": "hi@example.com", + "subject": "Hello, world!", + "name": "Python SDK Broadcast", + } + broadcast: resend.Broadcasts.CreateResponse = ( + await resend.Broadcasts.create_async(params) + ) + assert broadcast["id"] == "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794" + + async def test_should_create_broadcasts_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + params: resend.Broadcasts.CreateParams = { + "audience_id": "78b8d3bc-a55a-45a3-aee6-6ec0a5e13d7e", + "from": "hi@example.com", + "subject": "Hello, world!", + "name": "Python SDK Broadcast", + } + with self.assertRaises(NoContentError): + _ = await resend.Broadcasts.create_async(params) + + async def test_broadcasts_update_async(self) -> None: + self.set_mock_json({"id": "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794"}) + + params: resend.Broadcasts.UpdateParams = { + "broadcast_id": "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794", + "audience_id": "78b8d3bc-a55a-45a3-aee6-6ec0a5e13d7e", + "subject": "Hello, world! Updated!", + "name": "Python SDK Broadcast", + } + broadcast: resend.Broadcasts.UpdateResponse = ( + await resend.Broadcasts.update_async(params) + ) + assert broadcast["id"] == "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794" + + async def test_should_update_broadcasts_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + params: resend.Broadcasts.UpdateParams = { + "broadcast_id": "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794", + "audience_id": "78b8d3bc-a55a-45a3-aee6-6ec0a5e13d7e", + "subject": "Hello, world! Updated!", + "name": "Python SDK Broadcast", + } + with self.assertRaises(NoContentError): + _ = await resend.Broadcasts.update_async(params) + + async def test_broadcasts_get_async(self) -> None: + self.set_mock_json( + { + "object": "broadcast", + "id": "559ac32e-9ef5-46fb-82a1-b76b840c0f7b", + "name": "Announcements", + "audience_id": "78261eea-8f8b-4381-83c6-79fa7120f1cf", + "from": "Acme