databutton 0.31.0__tar.gz → 0.31.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {databutton-0.31.0 → databutton-0.31.1}/PKG-INFO +1 -1
- databutton-0.31.1/databutton/notify/__init__.py +8 -0
- databutton-0.31.1/databutton/notify/email.py +285 -0
- {databutton-0.31.0 → databutton-0.31.1}/databutton/version.py +1 -1
- {databutton-0.31.0 → databutton-0.31.1}/pyproject.toml +1 -1
- databutton-0.31.0/databutton/notify/__init__.py +0 -3
- databutton-0.31.0/databutton/notify/email.py +0 -173
- {databutton-0.31.0 → databutton-0.31.1}/LICENSE +0 -0
- {databutton-0.31.0 → databutton-0.31.1}/README.md +0 -0
- {databutton-0.31.0 → databutton-0.31.1}/databutton/__init__.py +0 -0
- {databutton-0.31.0 → databutton-0.31.1}/databutton/cachetools.py +0 -0
- {databutton-0.31.0 → databutton-0.31.1}/databutton/internal/__init__.py +0 -0
- {databutton-0.31.0 → databutton-0.31.1}/databutton/internal/auth.py +0 -0
- {databutton-0.31.0 → databutton-0.31.1}/databutton/internal/byteutils.py +0 -0
- {databutton-0.31.0 → databutton-0.31.1}/databutton/internal/dbapiclient.py +0 -0
- {databutton-0.31.0 → databutton-0.31.1}/databutton/internal/headers.py +0 -0
- {databutton-0.31.0 → databutton-0.31.1}/databutton/internal/httpxclient.py +0 -0
- {databutton-0.31.0 → databutton-0.31.1}/databutton/internal/performedby.py +0 -0
- {databutton-0.31.0 → databutton-0.31.1}/databutton/internal/retries.py +0 -0
- {databutton-0.31.0 → databutton-0.31.1}/databutton/jobs/__init__.py +0 -0
- {databutton-0.31.0 → databutton-0.31.1}/databutton/jobs/run.py +0 -0
- {databutton-0.31.0 → databutton-0.31.1}/databutton/notify/send.py +0 -0
- {databutton-0.31.0 → databutton-0.31.1}/databutton/secrets/__init__.py +0 -0
- {databutton-0.31.0 → databutton-0.31.1}/databutton/secrets/secrets.py +0 -0
- {databutton-0.31.0 → databutton-0.31.1}/databutton/storage/__init__.py +0 -0
- {databutton-0.31.0 → databutton-0.31.1}/databutton/storage/storage.py +0 -0
- {databutton-0.31.0 → databutton-0.31.1}/databutton/user.py +0 -0
@@ -0,0 +1,285 @@
|
|
1
|
+
import base64
|
2
|
+
import io
|
3
|
+
import mimetypes
|
4
|
+
import re
|
5
|
+
from collections.abc import Sequence
|
6
|
+
from typing import List, Optional, Union
|
7
|
+
|
8
|
+
import pandas as pd
|
9
|
+
from pydantic import BaseModel
|
10
|
+
|
11
|
+
from .send import send
|
12
|
+
|
13
|
+
|
14
|
+
def valid_email(recipient: str) -> bool:
|
15
|
+
# Note: We could possibly use some email validation library but it's tricky
|
16
|
+
parts = recipient.split("@")
|
17
|
+
if len(parts) != 2:
|
18
|
+
return False
|
19
|
+
return bool(parts[0] and parts[1])
|
20
|
+
|
21
|
+
|
22
|
+
def validate_email_to_arg(to: Union[str, List[str]]) -> List[str]:
|
23
|
+
if isinstance(to, str):
|
24
|
+
to = [to]
|
25
|
+
if not isinstance(to, (list, tuple)) and len(to) > 0:
|
26
|
+
raise ValueError(
|
27
|
+
"Invalid recipient, expecting 'to' to be a string or list of strings."
|
28
|
+
)
|
29
|
+
invalid_emails = []
|
30
|
+
for recipient in to:
|
31
|
+
if not valid_email(recipient):
|
32
|
+
invalid_emails.append(recipient)
|
33
|
+
if invalid_emails:
|
34
|
+
raise ValueError("\n".join(["Invalid email address(es):"] + invalid_emails))
|
35
|
+
return to
|
36
|
+
|
37
|
+
|
38
|
+
# This is the type expected in the api
|
39
|
+
class Attachment(BaseModel):
|
40
|
+
"""An attachment to be included with an email."""
|
41
|
+
|
42
|
+
# Attachment file name
|
43
|
+
file_name: Optional[str] = None
|
44
|
+
|
45
|
+
# MIME type of the attachment
|
46
|
+
content_type: Optional[str] = None
|
47
|
+
|
48
|
+
# Content ID (CID) to use for inline attachments
|
49
|
+
content_id: Optional[str] = None
|
50
|
+
|
51
|
+
# Base64 encoded data
|
52
|
+
content_base64: str
|
53
|
+
|
54
|
+
|
55
|
+
# This is the type expected in the api
|
56
|
+
class Email(BaseModel):
|
57
|
+
to: Union[str, List[str]]
|
58
|
+
subject: str
|
59
|
+
content_text: Optional[str] = None
|
60
|
+
content_html: Optional[str] = None
|
61
|
+
attachments: list[Attachment] = []
|
62
|
+
|
63
|
+
|
64
|
+
def determine_type(type: Optional[str], name: Optional[str]) -> Optional[str]:
|
65
|
+
if type:
|
66
|
+
return type
|
67
|
+
if name:
|
68
|
+
type, encoding = mimetypes.guess_type(name)
|
69
|
+
# if encoding is not None:
|
70
|
+
# return "; ".join([type, encoding])
|
71
|
+
return type
|
72
|
+
return None
|
73
|
+
|
74
|
+
|
75
|
+
def encode_content(content: bytes | str) -> str:
|
76
|
+
if isinstance(content, str):
|
77
|
+
content = content.encode()
|
78
|
+
return base64.b64encode(content).decode()
|
79
|
+
|
80
|
+
|
81
|
+
def attachment_from_bytes(
|
82
|
+
content: bytes,
|
83
|
+
*,
|
84
|
+
file_name: Optional[str] = None,
|
85
|
+
content_type: Optional[str] = None,
|
86
|
+
cid: Optional[str] = None,
|
87
|
+
) -> Attachment:
|
88
|
+
"""Create attachment with content as raw bytes.
|
89
|
+
|
90
|
+
You can optionally provide a file name and/or content type.
|
91
|
+
|
92
|
+
If missing we will try to guess the content type from the file name.
|
93
|
+
|
94
|
+
To use an attachment as an inline image in the email,
|
95
|
+
set the `cid="my_image_id"` parameter,
|
96
|
+
and use `<img src="cid:my_image_id">` in the html content.
|
97
|
+
"""
|
98
|
+
return Attachment(
|
99
|
+
file_name=file_name,
|
100
|
+
content_type=determine_type(content_type, file_name),
|
101
|
+
content_base64=encode_content(content),
|
102
|
+
content_id=cid,
|
103
|
+
)
|
104
|
+
|
105
|
+
|
106
|
+
def attachment_from_str(
|
107
|
+
content: str,
|
108
|
+
*,
|
109
|
+
file_name: Optional[str] = None,
|
110
|
+
content_type: Optional[str] = None,
|
111
|
+
cid: Optional[str] = None,
|
112
|
+
) -> Attachment:
|
113
|
+
"""Create attachment with content as raw str."""
|
114
|
+
return attachment_from_bytes(
|
115
|
+
content.encode(),
|
116
|
+
file_name=file_name,
|
117
|
+
content_type=content_type,
|
118
|
+
cid=cid,
|
119
|
+
)
|
120
|
+
|
121
|
+
|
122
|
+
def attachment_from_file(
|
123
|
+
fp: Optional[io.IOBase] = None,
|
124
|
+
*,
|
125
|
+
file_name: Optional[str] = None,
|
126
|
+
content_type: Optional[str] = None,
|
127
|
+
cid: Optional[str] = None,
|
128
|
+
) -> Attachment:
|
129
|
+
"""Create attachment with content from a file.
|
130
|
+
|
131
|
+
fp can be anything with a .read() method returning bytes or str,
|
132
|
+
or omitted to read file_name from file system.
|
133
|
+
"""
|
134
|
+
if fp is None:
|
135
|
+
if file_name is None:
|
136
|
+
raise ValueError("Either `fp` or `file_name` must be provided.")
|
137
|
+
with open(file_name, "rb") as fp:
|
138
|
+
buf = fp.read()
|
139
|
+
else:
|
140
|
+
buf = fp.read()
|
141
|
+
if isinstance(buf, str):
|
142
|
+
buf = buf.encode()
|
143
|
+
return attachment_from_bytes(
|
144
|
+
buf,
|
145
|
+
file_name=file_name,
|
146
|
+
content_type=content_type,
|
147
|
+
cid=cid,
|
148
|
+
)
|
149
|
+
|
150
|
+
|
151
|
+
def attachment_from_pil_image_as_jpeg(
|
152
|
+
image, # PIL image
|
153
|
+
*,
|
154
|
+
image_format: str = "JPEG",
|
155
|
+
file_name: Optional[str] = None,
|
156
|
+
content_type: Optional[str] = None,
|
157
|
+
cid: Optional[str] = None,
|
158
|
+
) -> Attachment:
|
159
|
+
"""Create image attachment with content from a PIL compatible image (such as pillow).
|
160
|
+
|
161
|
+
This convenience function calls image.save(buffer, format=image_format),
|
162
|
+
to further customize image formats etc take a peek at the implementation
|
163
|
+
here and use attachment_from_bytes directly instead.
|
164
|
+
|
165
|
+
You can optionally provide a file name and/or content type.
|
166
|
+
|
167
|
+
If missing we will try to guess the content type from the file name.
|
168
|
+
|
169
|
+
To use an attachment as an inline image in the email,
|
170
|
+
set the `cid="my_image_id"` parameter,
|
171
|
+
and use `<img src="cid:my_image_id">` in the html content.
|
172
|
+
"""
|
173
|
+
buf = io.BytesIO()
|
174
|
+
image.save(buf, format=image_format)
|
175
|
+
return attachment_from_bytes(
|
176
|
+
buf.getvalue(),
|
177
|
+
file_name=file_name,
|
178
|
+
content_type=content_type,
|
179
|
+
cid=cid,
|
180
|
+
)
|
181
|
+
|
182
|
+
|
183
|
+
def attachment_from_dataframe_as_csv(
|
184
|
+
df: pd.DataFrame,
|
185
|
+
*,
|
186
|
+
file_name: Optional[str] = None,
|
187
|
+
) -> Attachment:
|
188
|
+
"""Create CSV attachment with content from a pandas dataframe."""
|
189
|
+
return attachment_from_str(
|
190
|
+
df.to_csv(),
|
191
|
+
file_name=file_name,
|
192
|
+
content_type="text/csv",
|
193
|
+
)
|
194
|
+
|
195
|
+
|
196
|
+
MIME_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
197
|
+
|
198
|
+
|
199
|
+
def attachment_from_dataframe_as_excel(
|
200
|
+
df: pd.DataFrame,
|
201
|
+
*,
|
202
|
+
file_name: Optional[str] = None,
|
203
|
+
) -> Attachment:
|
204
|
+
"""Create Excel (.xlsx) attachment with content from a pandas dataframe.
|
205
|
+
|
206
|
+
Requires the openpyxl package to be installed.
|
207
|
+
"""
|
208
|
+
buf = io.BytesIO()
|
209
|
+
df.to_excel(buf)
|
210
|
+
return attachment_from_bytes(
|
211
|
+
buf.getvalue(),
|
212
|
+
file_name=file_name,
|
213
|
+
content_type=MIME_TYPE_XLSX,
|
214
|
+
)
|
215
|
+
|
216
|
+
|
217
|
+
def validate_attachment(att: Attachment) -> Attachment:
|
218
|
+
assert isinstance(att, Attachment)
|
219
|
+
assert att.content_type
|
220
|
+
assert att.file_name
|
221
|
+
assert att.content_base64
|
222
|
+
assert isinstance(att.content_base64, str)
|
223
|
+
assert re.match(r"^[A-Za-z0-9+/=]+$", att.content_base64)
|
224
|
+
return att
|
225
|
+
|
226
|
+
|
227
|
+
def create_email(
|
228
|
+
*,
|
229
|
+
to: Union[str, List[str]],
|
230
|
+
subject: str,
|
231
|
+
content_text: Optional[str] = None,
|
232
|
+
content_html: Optional[str] = None,
|
233
|
+
attachments: Sequence[Attachment] = (),
|
234
|
+
) -> Email:
|
235
|
+
attachments = [validate_attachment(att) for att in attachments]
|
236
|
+
|
237
|
+
# Sendgrid has a 30 MB limit on everything, this estimate should be slightly stricter
|
238
|
+
size = (
|
239
|
+
len(content_html or "")
|
240
|
+
+ len(content_text or "")
|
241
|
+
+ sum([len(att.content_base64) for att in attachments])
|
242
|
+
)
|
243
|
+
max = 30 * 1024**2 # 30 MB
|
244
|
+
headroom = 100 * 1024 # leave some room for headers etc
|
245
|
+
if size > max - headroom:
|
246
|
+
raise ValueError(
|
247
|
+
"Email and attachment size exceeds 30MB, please reduce the size of the email."
|
248
|
+
)
|
249
|
+
|
250
|
+
return Email(
|
251
|
+
to=validate_email_to_arg(to),
|
252
|
+
subject=subject,
|
253
|
+
content_text=content_text,
|
254
|
+
content_html=content_html,
|
255
|
+
attachments=attachments,
|
256
|
+
)
|
257
|
+
|
258
|
+
|
259
|
+
def email(
|
260
|
+
to: Union[str, List[str]],
|
261
|
+
subject: str,
|
262
|
+
*,
|
263
|
+
content_text: Optional[str] = None,
|
264
|
+
content_html: Optional[str] = None,
|
265
|
+
attachments: Sequence[Attachment] = (),
|
266
|
+
):
|
267
|
+
"""Send email notification from databutton.
|
268
|
+
|
269
|
+
At least one of the content arguments must be present.
|
270
|
+
|
271
|
+
A link to the project will be added at the end of the email body.
|
272
|
+
|
273
|
+
If content_text is not provided it will be generated from
|
274
|
+
content_html for email clients without html support,
|
275
|
+
the result may be less pretty than handcrafted text.
|
276
|
+
"""
|
277
|
+
send(
|
278
|
+
create_email(
|
279
|
+
to=to,
|
280
|
+
subject=subject,
|
281
|
+
content_text=content_text,
|
282
|
+
content_html=content_html,
|
283
|
+
attachments=attachments,
|
284
|
+
)
|
285
|
+
)
|
@@ -1,173 +0,0 @@
|
|
1
|
-
import base64
|
2
|
-
import mimetypes
|
3
|
-
import re
|
4
|
-
from collections.abc import Sequence
|
5
|
-
from typing import List, Optional, Union
|
6
|
-
|
7
|
-
from pydantic import BaseModel
|
8
|
-
|
9
|
-
from .send import send
|
10
|
-
|
11
|
-
|
12
|
-
def valid_email(recipient: str) -> bool:
|
13
|
-
# Note: We could possibly use some email validation library but it's tricky
|
14
|
-
parts = recipient.split("@")
|
15
|
-
if len(parts) != 2:
|
16
|
-
return False
|
17
|
-
return bool(parts[0] and parts[1])
|
18
|
-
|
19
|
-
|
20
|
-
def validate_email_to_arg(to: Union[str, List[str]]) -> List[str]:
|
21
|
-
if isinstance(to, str):
|
22
|
-
to = [to]
|
23
|
-
if not isinstance(to, (list, tuple)) and len(to) > 0:
|
24
|
-
raise ValueError(
|
25
|
-
"Invalid recipient, expecting 'to' to be a string or list of strings."
|
26
|
-
)
|
27
|
-
invalid_emails = []
|
28
|
-
for recipient in to:
|
29
|
-
if not valid_email(recipient):
|
30
|
-
invalid_emails.append(recipient)
|
31
|
-
if invalid_emails:
|
32
|
-
raise ValueError("\n".join(["Invalid email address(es):"] + invalid_emails))
|
33
|
-
return to
|
34
|
-
|
35
|
-
|
36
|
-
# This is the type expected in the api
|
37
|
-
class Attachment(BaseModel):
|
38
|
-
"""An attachment to be included with an email."""
|
39
|
-
|
40
|
-
# Filename
|
41
|
-
name: Optional[str] = None
|
42
|
-
|
43
|
-
# MIME type of the attachment
|
44
|
-
type: Optional[str] = None
|
45
|
-
|
46
|
-
# ID to use for inline attachments
|
47
|
-
cid: Optional[str] = None
|
48
|
-
|
49
|
-
# Base64 encoded data
|
50
|
-
base64_content: str
|
51
|
-
|
52
|
-
|
53
|
-
# This is the type expected in the api
|
54
|
-
class Email(BaseModel):
|
55
|
-
to: Union[str, List[str]]
|
56
|
-
subject: str
|
57
|
-
content_text: Optional[str] = None
|
58
|
-
content_html: Optional[str] = None
|
59
|
-
attachments: list[Attachment] = []
|
60
|
-
|
61
|
-
|
62
|
-
def determine_type(type: Optional[str], name: Optional[str]) -> Optional[str]:
|
63
|
-
if type:
|
64
|
-
return type
|
65
|
-
if name:
|
66
|
-
type, encoding = mimetypes.guess_type(name)
|
67
|
-
# if encoding is not None:
|
68
|
-
# return "; ".join([type, encoding])
|
69
|
-
return type
|
70
|
-
return None
|
71
|
-
|
72
|
-
|
73
|
-
def encode_content(content: bytes | str) -> str:
|
74
|
-
if isinstance(content, str):
|
75
|
-
content = content.encode()
|
76
|
-
return base64.b64encode(content).decode()
|
77
|
-
|
78
|
-
|
79
|
-
def create_attachment(
|
80
|
-
*,
|
81
|
-
content: bytes | str,
|
82
|
-
name: Optional[str] = None,
|
83
|
-
type: Optional[str] = None,
|
84
|
-
cid: Optional[str] = None,
|
85
|
-
) -> Attachment:
|
86
|
-
"""Create an attachment to be included with an email.
|
87
|
-
|
88
|
-
Content can either be a string or a bytes object.
|
89
|
-
We will base64 encode it for you.
|
90
|
-
|
91
|
-
The content type can be omitted if the name has a normal file extension.
|
92
|
-
|
93
|
-
To use an attachment as an inline image in the email,
|
94
|
-
set the `cid="my_image_id"` parameter,
|
95
|
-
and use `<img src="cid:my_image_id">` in the html content.
|
96
|
-
"""
|
97
|
-
return Attachment(
|
98
|
-
name=name,
|
99
|
-
type=determine_type(type, name),
|
100
|
-
base64_content=encode_content(content),
|
101
|
-
cid=cid,
|
102
|
-
)
|
103
|
-
|
104
|
-
|
105
|
-
def validate_attachment(att: Attachment) -> Attachment:
|
106
|
-
assert isinstance(att, Attachment)
|
107
|
-
assert att.type
|
108
|
-
assert att.name
|
109
|
-
assert att.base64_content
|
110
|
-
assert isinstance(att.base64_content, str)
|
111
|
-
assert re.match(r"^[A-Za-z0-9+/=]+$", att.base64_content)
|
112
|
-
return att
|
113
|
-
|
114
|
-
|
115
|
-
def create_email(
|
116
|
-
*,
|
117
|
-
to: Union[str, List[str]],
|
118
|
-
subject: str,
|
119
|
-
content_text: Optional[str] = None,
|
120
|
-
content_html: Optional[str] = None,
|
121
|
-
attachments: Sequence[Attachment] = (),
|
122
|
-
) -> Email:
|
123
|
-
attachments = [validate_attachment(att) for att in attachments]
|
124
|
-
|
125
|
-
# Sendgrid has a 30 MB limit on everything, this estimate should be slightly stricter
|
126
|
-
size = (
|
127
|
-
len(content_html or "")
|
128
|
-
+ len(content_text or "")
|
129
|
-
+ sum([len(att.base64_content) for att in attachments])
|
130
|
-
)
|
131
|
-
max = 30 * 1024**2 # 30 MB
|
132
|
-
headroom = 100 * 1024 # leave some room for headers etc
|
133
|
-
if size > max - headroom:
|
134
|
-
raise ValueError(
|
135
|
-
"Email and attachment size exceeds 30MB, please reduce the size of the email."
|
136
|
-
)
|
137
|
-
|
138
|
-
return Email(
|
139
|
-
to=validate_email_to_arg(to),
|
140
|
-
subject=subject,
|
141
|
-
content_text=content_text,
|
142
|
-
content_html=content_html,
|
143
|
-
attachments=attachments,
|
144
|
-
)
|
145
|
-
|
146
|
-
|
147
|
-
def email(
|
148
|
-
to: Union[str, List[str]],
|
149
|
-
subject: str,
|
150
|
-
*,
|
151
|
-
content_text: Optional[str] = None,
|
152
|
-
content_html: Optional[str] = None,
|
153
|
-
attachments: Sequence[Attachment] = (),
|
154
|
-
):
|
155
|
-
"""Send email notification from databutton.
|
156
|
-
|
157
|
-
At least one of the content arguments must be present.
|
158
|
-
|
159
|
-
A link to the project will be added at the end of the email body.
|
160
|
-
|
161
|
-
If content_text is not provided it will be generated from
|
162
|
-
content_html for email clients without html support,
|
163
|
-
the result may be less pretty than handcrafted text.
|
164
|
-
"""
|
165
|
-
send(
|
166
|
-
create_email(
|
167
|
-
to=to,
|
168
|
-
subject=subject,
|
169
|
-
content_text=content_text,
|
170
|
-
content_html=content_html,
|
171
|
-
attachments=attachments,
|
172
|
-
)
|
173
|
-
)
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|