diff --git a/examples/contact_imports.py b/examples/contact_imports.py new file mode 100644 index 0000000..e2857e9 --- /dev/null +++ b/examples/contact_imports.py @@ -0,0 +1,53 @@ +import os +import time + +import resend + +if not os.environ["RESEND_API_KEY"]: + raise EnvironmentError("RESEND_API_KEY is missing") + +ts = int(time.time()) +csv_path = os.path.join(os.path.dirname(__file__), "contacts.csv") +with open(csv_path, "rb") as f: + file_content = f.read().replace(b"steve@example.com,Steve,Wozniak", f"steve+{ts}@example.com,Steve,Wozniak".encode()) + +create_params: resend.ContactImports.CreateParams = { + "file": file_content, + "column_map": { + "email": "Email", + "first_name": "First Name", + "last_name": "Last Name", + "properties": { + "plan": { + "column": "Plan", + "type": "string", + }, + }, + }, + "on_conflict": "upsert", + "segments": ["60a2ac5e-0774-456e-817d-ebf40f6dba31"], + "topics": [ + { + "id": "6eb54030-9489-4e9c-8de6-cd337c5fef1e", + "subscription": "opt_in", + }, + ], +} + +import_response: resend.ContactImports.CreateContactImportResponse = ( + resend.Contacts.Imports.create(create_params) +) +print("Created contact import with ID: {}".format(import_response["id"])) +print(import_response) + +contact_import: resend.ContactImport = resend.Contacts.Imports.get(import_response["id"]) +print("Retrieved contact import") +print(contact_import) + +list_response: resend.ContactImports.ListContactImportsResponse = ( + resend.Contacts.Imports.list() +) +print(f"Found {len(list_response['data'])} imports") +print(f"Has more: {list_response['has_more']}") +for item in list_response["data"]: + print(item) diff --git a/examples/contacts.csv b/examples/contacts.csv new file mode 100644 index 0000000..798dbf4 --- /dev/null +++ b/examples/contacts.csv @@ -0,0 +1,2 @@ +Email,First Name,Last Name,Plan +steve@example.com,Steve,Wozniak,pro diff --git a/examples/with_custom_http_client.py b/examples/with_custom_http_client.py index 71f980c..adeb133 100644 --- a/examples/with_custom_http_client.py +++ b/examples/with_custom_http_client.py @@ -21,6 +21,8 @@ def request( url: str, headers: Mapping[str, str], json: Optional[Union[Dict[str, Any], List[Any]]] = None, + files: Optional[Dict[str, Any]] = None, + data: Optional[Dict[str, str]] = None, ) -> Tuple[bytes, int, Dict[str, str]]: print(f"[HTTP] {method.upper()} {url} with timeout={self.timeout}") try: @@ -28,7 +30,9 @@ def request( method=method, url=url, headers=headers, - json=json, + json=json if data is None and files is None else None, + files=files, + data=data, timeout=self.timeout, ) return ( diff --git a/resend/__init__.py b/resend/__init__.py index 32e3fa7..7b4b4e8 100644 --- a/resend/__init__.py +++ b/resend/__init__.py @@ -22,6 +22,8 @@ from .contacts._contact_topic import ContactTopic, TopicSubscriptionUpdate from .contacts._contacts import Contacts from .contacts._topics import Topics as ContactsTopics +from .contacts.imports._contact_import import ContactImport, ContactImportCounts +from .contacts.imports._contact_imports import ContactImports from .contacts.segments._contact_segment import ContactSegment from .contacts.segments._contact_segments import ContactSegments from .domains._domain import Domain @@ -83,6 +85,7 @@ "Audiences", "Automations", "Contacts", + "ContactImports", "ContactProperties", "Broadcasts", "Events", @@ -110,6 +113,8 @@ "EventSchema", "EventSchemaFieldType", "Contact", + "ContactImport", + "ContactImportCounts", "ContactSegment", "ContactSegments", "ContactProperty", diff --git a/resend/async_request.py b/resend/async_request.py index ad8dcd6..3b792ed 100644 --- a/resend/async_request.py +++ b/resend/async_request.py @@ -24,11 +24,15 @@ def __init__( params: ParamsType, verb: RequestVerb, options: Optional[Dict[str, Any]] = None, + files: Optional[Dict[str, Any]] = None, + data: Optional[Dict[str, str]] = None, ): self.path = path self.params = params self.verb = verb self.options = options + self.files = files + self.data = data self._response_headers: Dict[str, str] = {} async def perform(self) -> Union[T, None]: @@ -97,12 +101,18 @@ async def make_request(self, url: str) -> Union[Dict[str, Any], List[Any]]: suggested_action="Run: pip install resend[async]", ) - content, _status_code, resp_headers = await async_client.request( - method=self.verb, - url=url, - headers=headers, - json=json_params, - ) + kwargs: Dict[str, Any] = { + "method": self.verb, + "url": url, + "headers": headers, + "json": json_params, + } + if self.files is not None: + kwargs["files"] = self.files + if self.data is not None: + kwargs["data"] = self.data + + content, _status_code, resp_headers = await async_client.request(**kwargs) # Safety net around the HTTP Client except ResendError: diff --git a/resend/contacts/__init__.py b/resend/contacts/__init__.py index e69de29..b02803d 100644 --- a/resend/contacts/__init__.py +++ b/resend/contacts/__init__.py @@ -0,0 +1,4 @@ +from .imports._contact_import import ContactImport, ContactImportCounts +from .imports._contact_imports import ContactImports + +__all__ = ["ContactImports", "ContactImport", "ContactImportCounts"] diff --git a/resend/contacts/_contacts.py b/resend/contacts/_contacts.py index 3650a6c..5be40fd 100644 --- a/resend/contacts/_contacts.py +++ b/resend/contacts/_contacts.py @@ -8,6 +8,7 @@ from ._contact import Contact from ._topics import Topics +from .imports._contact_imports import ContactImports from .segments._contact_segments import ContactSegments # Async imports (optional - only available with pip install resend[async]) @@ -21,6 +22,7 @@ class Contacts: # Sub-API for managing contact-segment associations Segments = ContactSegments Topics = Topics + Imports = ContactImports class RemoveContactResponse(BaseResponse): """ diff --git a/resend/contacts/imports/__init__.py b/resend/contacts/imports/__init__.py new file mode 100644 index 0000000..2229463 --- /dev/null +++ b/resend/contacts/imports/__init__.py @@ -0,0 +1,4 @@ +from ._contact_import import ContactImport, ContactImportCounts +from ._contact_imports import ContactImports + +__all__ = ["ContactImports", "ContactImport", "ContactImportCounts"] diff --git a/resend/contacts/imports/_contact_import.py b/resend/contacts/imports/_contact_import.py new file mode 100644 index 0000000..992d48d --- /dev/null +++ b/resend/contacts/imports/_contact_import.py @@ -0,0 +1,44 @@ +from typing import Optional + +from typing_extensions import NotRequired + +from resend._base_response import BaseResponse + + +class ContactImportCounts(BaseResponse): + """ + ContactImportCounts holds row-level statistics for a contact import. + + Attributes: + total (int): Total number of rows processed. + created (int): Number of contacts created. + updated (int): Number of contacts updated. + skipped (int): Number of rows skipped. + failed (int): Number of rows that failed. + """ + + total: int + created: int + updated: int + skipped: int + failed: int + + +class ContactImport(BaseResponse): + """ + ContactImport represents a contact import job. + + Attributes: + object (str): Always 'contact_import'. + id (str): Unique identifier for the contact import. + status (str): 'queued', 'in_progress', 'completed', or 'failed'. + created_at (str): ISO 8601 timestamp of when the import was created. + counts (ContactImportCounts): Row-level import statistics (present when status is completed or failed). + """ + + object: str + id: str + status: str + created_at: str + completed_at: Optional[str] + counts: NotRequired[ContactImportCounts] diff --git a/resend/contacts/imports/_contact_imports.py b/resend/contacts/imports/_contact_imports.py new file mode 100644 index 0000000..0b3b392 --- /dev/null +++ b/resend/contacts/imports/_contact_imports.py @@ -0,0 +1,244 @@ +import json as json_lib +from typing import Any, Dict, List, Optional, Tuple, cast + +from typing_extensions import Literal, NotRequired, TypedDict + +from resend import request +from resend._base_response import BaseResponse +from resend.pagination_helper import PaginationHelper + +from ._contact_import import ContactImport + +try: + from resend.async_request import AsyncRequest +except ImportError: + pass + + +class ContactImports: + class CreateContactImportResponse(BaseResponse): + """ + CreateContactImportResponse wraps the response from a successful import creation. + + Attributes: + object (str): Always 'contact_import'. + id (str): Unique identifier for the created import job. + """ + + object: str + id: str + + class ListContactImportsResponse(BaseResponse): + """ + ListContactImportsResponse wraps a paginated list of contact imports. + + Attributes: + object (str): Always 'list'. + data (List[ContactImport]): The list of import objects. + has_more (bool): Whether additional pages of results exist. + """ + + object: str + data: List[ContactImport] + has_more: bool + + class CreateParams(TypedDict): + file: bytes + """ + CSV file content to import. Maximum size is 50MB. (required) + """ + filename: NotRequired[str] + """ + Filename used in the multipart upload. Defaults to 'import.csv'. + """ + column_map: NotRequired[Dict[str, Any]] + """ + Maps contact fields and custom property keys to CSV column names. + Will be JSON-encoded before sending. + Example: {"email": "Email", "first_name": "First Name"} + """ + on_conflict: NotRequired[Literal["upsert", "skip"]] + """ + Strategy when an imported contact already exists: 'upsert' or 'skip' (default 'skip'). + """ + segments: NotRequired[List[str]] + """ + List of segment IDs to add imported contacts to. + Will be serialized as [{"id": "..."}] before sending. + """ + topics: NotRequired[List[Dict[str, str]]] + """ + List of topic subscriptions for imported contacts. + Each entry must have 'id' and 'subscription' ('opt_in' or 'opt_out'). + """ + + class ListParams(TypedDict): + status: NotRequired[Literal["queued", "in_progress", "completed", "failed"]] + """ + Filter imports by status. + """ + limit: NotRequired[int] + """ + Number of imports to retrieve. Maximum is 100. + """ + after: NotRequired[str] + """ + Cursor for forward pagination (exclusive). + """ + before: NotRequired[str] + """ + Cursor for backward pagination (exclusive). + """ + + @staticmethod + def _build_multipart( + params: "ContactImports.CreateParams", + ) -> Tuple[Dict[str, Any], Dict[str, str]]: + if not params.get("file"): + raise ValueError("file is required") + filename = params.get("filename", "import.csv") + files: Dict[str, Any] = { + "file": (filename, params["file"], "text/csv"), + } + form_data: Dict[str, str] = {} + if "column_map" in params: + form_data["column_map"] = json_lib.dumps(params["column_map"]) + if "on_conflict" in params: + form_data["on_conflict"] = params["on_conflict"] + if "segments" in params: + form_data["segments"] = json_lib.dumps( + [{"id": sid} for sid in params["segments"]] + ) + if "topics" in params: + form_data["topics"] = json_lib.dumps(params["topics"]) + return files, form_data + + @classmethod + def create(cls, params: CreateParams) -> CreateContactImportResponse: + """ + Create a new contact import from a CSV file. + see more: https://resend.com/docs/api-reference/contacts/create-contact-import + + Args: + params (CreateParams): Import parameters including the CSV file content. + + Returns: + CreateContactImportResponse: The created import job with its ID. + """ + files, form_data = cls._build_multipart(params) + resp = request.Request[ContactImports.CreateContactImportResponse]( + path="/contacts/imports", + params={}, + verb="post", + files=files, + data=form_data, + ).perform_with_content() + return resp + + @classmethod + def get(cls, id: str) -> ContactImport: + """ + Retrieve a single contact import by ID. + see more: https://resend.com/docs/api-reference/contacts/get-contact-import + + Args: + id (str): The contact import ID. + + Returns: + ContactImport: The contact import object. + """ + if not id: + raise ValueError("id is required") + + resp = request.Request[ContactImport]( + path=f"/contacts/imports/{id}", + params={}, + verb="get", + ).perform_with_content() + return resp + + @classmethod + def list(cls, params: Optional[ListParams] = None) -> ListContactImportsResponse: + """ + Retrieve a list of contact imports. + see more: https://resend.com/docs/api-reference/contacts/list-contact-imports + + Args: + params (Optional[ListParams]): Optional filtering and pagination parameters. + + Returns: + ListContactImportsResponse: Paginated list of contact import objects. + """ + query_params = cast(Dict[Any, Any], params) if params else None + path = PaginationHelper.build_paginated_path("/contacts/imports", query_params) + resp = request.Request[ContactImports.ListContactImportsResponse]( + path=path, + params={}, + verb="get", + ).perform_with_content() + return resp + + @classmethod + async def create_async(cls, params: CreateParams) -> CreateContactImportResponse: + """ + Create a new contact import from a CSV file (async). + see more: https://resend.com/docs/api-reference/contacts/create-contact-import + + Args: + params (CreateParams): Import parameters including the CSV file content. + + Returns: + CreateContactImportResponse: The created import job with its ID. + """ + files, form_data = cls._build_multipart(params) + resp = await AsyncRequest[ContactImports.CreateContactImportResponse]( + path="/contacts/imports", + params={}, + verb="post", + files=files, + data=form_data, + ).perform_with_content() + return resp + + @classmethod + async def get_async(cls, id: str) -> ContactImport: + """ + Retrieve a single contact import by ID (async). + see more: https://resend.com/docs/api-reference/contacts/get-contact-import + + Args: + id (str): The contact import ID. + + Returns: + ContactImport: The contact import object. + """ + if not id: + raise ValueError("id is required") + + resp = await AsyncRequest[ContactImport]( + path=f"/contacts/imports/{id}", + params={}, + verb="get", + ).perform_with_content() + return resp + + @classmethod + async def list_async(cls, params: Optional[ListParams] = None) -> ListContactImportsResponse: + """ + Retrieve a list of contact imports (async). + see more: https://resend.com/docs/api-reference/contacts/list-contact-imports + + Args: + params (Optional[ListParams]): Optional filtering and pagination parameters. + + Returns: + ListContactImportsResponse: Paginated list of contact import objects. + """ + query_params = cast(Dict[Any, Any], params) if params else None + path = PaginationHelper.build_paginated_path("/contacts/imports", query_params) + resp = await AsyncRequest[ContactImports.ListContactImportsResponse]( + path=path, + params={}, + verb="get", + ).perform_with_content() + return resp diff --git a/resend/http_client.py b/resend/http_client.py index 0863420..ceb5f1d 100644 --- a/resend/http_client.py +++ b/resend/http_client.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Dict, List, Mapping, Optional, Tuple, Union +from typing import Any, Dict, List, Mapping, Optional, Tuple, Union class HTTPClient(ABC): @@ -16,5 +16,7 @@ def request( url: str, headers: Mapping[str, str], json: Optional[Union[Dict[str, object], List[object]]] = None, + files: Optional[Dict[str, Any]] = None, + data: Optional[Dict[str, str]] = None, ) -> Tuple[bytes, int, Mapping[str, str]]: pass diff --git a/resend/http_client_async.py b/resend/http_client_async.py index 4df324d..b7eedf0 100644 --- a/resend/http_client_async.py +++ b/resend/http_client_async.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Dict, List, Mapping, Optional, Tuple, Union +from typing import Any, Dict, List, Mapping, Optional, Tuple, Union class AsyncHTTPClient(ABC): @@ -16,5 +16,7 @@ async def request( url: str, headers: Mapping[str, str], json: Optional[Union[Dict[str, object], List[object]]] = None, + files: Optional[Dict[str, Any]] = None, + data: Optional[Dict[str, str]] = None, ) -> Tuple[bytes, int, Mapping[str, str]]: pass diff --git a/resend/http_client_httpx.py b/resend/http_client_httpx.py index 8ff2373..32ea193 100644 --- a/resend/http_client_httpx.py +++ b/resend/http_client_httpx.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Mapping, Optional, Tuple, Union +from typing import Any, Dict, List, Mapping, Optional, Tuple, Union import httpx @@ -19,15 +19,27 @@ async def request( url: str, headers: Mapping[str, str], json: Optional[Union[Dict[str, object], List[object]]] = None, + files: Optional[Dict[str, Any]] = None, + data: Optional[Dict[str, str]] = 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, - ) + if files is not None: + resp = await client.request( + method=method, + url=url, + headers=headers, + files=files, + data=data, + ) + else: + resp = await client.request( + method=method, + url=url, + headers=headers, + json=json if data is None else None, + data=data, + ) return resp.content, resp.status_code, resp.headers except httpx.RequestError as e: # This gets caught by the async request.perform() method diff --git a/resend/http_client_requests.py b/resend/http_client_requests.py index 6c78a2c..8caf308 100644 --- a/resend/http_client_requests.py +++ b/resend/http_client_requests.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Mapping, Optional, Tuple, Union +from typing import Any, Dict, List, Mapping, Optional, Tuple, Union import requests @@ -19,15 +19,28 @@ def request( url: str, headers: Mapping[str, str], json: Optional[Union[Dict[str, object], List[object]]] = None, + files: Optional[Dict[str, Any]] = None, + data: Optional[Dict[str, str]] = None, ) -> Tuple[bytes, int, Mapping[str, str]]: try: - resp = requests.request( - method=method, - url=url, - headers=headers, - json=json, - timeout=self._timeout, - ) + if files is not None: + resp = requests.request( + method=method, + url=url, + headers=headers, + files=files, + data=data, + timeout=self._timeout, + ) + else: + resp = requests.request( + method=method, + url=url, + headers=headers, + json=json if data is None else None, + data=data, + timeout=self._timeout, + ) return resp.content, resp.status_code, resp.headers except requests.RequestException as e: # This gets caught by the request.perform() method diff --git a/resend/request.py b/resend/request.py index 7b4fa74..7e3bcef 100644 --- a/resend/request.py +++ b/resend/request.py @@ -23,11 +23,15 @@ def __init__( params: ParamsType, verb: RequestVerb, options: Optional[Dict[str, Any]] = None, + files: Optional[Dict[str, Any]] = None, + data: Optional[Dict[str, str]] = None, ): self.path = path self.params = params self.verb = verb self.options = options + self.files = files + self.data = data self._response_headers: Dict[str, str] = {} def perform(self) -> Union[T, None]: @@ -84,12 +88,18 @@ def make_request(self, url: str) -> Union[Dict[str, Any], List[Any]]: sync_client = cast(HTTPClient, resend.default_http_client) - content, _status_code, resp_headers = sync_client.request( - method=self.verb, - url=url, - headers=headers, - json=json_params, - ) + kwargs: Dict[str, Any] = { + "method": self.verb, + "url": url, + "headers": headers, + "json": json_params, + } + if self.files is not None: + kwargs["files"] = self.files + if self.data is not None: + kwargs["data"] = self.data + + content, _status_code, resp_headers = sync_client.request(**kwargs) # Safety net around the HTTP Client except Exception as e: diff --git a/resend/version.py b/resend/version.py index d00b6d1..ca53631 100644 --- a/resend/version.py +++ b/resend/version.py @@ -1,4 +1,4 @@ -__version__ = "2.31.0" +__version__ = "2.32.0" def get_version() -> str: diff --git a/tests/contact_imports_test.py b/tests/contact_imports_test.py new file mode 100644 index 0000000..de26645 --- /dev/null +++ b/tests/contact_imports_test.py @@ -0,0 +1,105 @@ +import resend +from tests.conftest import ResendBaseTest + +# flake8: noqa + + +class TestContactImports(ResendBaseTest): + def test_create_contact_import(self) -> None: + self.set_mock_json( + {"object": "contact_import", "id": "479e3145-dd38-476b-932c-529ceb705947"} + ) + + params: resend.ContactImports.CreateParams = { + "file": b"email,first_name\nsteve@example.com,Steve", + } + resp: resend.ContactImports.CreateContactImportResponse = resend.Contacts.Imports.create(params) + assert resp["id"] == "479e3145-dd38-476b-932c-529ceb705947" + assert resp["object"] == "contact_import" + + def test_create_contact_import_with_options(self) -> None: + self.set_mock_json( + {"object": "contact_import", "id": "479e3145-dd38-476b-932c-529ceb705947"} + ) + + params: resend.ContactImports.CreateParams = { + "file": b"email,first_name\nsteve@example.com,Steve", + "filename": "contacts.csv", + "on_conflict": "upsert", + "column_map": {"email": "email", "first_name": "first_name"}, + "segments": ["seg-123"], + } + resp = resend.Contacts.Imports.create(params) + assert resp["id"] == "479e3145-dd38-476b-932c-529ceb705947" + + def test_create_contact_import_missing_file(self) -> None: + with self.assertRaises(ValueError) as ctx: + resend.Contacts.Imports.create({"file": b""}) + assert str(ctx.exception) == "file is required" + + def test_get_contact_import(self) -> None: + self.set_mock_json( + { + "object": "contact_import", + "id": "479e3145-dd38-476b-932c-529ceb705947", + "status": "completed", + "created_at": "2023-10-06T23:47:56.678Z", + "counts": { + "total": 100, + "created": 80, + "updated": 10, + "skipped": 5, + "failed": 5, + }, + } + ) + + result: resend.ContactImport = resend.Contacts.Imports.get( + "479e3145-dd38-476b-932c-529ceb705947" + ) + assert result["id"] == "479e3145-dd38-476b-932c-529ceb705947" + assert result["status"] == "completed" + counts = result["counts"] + assert counts is not None + assert counts["total"] == 100 + + def test_get_contact_import_missing_id(self) -> None: + with self.assertRaises(ValueError) as ctx: + resend.Contacts.Imports.get("") + assert str(ctx.exception) == "id is required" + + def test_list_contact_imports(self) -> None: + self.set_mock_json( + { + "object": "list", + "has_more": False, + "data": [ + { + "object": "contact_import", + "id": "479e3145-dd38-476b-932c-529ceb705947", + "status": "completed", + "created_at": "2023-10-06T23:47:56.678Z", + } + ], + } + ) + + result: resend.ContactImports.ListContactImportsResponse = resend.Contacts.Imports.list() + assert result["object"] == "list" + assert result["has_more"] is False + assert len(result["data"]) == 1 + assert result["data"][0]["id"] == "479e3145-dd38-476b-932c-529ceb705947" + + def test_list_contact_imports_with_filters(self) -> None: + self.set_mock_json( + { + "object": "list", + "has_more": False, + "data": [], + } + ) + + result = resend.Contacts.Imports.list( + {"status": "completed", "limit": 10} + ) + assert result["object"] == "list"