Skip to content

Commit c8835ae

Browse files
committed
Merge branch 'main' of https://github.com/resend/resend-python into feat/headers-all-responses
2 parents 557e2cd + 1e71161 commit c8835ae

8 files changed

Lines changed: 154 additions & 4 deletions

File tree

.github/workflows/ci.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ jobs:
99
matrix:
1010
python-version: ["3.8", "3.9", "3.10", "3.11"]
1111
steps:
12-
- uses: actions/checkout@v5
12+
- uses: actions/checkout@v6
1313
- name: Set up Python ${{ matrix.python-version }}
1414
uses: actions/setup-python@v6
1515
with:
@@ -28,7 +28,7 @@ jobs:
2828
os: [ubuntu-latest]
2929
python-version: ["3.8", "3.9", "3.10", "3.11"]
3030
steps:
31-
- uses: actions/checkout@v5
31+
- uses: actions/checkout@v6
3232
- name: Set up Python ${{ matrix.python-version }}
3333
uses: actions/setup-python@v6
3434
with:

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM python:3.13.7
1+
FROM python:3.14.3
22

33
RUN pip install --upgrade pip
44

resend/broadcasts/_broadcasts.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ class CreateParams(_CreateParamsFrom):
4040
html (NotRequired[str]): The HTML version of the message.
4141
text (NotRequired[str]): The text version of the message.
4242
name (NotRequired[str]): The friendly name of the broadcast. Only used for internal reference.
43+
send (NotRequired[bool]): When true, the broadcast will be sent immediately after creation.
44+
scheduled_at (NotRequired[str]): Schedule the broadcast to be sent later. Only valid when send is true.
4345
"""
4446

4547
segment_id: NotRequired[str]
@@ -73,6 +75,17 @@ class CreateParams(_CreateParamsFrom):
7375
"""
7476
The friendly name of the broadcast. Only used for internal reference.
7577
"""
78+
send: NotRequired[bool]
79+
"""
80+
When set to true, the broadcast will be sent immediately after creation.
81+
If false or not provided, the broadcast will be created as a draft.
82+
"""
83+
scheduled_at: NotRequired[str]
84+
"""
85+
Schedule the broadcast to be sent later.
86+
Only valid when send is set to true.
87+
The date should be in natural language (e.g.: in 1 min) or ISO 8601 format (e.g: 2024-08-05T11:52:01.858Z).
88+
"""
7689

7790
class UpdateParams(_UpdateParamsFrom):
7891
"""UpdateParams is the class that wraps the parameters for the update method.

resend/request.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import resend
77
from resend.exceptions import (NoContentError, ResendError,
88
raise_for_code_and_type)
9+
from resend.response import ResponseDict
910
from resend.version import get_version
1011

1112
RequestVerb = Literal["get", "post", "put", "patch", "delete"]
@@ -39,6 +40,8 @@ def perform(self) -> Union[T, None]:
3940
error_type=data.get("name", "InternalServerError"),
4041
)
4142

43+
if isinstance(data, dict):
44+
data = ResponseDict(data)
4245
return cast(T, data)
4346

4447
def perform_with_content(self) -> T:

resend/response.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from typing import Any, Dict
2+
3+
4+
class ResponseDict(Dict[str, Any]):
5+
"""Dict subclass that supports attribute-style access.
6+
7+
This allows SDK responses to be accessed using either dict syntax
8+
(response['data']) or attribute syntax (response.data), providing
9+
consistency with other Resend SDKs (e.g., Node.js).
10+
"""
11+
12+
def __getattr__(self, name: str) -> Any:
13+
try:
14+
return self[name]
15+
except KeyError:
16+
raise AttributeError(
17+
f"'{type(self).__name__}' object has no attribute '{name}'"
18+
)

resend/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
__version__ = "2.20.0"
1+
__version__ = "2.22.0"
22

33

44
def get_version() -> str:

tests/broadcasts_test.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,33 @@ def test_broadcasts_send(self) -> None:
7474
broadcast = resend.Broadcasts.send(params)
7575
assert broadcast["id"] == "49a3999c-0ce1-4ea6-ab68-afcd6dc2e791"
7676

