From 2a7b3818b454e3d7f6820b5869c2636e88589a3f Mon Sep 17 00:00:00 2001 From: drish Date: Tue, 31 Mar 2026 10:16:27 -0300 Subject: [PATCH 1/4] feat(logs): add logs API support --- .gitignore | 3 + examples/async/logs_async.py | 45 ++++++++ examples/logs.py | 39 +++++++ resend/__init__.py | 4 + resend/logs/__init__.py | 4 + resend/logs/_log.py | 38 +++++++ resend/logs/_logs.py | 193 +++++++++++++++++++++++++++++++++++ tests/logs_async_test.py | 97 ++++++++++++++++++ tests/logs_test.py | 134 ++++++++++++++++++++++++ 9 files changed, 557 insertions(+) create mode 100644 examples/async/logs_async.py create mode 100644 examples/logs.py create mode 100644 resend/logs/__init__.py create mode 100644 resend/logs/_log.py create mode 100644 resend/logs/_logs.py create mode 100644 tests/logs_async_test.py create mode 100644 tests/logs_test.py diff --git a/.gitignore b/.gitignore index 2ebbdf3..a740508 100644 --- a/.gitignore +++ b/.gitignore @@ -901,3 +901,6 @@ FodyWeavers.xsd # End of https://www.toptal.com/developers/gitignore/api/macos,linux,windows,python,jupyternotebooks,jetbrains,pycharm,vim,emacs,visualstudiocode,visualstudio scratch/ + +# Allow resend/logs module (overrides [Ll]ogs/ rule above) +!resend/logs/ diff --git a/examples/async/logs_async.py b/examples/async/logs_async.py new file mode 100644 index 0000000..327ed86 --- /dev/null +++ b/examples/async/logs_async.py @@ -0,0 +1,45 @@ +import asyncio +import os + +import resend + +if not os.environ["RESEND_API_KEY"]: + raise EnvironmentError("RESEND_API_KEY is missing") + + +async def main() -> None: + logs: resend.Logs.ListResponse = await resend.Logs.list_async() + for log in logs["data"]: + print(log["id"]) + print(log["endpoint"]) + print(log["method"]) + print(log["response_status"]) + print(log["created_at"]) + + print("\n--- Using pagination parameters ---") + if logs["data"]: + paginated_params: resend.Logs.ListParams = { + "limit": 10, + "after": logs["data"][0]["id"], + } + paginated_logs: resend.Logs.ListResponse = await resend.Logs.list_async( + params=paginated_params + ) + print(f"Retrieved {len(paginated_logs['data'])} logs with pagination") + print(f"Has more logs: {paginated_logs['has_more']}") + else: + print("No logs available for pagination example") + + print("\n--- Retrieve a single log ---") + if logs["data"]: + log_id = logs["data"][0]["id"] + single_log: resend.Logs.GetResponse = await resend.Logs.get_async(log_id) + print(f"Log ID: {single_log['id']}") + print(f"Endpoint: {single_log['endpoint']}") + print(f"Method: {single_log['method']}") + print(f"Status: {single_log['response_status']}") + print(f"Request body: {single_log['request_body']}") + print(f"Response body: {single_log['response_body']}") + + +asyncio.run(main()) diff --git a/examples/logs.py b/examples/logs.py new file mode 100644 index 0000000..4e091a6 --- /dev/null +++ b/examples/logs.py @@ -0,0 +1,39 @@ +import os + +import resend + +if not os.environ["RESEND_API_KEY"]: + raise EnvironmentError("RESEND_API_KEY is missing") + +logs: resend.Logs.ListResponse = resend.Logs.list() +for log in logs["data"]: + print(log["id"]) + print(log["endpoint"]) + print(log["method"]) + print(log["response_status"]) + print(log["created_at"]) + +print("\n--- Using pagination parameters ---") +if logs["data"]: + paginated_params: resend.Logs.ListParams = { + "limit": 10, + "after": logs["data"][0]["id"], + } + paginated_logs: resend.Logs.ListResponse = resend.Logs.list( + params=paginated_params + ) + print(f"Retrieved {len(paginated_logs['data'])} logs with pagination") + print(f"Has more logs: {paginated_logs['has_more']}") +else: + print("No logs available for pagination example") + +print("\n--- Retrieve a single log ---") +if logs["data"]: + log_id = logs["data"][0]["id"] + single_log: resend.Logs.GetResponse = resend.Logs.get(log_id) + print(f"Log ID: {single_log['id']}") + print(f"Endpoint: {single_log['endpoint']}") + print(f"Method: {single_log['method']}") + print(f"Status: {single_log['response_status']}") + print(f"Request body: {single_log['request_body']}") + print(f"Response body: {single_log['response_body']}") diff --git a/resend/__init__.py b/resend/__init__.py index 3a695a7..2bce0f8 100644 --- a/resend/__init__.py +++ b/resend/__init__.py @@ -17,6 +17,8 @@ from .contacts.segments._contact_segments import ContactSegments from .domains._domain import Domain from .domains._domains import Domains +from .logs._log import Log +from .logs._logs import Logs from .emails._attachment import Attachment, RemoteAttachment from .emails._attachments import Attachments as EmailAttachments from .emails._batch import Batch, BatchValidationError @@ -74,6 +76,7 @@ "Templates", "Webhooks", "Topics", + "Logs", # Types "Audience", "Contact", @@ -84,6 +87,7 @@ "TopicSubscriptionUpdate", "Domain", "ApiKey", + "Log", "Email", "Attachment", "RemoteAttachment", diff --git a/resend/logs/__init__.py b/resend/logs/__init__.py new file mode 100644 index 0000000..6689bb1 --- /dev/null +++ b/resend/logs/__init__.py @@ -0,0 +1,4 @@ +from resend.logs._log import Log +from resend.logs._logs import Logs + +__all__ = ["Log", "Logs"] diff --git a/resend/logs/_log.py b/resend/logs/_log.py new file mode 100644 index 0000000..7863741 --- /dev/null +++ b/resend/logs/_log.py @@ -0,0 +1,38 @@ +from typing import Any + +from typing_extensions import NotRequired, TypedDict + + +class Log(TypedDict): + id: str + """ + The log ID + """ + created_at: str + """ + The date and time the log was created + """ + endpoint: str + """ + The API endpoint that was called + """ + method: str + """ + The HTTP method used + """ + response_status: int + """ + The HTTP response status code + """ + user_agent: str + """ + The user agent of the client + """ + request_body: NotRequired[Any] + """ + The original request body (only present when retrieving a single log) + """ + response_body: NotRequired[Any] + """ + The API response body (only present when retrieving a single log) + """ diff --git a/resend/logs/_logs.py b/resend/logs/_logs.py new file mode 100644 index 0000000..9273a7a --- /dev/null +++ b/resend/logs/_logs.py @@ -0,0 +1,193 @@ +from typing import Any, Dict, List, Optional, cast + +from typing_extensions import NotRequired, TypedDict + +from resend import request +from resend._base_response import BaseResponse +from resend.logs._log import Log +from resend.pagination_helper import PaginationHelper + +# Async imports (optional - only available with pip install resend[async]) +try: + from resend.async_request import AsyncRequest +except ImportError: + pass + + +class Logs: + + class GetResponse(BaseResponse): + """ + GetResponse type that wraps a single log object + + Attributes: + object (str): The object type, always "log" + id (str): The log ID + created_at (str): The date and time the log was created + endpoint (str): The API endpoint that was called + method (str): The HTTP method used + response_status (int): The HTTP response status code + user_agent (str): The user agent of the client + request_body (Any): The original request body + response_body (Any): The API response body + """ + + object: str + """ + The object type, always "log" + """ + id: str + """ + The log ID + """ + created_at: str + """ + The date and time the log was created + """ + endpoint: str + """ + The API endpoint that was called + """ + method: str + """ + The HTTP method used + """ + response_status: int + """ + The HTTP response status code + """ + user_agent: str + """ + The user agent of the client + """ + request_body: Any + """ + The original request body + """ + response_body: Any + """ + The API response body + """ + + class ListResponse(BaseResponse): + """ + ListResponse type that wraps a list of log objects with pagination metadata + + Attributes: + object (str): The object type, always "list" + data (List[Log]): A list of log objects + has_more (bool): Whether there are more results available + """ + + object: str + """ + The object type, always "list" + """ + data: List[Log] + """ + A list of log objects + """ + has_more: bool + """ + Whether there are more results available for pagination + """ + + class ListParams(TypedDict): + limit: NotRequired[int] + """ + Number of logs to retrieve. Maximum is 100, and minimum is 1. + """ + after: NotRequired[str] + """ + The ID after which we'll retrieve more logs (for pagination). + This ID will not be included in the returned list. + Cannot be used with the before parameter. + """ + before: NotRequired[str] + """ + The ID before which we'll retrieve more logs (for pagination). + This ID will not be included in the returned list. + Cannot be used with the after parameter. + """ + + @classmethod + def get(cls, log_id: str) -> GetResponse: + """ + Retrieve a single log by its ID. + see more: https://resend.com/docs/api-reference/logs/retrieve-log + + Args: + log_id (str): The ID of the log to retrieve + + Returns: + GetResponse: The log object + """ + path = f"/logs/{log_id}" + resp = request.Request[Logs.GetResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + def list(cls, params: Optional[ListParams] = None) -> ListResponse: + """ + Retrieve a list of logs. + see more: https://resend.com/docs/api-reference/logs/list-logs + + Args: + params (Optional[ListParams]): Optional pagination parameters + - limit: Number of logs to retrieve (max 100, min 1) + - after: ID after which to retrieve more logs + - before: ID before which to retrieve more logs + + Returns: + ListResponse: A list of log objects + """ + base_path = "/logs" + query_params = cast(Dict[Any, Any], params) if params else None + path = PaginationHelper.build_paginated_path(base_path, query_params) + resp = request.Request[Logs.ListResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def get_async(cls, log_id: str) -> GetResponse: + """ + Retrieve a single log by its ID (async). + see more: https://resend.com/docs/api-reference/logs/retrieve-log + + Args: + log_id (str): The ID of the log to retrieve + + Returns: + GetResponse: The log object + """ + path = f"/logs/{log_id}" + resp = await AsyncRequest[Logs.GetResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def list_async(cls, params: Optional[ListParams] = None) -> ListResponse: + """ + Retrieve a list of logs (async). + see more: https://resend.com/docs/api-reference/logs/list-logs + + Args: + params (Optional[ListParams]): Optional pagination parameters + - limit: Number of logs to retrieve (max 100, min 1) + - after: ID after which to retrieve more logs + - before: ID before which to retrieve more logs + + Returns: + ListResponse: A list of log objects + """ + base_path = "/logs" + query_params = cast(Dict[Any, Any], params) if params else None + path = PaginationHelper.build_paginated_path(base_path, query_params) + resp = await AsyncRequest[Logs.ListResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp diff --git a/tests/logs_async_test.py b/tests/logs_async_test.py new file mode 100644 index 0000000..3883454 --- /dev/null +++ b/tests/logs_async_test.py @@ -0,0 +1,97 @@ +import pytest + +import resend +from resend.exceptions import NoContentError +from tests.conftest import AsyncResendBaseTest + +# flake8: noqa + +pytestmark = pytest.mark.asyncio + + +class TestResendLogsAsync(AsyncResendBaseTest): + async def test_logs_get_async(self) -> None: + self.set_mock_json( + { + "object": "log", + "id": "37e4414c-5e25-4dbc-a071-43552a4bd53b", + "created_at": "2024-01-01T00:00:00.000000+00:00", + "endpoint": "/emails", + "method": "POST", + "response_status": 200, + "user_agent": "resend-python/2.0.0", + "request_body": {"to": ["user@example.com"], "subject": "Hello"}, + "response_body": {"id": "email-id-123"}, + } + ) + + log: resend.Logs.GetResponse = await resend.Logs.get_async( + "37e4414c-5e25-4dbc-a071-43552a4bd53b" + ) + assert log["object"] == "log" + assert log["id"] == "37e4414c-5e25-4dbc-a071-43552a4bd53b" + assert log["endpoint"] == "/emails" + assert log["method"] == "POST" + assert log["response_status"] == 200 + + async def test_should_get_log_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Logs.get_async("37e4414c-5e25-4dbc-a071-43552a4bd53b") + + async def test_logs_list_async(self) -> None: + self.set_mock_json( + { + "object": "list", + "has_more": False, + "data": [ + { + "id": "37e4414c-5e25-4dbc-a071-43552a4bd53b", + "created_at": "2024-01-01T00:00:00.000000+00:00", + "endpoint": "/emails", + "method": "POST", + "response_status": 200, + "user_agent": "resend-python/2.0.0", + } + ], + } + ) + + logs: resend.Logs.ListResponse = await resend.Logs.list_async() + assert logs["object"] == "list" + assert logs["has_more"] is False + for log in logs["data"]: + assert log["id"] == "37e4414c-5e25-4dbc-a071-43552a4bd53b" + assert log["endpoint"] == "/emails" + + async def test_should_list_logs_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Logs.list_async() + + async def test_logs_list_async_with_pagination_params(self) -> None: + self.set_mock_json( + { + "object": "list", + "has_more": True, + "data": [ + { + "id": "log-id-1", + "created_at": "2024-01-01T00:00:00.000000+00:00", + "endpoint": "/emails", + "method": "POST", + "response_status": 200, + "user_agent": "resend-python/2.0.0", + } + ], + } + ) + + params: resend.Logs.ListParams = {"limit": 10, "after": "previous-log-id"} + logs: resend.Logs.ListResponse = await resend.Logs.list_async(params=params) + assert logs["has_more"] is True + assert logs["data"][0]["id"] == "log-id-1" diff --git a/tests/logs_test.py b/tests/logs_test.py new file mode 100644 index 0000000..3a7ba8b --- /dev/null +++ b/tests/logs_test.py @@ -0,0 +1,134 @@ +import resend +from resend.exceptions import NoContentError +from tests.conftest import ResendBaseTest + +# flake8: noqa + + +class TestResendLogs(ResendBaseTest): + def test_logs_get(self) -> None: + self.set_mock_json( + { + "object": "log", + "id": "37e4414c-5e25-4dbc-a071-43552a4bd53b", + "created_at": "2024-01-01T00:00:00.000000+00:00", + "endpoint": "/emails", + "method": "POST", + "response_status": 200, + "user_agent": "resend-python/2.0.0", + "request_body": {"to": ["user@example.com"], "subject": "Hello"}, + "response_body": {"id": "email-id-123"}, + } + ) + + log: resend.Logs.GetResponse = resend.Logs.get( + "37e4414c-5e25-4dbc-a071-43552a4bd53b" + ) + assert log["object"] == "log" + assert log["id"] == "37e4414c-5e25-4dbc-a071-43552a4bd53b" + assert log["created_at"] == "2024-01-01T00:00:00.000000+00:00" + assert log["endpoint"] == "/emails" + assert log["method"] == "POST" + assert log["response_status"] == 200 + assert log["user_agent"] == "resend-python/2.0.0" + assert log["request_body"] == {"to": ["user@example.com"], "subject": "Hello"} + assert log["response_body"] == {"id": "email-id-123"} + + def test_should_get_log_raise_exception_when_no_content(self) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Logs.get("37e4414c-5e25-4dbc-a071-43552a4bd53b") + + def test_logs_list(self) -> None: + self.set_mock_json( + { + "object": "list", + "has_more": False, + "data": [ + { + "id": "37e4414c-5e25-4dbc-a071-43552a4bd53b", + "created_at": "2024-01-01T00:00:00.000000+00:00", + "endpoint": "/emails", + "method": "POST", + "response_status": 200, + "user_agent": "resend-python/2.0.0", + } + ], + } + ) + + logs: resend.Logs.ListResponse = resend.Logs.list() + assert logs["object"] == "list" + assert logs["has_more"] is False + assert len(logs["data"]) == 1 + log = logs["data"][0] + assert log["id"] == "37e4414c-5e25-4dbc-a071-43552a4bd53b" + assert log["created_at"] == "2024-01-01T00:00:00.000000+00:00" + assert log["endpoint"] == "/emails" + assert log["method"] == "POST" + assert log["response_status"] == 200 + assert log["user_agent"] == "resend-python/2.0.0" + + def test_should_list_logs_raise_exception_when_no_content(self) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Logs.list() + + def test_logs_list_with_pagination_params(self) -> None: + self.set_mock_json( + { + "object": "list", + "has_more": True, + "data": [ + { + "id": "log-id-1", + "created_at": "2024-01-01T00:00:00.000000+00:00", + "endpoint": "/emails", + "method": "POST", + "response_status": 200, + "user_agent": "resend-python/2.0.0", + }, + { + "id": "log-id-2", + "created_at": "2024-01-02T00:00:00.000000+00:00", + "endpoint": "/domains", + "method": "GET", + "response_status": 200, + "user_agent": "resend-python/2.0.0", + }, + ], + } + ) + + params: resend.Logs.ListParams = {"limit": 10, "after": "previous-log-id"} + logs: resend.Logs.ListResponse = resend.Logs.list(params=params) + assert logs["object"] == "list" + assert logs["has_more"] is True + assert len(logs["data"]) == 2 + assert logs["data"][0]["id"] == "log-id-1" + assert logs["data"][1]["id"] == "log-id-2" + + def test_logs_list_with_before_param(self) -> None: + self.set_mock_json( + { + "object": "list", + "has_more": False, + "data": [ + { + "id": "log-id-3", + "created_at": "2024-01-03T00:00:00.000000+00:00", + "endpoint": "/api-keys", + "method": "DELETE", + "response_status": 200, + "user_agent": "resend-python/2.0.0", + } + ], + } + ) + + params: resend.Logs.ListParams = {"limit": 5, "before": "later-log-id"} + logs: resend.Logs.ListResponse = resend.Logs.list(params=params) + assert logs["object"] == "list" + assert logs["has_more"] is False + assert len(logs["data"]) == 1 + assert logs["data"][0]["id"] == "log-id-3" From 59f5c4ef3c37d9e0307d3b21ea4969819d83a45c Mon Sep 17 00:00:00 2001 From: drish Date: Tue, 31 Mar 2026 10:19:11 -0300 Subject: [PATCH 2/4] chore: bump version to 2.27.0 --- resend/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resend/version.py b/resend/version.py index 2ef6d13..d9cc54e 100644 --- a/resend/version.py +++ b/resend/version.py @@ -1,4 +1,4 @@ -__version__ = "2.26.0" +__version__ = "2.27.0" def get_version() -> str: From f595e1114f9fe175865f4d6afe49059a00fb4cea Mon Sep 17 00:00:00 2001 From: drish Date: Wed, 1 Apr 2026 16:54:23 -0300 Subject: [PATCH 3/4] fix(logs): guard AsyncRequest with clear ImportError when async extras missing --- resend/logs/_logs.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/resend/logs/_logs.py b/resend/logs/_logs.py index 9273a7a..4b2831a 100644 --- a/resend/logs/_logs.py +++ b/resend/logs/_logs.py @@ -11,7 +11,7 @@ try: from resend.async_request import AsyncRequest except ImportError: - pass + AsyncRequest = None # type: ignore[assignment] class Logs: @@ -163,6 +163,11 @@ async def get_async(cls, log_id: str) -> GetResponse: Returns: GetResponse: The log object """ + if AsyncRequest is None: + raise ImportError( + "Async support requires additional dependencies. " + "Install them with: pip install resend[async]" + ) path = f"/logs/{log_id}" resp = await AsyncRequest[Logs.GetResponse]( path=path, params={}, verb="get" @@ -184,6 +189,11 @@ async def list_async(cls, params: Optional[ListParams] = None) -> ListResponse: Returns: ListResponse: A list of log objects """ + if AsyncRequest is None: + raise ImportError( + "Async support requires additional dependencies. " + "Install them with: pip install resend[async]" + ) base_path = "/logs" query_params = cast(Dict[Any, Any], params) if params else None path = PaginationHelper.build_paginated_path(base_path, query_params) From c6cb6bfa955d314d10cdd8c2aa69152abe765b43 Mon Sep 17 00:00:00 2001 From: drish Date: Wed, 1 Apr 2026 17:51:05 -0300 Subject: [PATCH 4/4] Revert "fix(logs): guard AsyncRequest with clear ImportError when async extras missing" This reverts commit f595e1114f9fe175865f4d6afe49059a00fb4cea. --- resend/logs/_logs.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/resend/logs/_logs.py b/resend/logs/_logs.py index 4b2831a..9273a7a 100644 --- a/resend/logs/_logs.py +++ b/resend/logs/_logs.py @@ -11,7 +11,7 @@ try: from resend.async_request import AsyncRequest except ImportError: - AsyncRequest = None # type: ignore[assignment] + pass class Logs: @@ -163,11 +163,6 @@ async def get_async(cls, log_id: str) -> GetResponse: Returns: GetResponse: The log object """ - if AsyncRequest is None: - raise ImportError( - "Async support requires additional dependencies. " - "Install them with: pip install resend[async]" - ) path = f"/logs/{log_id}" resp = await AsyncRequest[Logs.GetResponse]( path=path, params={}, verb="get" @@ -189,11 +184,6 @@ async def list_async(cls, params: Optional[ListParams] = None) -> ListResponse: Returns: ListResponse: A list of log objects """ - if AsyncRequest is None: - raise ImportError( - "Async support requires additional dependencies. " - "Install them with: pip install resend[async]" - ) base_path = "/logs" query_params = cast(Dict[Any, Any], params) if params else None path = PaginationHelper.build_paginated_path(base_path, query_params)