77+
def test_broadcasts_create_and_send(self) -> None:
78+
self.set_mock_json({"id": "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794"})
79+
80+
params: resend.Broadcasts.CreateParams = {
81+
"audience_id": "78b8d3bc-a55a-45a3-aee6-6ec0a5e13d7e",
82+
"from": "hi@example.com",
83+
"subject": "Hello, world!",
84+
"name": "Python SDK Broadcast",
85+
"send": True,
86+
}
87+
broadcast: resend.Broadcasts.CreateResponse = resend.Broadcasts.create(params)
88+
assert broadcast["id"] == "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794"
89+
90+
def test_broadcasts_create_and_schedule(self) -> None:
91+
self.set_mock_json({"id": "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794"})
92+
93+
params: resend.Broadcasts.CreateParams = {
94+
"audience_id": "78b8d3bc-a55a-45a3-aee6-6ec0a5e13d7e",
95+
"from": "hi@example.com",
96+
"subject": "Hello, world!",
97+
"name": "Python SDK Broadcast",
98+
"send": True,
99+
"scheduled_at": "2024-12-21T19:32:22.980Z",
100+
}
101+
broadcast: resend.Broadcasts.CreateResponse = resend.Broadcasts.create(params)
102+
assert broadcast["id"] == "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794"
103+
77104
def test_broadcasts_remove(self) -> None:
78105
self.set_mock_json(
79106
{

tests/response_test.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import resend
2+
from tests.conftest import ResendBaseTest
3+
4+
# flake8: noqa
5+
6+
7+
class TestResponseDict(ResendBaseTest):
8+
9+
def test_list_response_supports_dict_access(self) -> None:
10+
self.set_mock_json(
11+
{
12+
"object": "list",
13+
"has_more": False,
14+
"data": [
15+
{
16+
"id": "att-1",
17+
"filename": "avatar.png",
18+
"content_type": "image/png",
19+
"content_disposition": "inline",
20+
"size": 1024,
21+
},
22+
],
23+
}
24+
)
25+
26+
attachments = resend.Emails.Receiving.Attachments.list(
27+
email_id="test-email-id"
28+
)
29+
assert attachments["object"] == "list"
30+
assert attachments["has_more"] is False
31+
assert len(attachments["data"]) == 1
32+
assert attachments["data"][0]["id"] == "att-1"
33+
34+
def test_list_response_supports_attribute_access(self) -> None:
35+
self.set_mock_json(
36+
{
37+
"object": "list",
38+
"has_more": False,
39+
"data": [
40+
{
41+
"id": "att-1",
42+
"filename": "avatar.png",
43+
"content_type": "image/png",
44+
"content_disposition": "inline",
45+
"size": 1024,
46+
},
47+
],
48+
}
49+
)
50+
51+
attachments = resend.Emails.Receiving.Attachments.list(
52+
email_id="test-email-id"
53+
)
54+
assert attachments.object == "list" # type: ignore[attr-defined]
55+
assert attachments.has_more is False # type: ignore[attr-defined]
56+
assert len(attachments.data) == 1 # type: ignore[attr-defined]
57+
assert attachments.data[0]["id"] == "att-1" # type: ignore[attr-defined]
58+
59+
def test_attribute_access_raises_for_missing_key(self) -> None:
60+
self.set_mock_json(
61+
{
62+
"object": "list",
63+
"has_more": False,
64+
"data": [],
65+
}
66+
)
67+
68+
attachments = resend.Emails.Receiving.Attachments.list(
69+
email_id="test-email-id"
70+
)
71+
with self.assertRaises(AttributeError):
72+
_ = attachments.nonexistent # type: ignore[attr-defined]
73+
74+
def test_single_response_supports_attribute_access(self) -> None:
75+
self.set_mock_json(
76+
{
77+
"id": "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794",
78+
}
79+
)
80+
81+
params: resend.Emails.SendParams = {
82+
"from": "from@email.io",
83+
"to": ["to@email.io"],
84+
"subject": "subject",
85+
"html": "<p>Hello</p>",
86+
}
87+
email = resend.Emails.send(params)
88+
assert email["id"] == "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794"
89+
assert email.id == "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794" # type: ignore[attr-defined]

0 commit comments

Comments
 (0)