kstlib 0.0.1a0__py3-none-any.whl → 1.0.0__py3-none-any.whl
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.
- kstlib/__init__.py +266 -1
- kstlib/__main__.py +16 -0
- kstlib/alerts/__init__.py +110 -0
- kstlib/alerts/channels/__init__.py +36 -0
- kstlib/alerts/channels/base.py +197 -0
- kstlib/alerts/channels/email.py +227 -0
- kstlib/alerts/channels/slack.py +389 -0
- kstlib/alerts/exceptions.py +72 -0
- kstlib/alerts/manager.py +651 -0
- kstlib/alerts/models.py +142 -0
- kstlib/alerts/throttle.py +263 -0
- kstlib/auth/__init__.py +139 -0
- kstlib/auth/callback.py +399 -0
- kstlib/auth/config.py +502 -0
- kstlib/auth/errors.py +127 -0
- kstlib/auth/models.py +316 -0
- kstlib/auth/providers/__init__.py +14 -0
- kstlib/auth/providers/base.py +393 -0
- kstlib/auth/providers/oauth2.py +645 -0
- kstlib/auth/providers/oidc.py +821 -0
- kstlib/auth/session.py +338 -0
- kstlib/auth/token.py +482 -0
- kstlib/cache/__init__.py +50 -0
- kstlib/cache/decorator.py +261 -0
- kstlib/cache/strategies.py +516 -0
- kstlib/cli/__init__.py +8 -0
- kstlib/cli/app.py +195 -0
- kstlib/cli/commands/__init__.py +5 -0
- kstlib/cli/commands/auth/__init__.py +39 -0
- kstlib/cli/commands/auth/common.py +122 -0
- kstlib/cli/commands/auth/login.py +325 -0
- kstlib/cli/commands/auth/logout.py +74 -0
- kstlib/cli/commands/auth/providers.py +57 -0
- kstlib/cli/commands/auth/status.py +291 -0
- kstlib/cli/commands/auth/token.py +199 -0
- kstlib/cli/commands/auth/whoami.py +106 -0
- kstlib/cli/commands/config.py +89 -0
- kstlib/cli/commands/ops/__init__.py +39 -0
- kstlib/cli/commands/ops/attach.py +49 -0
- kstlib/cli/commands/ops/common.py +269 -0
- kstlib/cli/commands/ops/list_sessions.py +252 -0
- kstlib/cli/commands/ops/logs.py +49 -0
- kstlib/cli/commands/ops/start.py +98 -0
- kstlib/cli/commands/ops/status.py +138 -0
- kstlib/cli/commands/ops/stop.py +60 -0
- kstlib/cli/commands/rapi/__init__.py +60 -0
- kstlib/cli/commands/rapi/call.py +341 -0
- kstlib/cli/commands/rapi/list.py +99 -0
- kstlib/cli/commands/rapi/show.py +206 -0
- kstlib/cli/commands/secrets/__init__.py +35 -0
- kstlib/cli/commands/secrets/common.py +425 -0
- kstlib/cli/commands/secrets/decrypt.py +88 -0
- kstlib/cli/commands/secrets/doctor.py +743 -0
- kstlib/cli/commands/secrets/encrypt.py +242 -0
- kstlib/cli/commands/secrets/shred.py +96 -0
- kstlib/cli/common.py +86 -0
- kstlib/config/__init__.py +76 -0
- kstlib/config/exceptions.py +110 -0
- kstlib/config/export.py +225 -0
- kstlib/config/loader.py +963 -0
- kstlib/config/sops.py +287 -0
- kstlib/db/__init__.py +54 -0
- kstlib/db/aiosqlcipher.py +137 -0
- kstlib/db/cipher.py +112 -0
- kstlib/db/database.py +367 -0
- kstlib/db/exceptions.py +25 -0
- kstlib/db/pool.py +302 -0
- kstlib/helpers/__init__.py +35 -0
- kstlib/helpers/exceptions.py +11 -0
- kstlib/helpers/time_trigger.py +396 -0
- kstlib/kstlib.conf.yml +890 -0
- kstlib/limits.py +963 -0
- kstlib/logging/__init__.py +108 -0
- kstlib/logging/manager.py +633 -0
- kstlib/mail/__init__.py +42 -0
- kstlib/mail/builder.py +626 -0
- kstlib/mail/exceptions.py +27 -0
- kstlib/mail/filesystem.py +248 -0
- kstlib/mail/transport.py +224 -0
- kstlib/mail/transports/__init__.py +19 -0
- kstlib/mail/transports/gmail.py +268 -0
- kstlib/mail/transports/resend.py +324 -0
- kstlib/mail/transports/smtp.py +326 -0
- kstlib/meta.py +72 -0
- kstlib/metrics/__init__.py +88 -0
- kstlib/metrics/decorators.py +1090 -0
- kstlib/metrics/exceptions.py +14 -0
- kstlib/monitoring/__init__.py +116 -0
- kstlib/monitoring/_styles.py +163 -0
- kstlib/monitoring/cell.py +57 -0
- kstlib/monitoring/config.py +424 -0
- kstlib/monitoring/delivery.py +579 -0
- kstlib/monitoring/exceptions.py +63 -0
- kstlib/monitoring/image.py +220 -0
- kstlib/monitoring/kv.py +79 -0
- kstlib/monitoring/list.py +69 -0
- kstlib/monitoring/metric.py +88 -0
- kstlib/monitoring/monitoring.py +341 -0
- kstlib/monitoring/renderer.py +139 -0
- kstlib/monitoring/service.py +392 -0
- kstlib/monitoring/table.py +129 -0
- kstlib/monitoring/types.py +56 -0
- kstlib/ops/__init__.py +86 -0
- kstlib/ops/base.py +148 -0
- kstlib/ops/container.py +577 -0
- kstlib/ops/exceptions.py +209 -0
- kstlib/ops/manager.py +407 -0
- kstlib/ops/models.py +176 -0
- kstlib/ops/tmux.py +372 -0
- kstlib/ops/validators.py +287 -0
- kstlib/py.typed +0 -0
- kstlib/rapi/__init__.py +118 -0
- kstlib/rapi/client.py +875 -0
- kstlib/rapi/config.py +861 -0
- kstlib/rapi/credentials.py +887 -0
- kstlib/rapi/exceptions.py +213 -0
- kstlib/resilience/__init__.py +101 -0
- kstlib/resilience/circuit_breaker.py +440 -0
- kstlib/resilience/exceptions.py +95 -0
- kstlib/resilience/heartbeat.py +491 -0
- kstlib/resilience/rate_limiter.py +506 -0
- kstlib/resilience/shutdown.py +417 -0
- kstlib/resilience/watchdog.py +637 -0
- kstlib/secrets/__init__.py +29 -0
- kstlib/secrets/exceptions.py +19 -0
- kstlib/secrets/models.py +62 -0
- kstlib/secrets/providers/__init__.py +79 -0
- kstlib/secrets/providers/base.py +58 -0
- kstlib/secrets/providers/environment.py +66 -0
- kstlib/secrets/providers/keyring.py +107 -0
- kstlib/secrets/providers/kms.py +223 -0
- kstlib/secrets/providers/kwargs.py +101 -0
- kstlib/secrets/providers/sops.py +209 -0
- kstlib/secrets/resolver.py +221 -0
- kstlib/secrets/sensitive.py +130 -0
- kstlib/secure/__init__.py +23 -0
- kstlib/secure/fs.py +194 -0
- kstlib/secure/permissions.py +70 -0
- kstlib/ssl.py +347 -0
- kstlib/ui/__init__.py +23 -0
- kstlib/ui/exceptions.py +26 -0
- kstlib/ui/panels.py +484 -0
- kstlib/ui/spinner.py +864 -0
- kstlib/ui/tables.py +382 -0
- kstlib/utils/__init__.py +48 -0
- kstlib/utils/dict.py +36 -0
- kstlib/utils/formatting.py +338 -0
- kstlib/utils/http_trace.py +237 -0
- kstlib/utils/lazy.py +49 -0
- kstlib/utils/secure_delete.py +205 -0
- kstlib/utils/serialization.py +247 -0
- kstlib/utils/text.py +56 -0
- kstlib/utils/validators.py +124 -0
- kstlib/websocket/__init__.py +97 -0
- kstlib/websocket/exceptions.py +214 -0
- kstlib/websocket/manager.py +1102 -0
- kstlib/websocket/models.py +361 -0
- kstlib-1.0.0.dist-info/METADATA +201 -0
- kstlib-1.0.0.dist-info/RECORD +163 -0
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/WHEEL +1 -1
- kstlib-1.0.0.dist-info/entry_points.txt +2 -0
- kstlib-1.0.0.dist-info/licenses/LICENSE.md +9 -0
- kstlib-0.0.1a0.dist-info/METADATA +0 -29
- kstlib-0.0.1a0.dist-info/RECORD +0 -6
- kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/top_level.txt +0 -0
kstlib/mail/builder.py
ADDED
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
"""Fluent mail builder with transport-agnostic delivery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
# pylint: disable=too-many-instance-attributes
|
|
6
|
+
import contextlib
|
|
7
|
+
import copy
|
|
8
|
+
import functools
|
|
9
|
+
import html
|
|
10
|
+
import inspect
|
|
11
|
+
import mimetypes
|
|
12
|
+
import time
|
|
13
|
+
import traceback
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from email.message import EmailMessage
|
|
17
|
+
from typing import TYPE_CHECKING, Any, Literal, ParamSpec, TypeVar, overload
|
|
18
|
+
|
|
19
|
+
from kstlib.limits import MailLimits, get_mail_limits
|
|
20
|
+
from kstlib.mail.exceptions import MailConfigurationError, MailTransportError, MailValidationError
|
|
21
|
+
from kstlib.mail.filesystem import MailFilesystemGuards
|
|
22
|
+
from kstlib.utils import (
|
|
23
|
+
EmailAddress,
|
|
24
|
+
ValidationError,
|
|
25
|
+
format_bytes,
|
|
26
|
+
normalize_address_list,
|
|
27
|
+
parse_email_address,
|
|
28
|
+
replace_placeholders,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
from kstlib.mail.transport import MailTransport
|
|
36
|
+
|
|
37
|
+
P = ParamSpec("P")
|
|
38
|
+
R = TypeVar("R")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_DEFAULT_ENCODING = "utf-8"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class _InlineResource:
|
|
46
|
+
cid: str
|
|
47
|
+
path: Path
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(slots=True)
|
|
51
|
+
class NotifyResult:
|
|
52
|
+
"""Result of a notified function execution.
|
|
53
|
+
|
|
54
|
+
Attributes:
|
|
55
|
+
function_name: Name of the decorated function.
|
|
56
|
+
success: Whether the function completed without exception.
|
|
57
|
+
started_at: UTC timestamp when execution started.
|
|
58
|
+
ended_at: UTC timestamp when execution ended.
|
|
59
|
+
duration_ms: Execution duration in milliseconds.
|
|
60
|
+
return_value: Function return value (if success and include_return=True).
|
|
61
|
+
exception: Exception raised (if failure).
|
|
62
|
+
traceback_str: Formatted traceback string (if failure and include_traceback=True).
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
function_name: str
|
|
66
|
+
success: bool
|
|
67
|
+
started_at: datetime
|
|
68
|
+
ended_at: datetime
|
|
69
|
+
duration_ms: float
|
|
70
|
+
return_value: Any = None
|
|
71
|
+
exception: BaseException | None = None
|
|
72
|
+
traceback_str: str | None = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class MailBuilder:
|
|
76
|
+
"""Compose and send emails using a fluent interface.
|
|
77
|
+
|
|
78
|
+
Supports plain text and HTML bodies, file attachments, inline images,
|
|
79
|
+
and template-based content with placeholder substitution.
|
|
80
|
+
|
|
81
|
+
Example:
|
|
82
|
+
Build an email without sending (useful for inspection)::
|
|
83
|
+
|
|
84
|
+
>>> from kstlib.mail import MailBuilder
|
|
85
|
+
>>> mail = (
|
|
86
|
+
... MailBuilder()
|
|
87
|
+
... .sender("noreply@example.com")
|
|
88
|
+
... .to("user@example.com")
|
|
89
|
+
... .subject("Welcome!")
|
|
90
|
+
... .message("<h1>Hello</h1>", content_type="html")
|
|
91
|
+
... )
|
|
92
|
+
>>> msg = mail.build()
|
|
93
|
+
>>> msg["Subject"]
|
|
94
|
+
'Welcome!'
|
|
95
|
+
|
|
96
|
+
With a configured transport for actual delivery::
|
|
97
|
+
|
|
98
|
+
>>> from kstlib.mail import MailBuilder
|
|
99
|
+
>>> from kstlib.mail.transports import SMTPTransport
|
|
100
|
+
>>> transport = SMTPTransport(host="smtp.example.com", port=587)
|
|
101
|
+
>>> mail = MailBuilder(transport=transport)
|
|
102
|
+
>>> # mail.sender(...).to(...).subject(...).message(...).send()
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
*,
|
|
108
|
+
transport: MailTransport | None = None,
|
|
109
|
+
encoding: str = _DEFAULT_ENCODING,
|
|
110
|
+
filesystem: MailFilesystemGuards | None = None,
|
|
111
|
+
limits: MailLimits | None = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Initialise the builder with optional transport, charset, and guardrails."""
|
|
114
|
+
self._transport = transport
|
|
115
|
+
self._encoding = encoding
|
|
116
|
+
self._filesystem = filesystem or MailFilesystemGuards.default()
|
|
117
|
+
self._limits = limits or get_mail_limits()
|
|
118
|
+
self._sender: EmailAddress | None = None
|
|
119
|
+
self._reply_to: EmailAddress | None = None
|
|
120
|
+
self._to: list[EmailAddress] = []
|
|
121
|
+
self._cc: list[EmailAddress] = []
|
|
122
|
+
self._bcc: list[EmailAddress] = []
|
|
123
|
+
self._subject: str = ""
|
|
124
|
+
self._plain_body: str | None = None
|
|
125
|
+
self._html_body: str | None = None
|
|
126
|
+
self._attachments: list[Path] = []
|
|
127
|
+
self._inline: list[_InlineResource] = []
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
# Addressing
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def transport(self, transport: MailTransport) -> MailBuilder:
|
|
134
|
+
"""Attach a transport backend to this builder."""
|
|
135
|
+
self._transport = transport
|
|
136
|
+
return self
|
|
137
|
+
|
|
138
|
+
def sender(self, value: str) -> MailBuilder:
|
|
139
|
+
"""Set the sender address."""
|
|
140
|
+
self._sender = self._parse_address(value)
|
|
141
|
+
return self
|
|
142
|
+
|
|
143
|
+
def reply_to(self, value: str | None) -> MailBuilder:
|
|
144
|
+
"""Set an optional reply-to address."""
|
|
145
|
+
self._reply_to = self._parse_address(value) if value else None
|
|
146
|
+
return self
|
|
147
|
+
|
|
148
|
+
def to(self, *values: str) -> MailBuilder:
|
|
149
|
+
"""Append recipients to the ``To`` header."""
|
|
150
|
+
self._to.extend(self._parse_addresses(values))
|
|
151
|
+
return self
|
|
152
|
+
|
|
153
|
+
def cc(self, *values: str) -> MailBuilder:
|
|
154
|
+
"""Append recipients to the ``Cc`` header."""
|
|
155
|
+
self._cc.extend(self._parse_addresses(values))
|
|
156
|
+
return self
|
|
157
|
+
|
|
158
|
+
def bcc(self, *values: str) -> MailBuilder:
|
|
159
|
+
"""Append recipients to the ``Bcc`` header."""
|
|
160
|
+
self._bcc.extend(self._parse_addresses(values))
|
|
161
|
+
return self
|
|
162
|
+
|
|
163
|
+
# ------------------------------------------------------------------
|
|
164
|
+
# Content
|
|
165
|
+
# ------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
def subject(self, value: str) -> MailBuilder:
|
|
168
|
+
"""Set the message subject."""
|
|
169
|
+
self._subject = value
|
|
170
|
+
return self
|
|
171
|
+
|
|
172
|
+
def message(
|
|
173
|
+
self,
|
|
174
|
+
content: str | None = None,
|
|
175
|
+
*,
|
|
176
|
+
content_type: Literal["plain", "html"] = "html",
|
|
177
|
+
template: str | Path | None = None,
|
|
178
|
+
placeholders: Mapping[str, Any] | None = None,
|
|
179
|
+
**extra_placeholders: Any,
|
|
180
|
+
) -> MailBuilder:
|
|
181
|
+
"""Populate the message body either via raw content or a template.
|
|
182
|
+
|
|
183
|
+
Raises:
|
|
184
|
+
MailValidationError: If content_type is unsupported.
|
|
185
|
+
"""
|
|
186
|
+
body = self._resolve_body(content, template, placeholders, extra_placeholders)
|
|
187
|
+
|
|
188
|
+
if content_type == "html":
|
|
189
|
+
self._html_body = body
|
|
190
|
+
elif content_type == "plain":
|
|
191
|
+
self._plain_body = body
|
|
192
|
+
else: # pragma: no cover - defensive guard
|
|
193
|
+
raise MailValidationError(f"Unsupported content type: {content_type}")
|
|
194
|
+
return self
|
|
195
|
+
|
|
196
|
+
def attach(self, *paths: str | Path) -> MailBuilder:
|
|
197
|
+
"""Attach binary files to the message.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
*paths: One or more file paths to attach.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
Self for method chaining.
|
|
204
|
+
|
|
205
|
+
Raises:
|
|
206
|
+
MailValidationError: If no paths provided, attachment limit exceeded,
|
|
207
|
+
or file size exceeds configured limits.
|
|
208
|
+
"""
|
|
209
|
+
if not paths:
|
|
210
|
+
raise MailValidationError("attach() expects at least one file path")
|
|
211
|
+
for raw in paths:
|
|
212
|
+
path = self._filesystem.resolve_attachment(raw)
|
|
213
|
+
# Validate attachment count
|
|
214
|
+
if len(self._attachments) >= self._limits.max_attachments:
|
|
215
|
+
raise MailValidationError(f"Maximum of {self._limits.max_attachments} attachments exceeded")
|
|
216
|
+
# Validate file size
|
|
217
|
+
file_size = path.stat().st_size
|
|
218
|
+
if file_size > self._limits.max_attachment_size:
|
|
219
|
+
raise MailValidationError(
|
|
220
|
+
f"Attachment '{path.name}' exceeds size limit "
|
|
221
|
+
f"({format_bytes(file_size)} > {self._limits.max_attachment_size_display})"
|
|
222
|
+
)
|
|
223
|
+
self._attachments.append(path)
|
|
224
|
+
return self
|
|
225
|
+
|
|
226
|
+
def attach_inline(self, cid: str, path: str | Path) -> MailBuilder:
|
|
227
|
+
"""Attach an inline resource (e.g. image referenced with ``cid:``).
|
|
228
|
+
|
|
229
|
+
Raises:
|
|
230
|
+
MailValidationError: If cid is empty or file size exceeds limits.
|
|
231
|
+
"""
|
|
232
|
+
if not cid:
|
|
233
|
+
raise MailValidationError("Inline resources require a non-empty content ID")
|
|
234
|
+
resource_path = self._filesystem.resolve_inline(path)
|
|
235
|
+
# Validate file size for inline resources
|
|
236
|
+
file_size = resource_path.stat().st_size
|
|
237
|
+
if file_size > self._limits.max_attachment_size:
|
|
238
|
+
raise MailValidationError(
|
|
239
|
+
f"Inline resource '{resource_path.name}' exceeds size limit "
|
|
240
|
+
f"({format_bytes(file_size)} > {self._limits.max_attachment_size_display})"
|
|
241
|
+
)
|
|
242
|
+
self._inline.append(_InlineResource(cid=cid, path=resource_path))
|
|
243
|
+
return self
|
|
244
|
+
|
|
245
|
+
# ------------------------------------------------------------------
|
|
246
|
+
# Build & send
|
|
247
|
+
# ------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
def build(self) -> EmailMessage:
|
|
250
|
+
"""Assemble and return an :class:`EmailMessage` without sending it.
|
|
251
|
+
|
|
252
|
+
Raises:
|
|
253
|
+
MailValidationError: If sender, recipient, or body is missing.
|
|
254
|
+
"""
|
|
255
|
+
sender = self._validate_ready()
|
|
256
|
+
message = self._initialise_message(sender)
|
|
257
|
+
self._apply_inline_resources(message)
|
|
258
|
+
self._apply_file_attachments(message)
|
|
259
|
+
return message
|
|
260
|
+
|
|
261
|
+
def send(self) -> EmailMessage:
|
|
262
|
+
"""Build and send the email using the configured transport.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
The constructed EmailMessage after successful delivery.
|
|
266
|
+
|
|
267
|
+
Raises:
|
|
268
|
+
MailConfigurationError: If no transport has been configured.
|
|
269
|
+
MailTransportError: If the transport fails to deliver the message.
|
|
270
|
+
"""
|
|
271
|
+
if self._transport is None:
|
|
272
|
+
raise MailConfigurationError("No mail transport configured")
|
|
273
|
+
|
|
274
|
+
message = self.build()
|
|
275
|
+
try:
|
|
276
|
+
self._transport.send(message)
|
|
277
|
+
except MailTransportError:
|
|
278
|
+
raise
|
|
279
|
+
except Exception as exc: # pragma: no cover - defensive guard
|
|
280
|
+
raise MailTransportError("Unexpected error during delivery") from exc
|
|
281
|
+
return message
|
|
282
|
+
|
|
283
|
+
# ------------------------------------------------------------------
|
|
284
|
+
# Notification decorator
|
|
285
|
+
# ------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
def _snapshot(self) -> MailBuilder:
|
|
288
|
+
"""Create an independent copy of this builder for decoration.
|
|
289
|
+
|
|
290
|
+
Returns a copy that shares the transport but has independent
|
|
291
|
+
message state, so decorated functions don't interfere with each other.
|
|
292
|
+
"""
|
|
293
|
+
# Save transport before deepcopy (transports should not be copied)
|
|
294
|
+
transport = self._transport
|
|
295
|
+
self._transport = None
|
|
296
|
+
try:
|
|
297
|
+
snapshot = copy.deepcopy(self)
|
|
298
|
+
finally:
|
|
299
|
+
self._transport = transport
|
|
300
|
+
snapshot._transport = transport # noqa: SLF001
|
|
301
|
+
return snapshot
|
|
302
|
+
|
|
303
|
+
@overload
|
|
304
|
+
def notify(
|
|
305
|
+
self,
|
|
306
|
+
func: Callable[P, R],
|
|
307
|
+
/,
|
|
308
|
+
) -> Callable[P, R]: ...
|
|
309
|
+
|
|
310
|
+
@overload
|
|
311
|
+
def notify(
|
|
312
|
+
self,
|
|
313
|
+
func: None = None,
|
|
314
|
+
/,
|
|
315
|
+
*,
|
|
316
|
+
subject: str | None = None,
|
|
317
|
+
on_error_only: bool = False,
|
|
318
|
+
include_return: bool = False,
|
|
319
|
+
include_traceback: bool = True,
|
|
320
|
+
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
|
|
321
|
+
|
|
322
|
+
def notify(
|
|
323
|
+
self,
|
|
324
|
+
func: Callable[P, R] | None = None,
|
|
325
|
+
/,
|
|
326
|
+
*,
|
|
327
|
+
subject: str | None = None,
|
|
328
|
+
on_error_only: bool = False,
|
|
329
|
+
include_return: bool = False,
|
|
330
|
+
include_traceback: bool = True,
|
|
331
|
+
) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]:
|
|
332
|
+
"""Decorator to send email notifications on function execution.
|
|
333
|
+
|
|
334
|
+
Sends a notification email after the decorated function completes,
|
|
335
|
+
reporting success or failure with execution metrics.
|
|
336
|
+
|
|
337
|
+
Can be used with or without parentheses::
|
|
338
|
+
|
|
339
|
+
@mail.notify
|
|
340
|
+
def task(): ...
|
|
341
|
+
|
|
342
|
+
@mail.notify(subject="Step 1", on_error_only=True)
|
|
343
|
+
def task(): ...
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
func: The function to decorate (when used without parentheses).
|
|
347
|
+
subject: Override the builder's subject for this notification.
|
|
348
|
+
on_error_only: Only send notification if the function raises.
|
|
349
|
+
include_return: Include return value in success notifications.
|
|
350
|
+
include_traceback: Include traceback in failure notifications.
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
Decorated function that sends notifications.
|
|
354
|
+
|
|
355
|
+
Example:
|
|
356
|
+
>>> from kstlib.mail import MailBuilder
|
|
357
|
+
>>> mail = MailBuilder().sender("bot@x.com").to("admin@x.com")
|
|
358
|
+
>>> _ = mail.subject("Daily ETL")
|
|
359
|
+
>>> @mail.notify(on_error_only=True)
|
|
360
|
+
... def extract():
|
|
361
|
+
... return {"rows": 100}
|
|
362
|
+
"""
|
|
363
|
+
|
|
364
|
+
def decorator(fn: Callable[P, R]) -> Callable[P, R]:
|
|
365
|
+
builder = self._snapshot()
|
|
366
|
+
effective_subject = subject if subject is not None else builder._subject # noqa: SLF001
|
|
367
|
+
|
|
368
|
+
if inspect.iscoroutinefunction(fn):
|
|
369
|
+
|
|
370
|
+
@functools.wraps(fn)
|
|
371
|
+
async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
372
|
+
start = time.perf_counter()
|
|
373
|
+
started_at = datetime.now(timezone.utc)
|
|
374
|
+
try:
|
|
375
|
+
result = await fn(*args, **kwargs)
|
|
376
|
+
ended_at = datetime.now(timezone.utc)
|
|
377
|
+
duration_ms = (time.perf_counter() - start) * 1000
|
|
378
|
+
|
|
379
|
+
if not on_error_only:
|
|
380
|
+
notify_result = NotifyResult(
|
|
381
|
+
function_name=fn.__name__,
|
|
382
|
+
success=True,
|
|
383
|
+
started_at=started_at,
|
|
384
|
+
ended_at=ended_at,
|
|
385
|
+
duration_ms=duration_ms,
|
|
386
|
+
return_value=result if include_return else None,
|
|
387
|
+
)
|
|
388
|
+
builder._send_notification( # noqa: SLF001
|
|
389
|
+
notify_result, effective_subject, include_return
|
|
390
|
+
)
|
|
391
|
+
return result # type: ignore[no-any-return]
|
|
392
|
+
except BaseException as exc:
|
|
393
|
+
ended_at = datetime.now(timezone.utc)
|
|
394
|
+
duration_ms = (time.perf_counter() - start) * 1000
|
|
395
|
+
tb_str = traceback.format_exc() if include_traceback else None
|
|
396
|
+
|
|
397
|
+
notify_result = NotifyResult(
|
|
398
|
+
function_name=fn.__name__,
|
|
399
|
+
success=False,
|
|
400
|
+
started_at=started_at,
|
|
401
|
+
ended_at=ended_at,
|
|
402
|
+
duration_ms=duration_ms,
|
|
403
|
+
exception=exc,
|
|
404
|
+
traceback_str=tb_str,
|
|
405
|
+
)
|
|
406
|
+
builder._send_notification( # noqa: SLF001
|
|
407
|
+
notify_result, effective_subject, include_return
|
|
408
|
+
)
|
|
409
|
+
raise
|
|
410
|
+
|
|
411
|
+
return async_wrapper # type: ignore[return-value]
|
|
412
|
+
|
|
413
|
+
@functools.wraps(fn)
|
|
414
|
+
def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
415
|
+
start = time.perf_counter()
|
|
416
|
+
started_at = datetime.now(timezone.utc)
|
|
417
|
+
try:
|
|
418
|
+
result = fn(*args, **kwargs)
|
|
419
|
+
ended_at = datetime.now(timezone.utc)
|
|
420
|
+
duration_ms = (time.perf_counter() - start) * 1000
|
|
421
|
+
|
|
422
|
+
if not on_error_only:
|
|
423
|
+
notify_result = NotifyResult(
|
|
424
|
+
function_name=fn.__name__,
|
|
425
|
+
success=True,
|
|
426
|
+
started_at=started_at,
|
|
427
|
+
ended_at=ended_at,
|
|
428
|
+
duration_ms=duration_ms,
|
|
429
|
+
return_value=result if include_return else None,
|
|
430
|
+
)
|
|
431
|
+
builder._send_notification( # noqa: SLF001
|
|
432
|
+
notify_result, effective_subject, include_return
|
|
433
|
+
)
|
|
434
|
+
return result
|
|
435
|
+
except BaseException as exc:
|
|
436
|
+
ended_at = datetime.now(timezone.utc)
|
|
437
|
+
duration_ms = (time.perf_counter() - start) * 1000
|
|
438
|
+
tb_str = traceback.format_exc() if include_traceback else None
|
|
439
|
+
|
|
440
|
+
notify_result = NotifyResult(
|
|
441
|
+
function_name=fn.__name__,
|
|
442
|
+
success=False,
|
|
443
|
+
started_at=started_at,
|
|
444
|
+
ended_at=ended_at,
|
|
445
|
+
duration_ms=duration_ms,
|
|
446
|
+
exception=exc,
|
|
447
|
+
traceback_str=tb_str,
|
|
448
|
+
)
|
|
449
|
+
builder._send_notification( # noqa: SLF001
|
|
450
|
+
notify_result, effective_subject, include_return
|
|
451
|
+
)
|
|
452
|
+
raise
|
|
453
|
+
|
|
454
|
+
return sync_wrapper
|
|
455
|
+
|
|
456
|
+
if func is not None:
|
|
457
|
+
return decorator(func)
|
|
458
|
+
return decorator
|
|
459
|
+
|
|
460
|
+
def _send_notification(
|
|
461
|
+
self,
|
|
462
|
+
result: NotifyResult,
|
|
463
|
+
subject: str,
|
|
464
|
+
include_return: bool,
|
|
465
|
+
) -> None:
|
|
466
|
+
"""Send the notification email based on execution result."""
|
|
467
|
+
if result.success:
|
|
468
|
+
body = self._format_success_body(result, include_return)
|
|
469
|
+
full_subject = f"[OK] {subject} - {result.function_name}"
|
|
470
|
+
else:
|
|
471
|
+
body = self._format_failure_body(result)
|
|
472
|
+
full_subject = f"[FAILED] {subject} - {result.function_name}"
|
|
473
|
+
|
|
474
|
+
# Create fresh message with notification content
|
|
475
|
+
self._subject = full_subject
|
|
476
|
+
self._html_body = body
|
|
477
|
+
self._plain_body = None
|
|
478
|
+
self._attachments = []
|
|
479
|
+
self._inline = []
|
|
480
|
+
|
|
481
|
+
# Don't let notification failure crash the decorated function
|
|
482
|
+
with contextlib.suppress(MailTransportError):
|
|
483
|
+
self.send()
|
|
484
|
+
|
|
485
|
+
def _format_success_body(self, result: NotifyResult, include_return: bool) -> str:
|
|
486
|
+
"""Format HTML body for successful execution notification."""
|
|
487
|
+
parts = [
|
|
488
|
+
"<h2>Function completed successfully</h2>",
|
|
489
|
+
f"<p><strong>Function:</strong> <code>{html.escape(result.function_name)}</code></p>",
|
|
490
|
+
f"<p><strong>Started:</strong> {result.started_at.isoformat()}</p>",
|
|
491
|
+
f"<p><strong>Ended:</strong> {result.ended_at.isoformat()}</p>",
|
|
492
|
+
f"<p><strong>Duration:</strong> {result.duration_ms:.2f} ms</p>",
|
|
493
|
+
]
|
|
494
|
+
|
|
495
|
+
if include_return and result.return_value is not None:
|
|
496
|
+
escaped_value = html.escape(repr(result.return_value))
|
|
497
|
+
parts.append(f"<p><strong>Return value:</strong></p><pre>{escaped_value}</pre>")
|
|
498
|
+
|
|
499
|
+
return "\n".join(parts)
|
|
500
|
+
|
|
501
|
+
def _format_failure_body(self, result: NotifyResult) -> str:
|
|
502
|
+
"""Format HTML body for failed execution notification."""
|
|
503
|
+
exc_type = type(result.exception).__name__ if result.exception else "Unknown"
|
|
504
|
+
exc_msg = str(result.exception) if result.exception else "No message"
|
|
505
|
+
|
|
506
|
+
parts = [
|
|
507
|
+
"<h2>Function execution failed</h2>",
|
|
508
|
+
f"<p><strong>Function:</strong> <code>{html.escape(result.function_name)}</code></p>",
|
|
509
|
+
f"<p><strong>Started:</strong> {result.started_at.isoformat()}</p>",
|
|
510
|
+
f"<p><strong>Ended:</strong> {result.ended_at.isoformat()}</p>",
|
|
511
|
+
f"<p><strong>Duration:</strong> {result.duration_ms:.2f} ms</p>",
|
|
512
|
+
f"<p><strong>Exception:</strong> {html.escape(exc_type)}: {html.escape(exc_msg)}</p>",
|
|
513
|
+
]
|
|
514
|
+
|
|
515
|
+
if result.traceback_str:
|
|
516
|
+
escaped_tb = html.escape(result.traceback_str)
|
|
517
|
+
parts.append(f"<h3>Traceback</h3><pre>{escaped_tb}</pre>")
|
|
518
|
+
|
|
519
|
+
return "\n".join(parts)
|
|
520
|
+
|
|
521
|
+
# ------------------------------------------------------------------
|
|
522
|
+
# Helpers
|
|
523
|
+
# ------------------------------------------------------------------
|
|
524
|
+
|
|
525
|
+
def _parse_address(self, value: str) -> EmailAddress:
|
|
526
|
+
try:
|
|
527
|
+
return parse_email_address(value)
|
|
528
|
+
except ValidationError as exc:
|
|
529
|
+
raise MailValidationError(str(exc)) from exc
|
|
530
|
+
|
|
531
|
+
def _parse_addresses(self, values: Iterable[str]) -> list[EmailAddress]:
|
|
532
|
+
try:
|
|
533
|
+
return normalize_address_list(values)
|
|
534
|
+
except ValidationError as exc:
|
|
535
|
+
raise MailValidationError(str(exc)) from exc
|
|
536
|
+
|
|
537
|
+
def _resolve_body(
|
|
538
|
+
self,
|
|
539
|
+
content: str | None,
|
|
540
|
+
template: str | Path | None,
|
|
541
|
+
placeholders: Mapping[str, Any] | None,
|
|
542
|
+
extra_placeholders: Mapping[str, Any],
|
|
543
|
+
) -> str:
|
|
544
|
+
if template is not None:
|
|
545
|
+
template_path = self._filesystem.resolve_template(template)
|
|
546
|
+
content = template_path.read_text(encoding=self._encoding)
|
|
547
|
+
|
|
548
|
+
if content is None:
|
|
549
|
+
raise MailValidationError("Message content cannot be empty")
|
|
550
|
+
|
|
551
|
+
merged: dict[str, Any] = {}
|
|
552
|
+
if placeholders:
|
|
553
|
+
merged.update(dict(placeholders))
|
|
554
|
+
if extra_placeholders:
|
|
555
|
+
merged.update(extra_placeholders)
|
|
556
|
+
if merged:
|
|
557
|
+
content = replace_placeholders(content, merged)
|
|
558
|
+
return content
|
|
559
|
+
|
|
560
|
+
def _validate_ready(self) -> EmailAddress:
|
|
561
|
+
if self._sender is None:
|
|
562
|
+
raise MailValidationError("Sender must be provided")
|
|
563
|
+
if not (self._to or self._cc or self._bcc):
|
|
564
|
+
raise MailValidationError("At least one recipient must be specified")
|
|
565
|
+
if self._plain_body is None and self._html_body is None:
|
|
566
|
+
raise MailValidationError("Message body is empty")
|
|
567
|
+
return self._sender
|
|
568
|
+
|
|
569
|
+
def _initialise_message(self, sender: EmailAddress) -> EmailMessage:
|
|
570
|
+
message = EmailMessage()
|
|
571
|
+
message["From"] = sender.formatted
|
|
572
|
+
if self._reply_to:
|
|
573
|
+
message["Reply-To"] = self._reply_to.formatted
|
|
574
|
+
if self._to:
|
|
575
|
+
message["To"] = ", ".join(addr.formatted for addr in self._to)
|
|
576
|
+
if self._cc:
|
|
577
|
+
message["Cc"] = ", ".join(addr.formatted for addr in self._cc)
|
|
578
|
+
if self._bcc:
|
|
579
|
+
message["Bcc"] = ", ".join(addr.formatted for addr in self._bcc)
|
|
580
|
+
if self._subject:
|
|
581
|
+
message["Subject"] = self._subject
|
|
582
|
+
|
|
583
|
+
plain = self._plain_body if self._plain_body is not None else ""
|
|
584
|
+
message.set_content(plain, subtype="plain", charset=self._encoding)
|
|
585
|
+
if self._html_body is not None:
|
|
586
|
+
message.add_alternative(self._html_body, subtype="html", charset=self._encoding)
|
|
587
|
+
return message
|
|
588
|
+
|
|
589
|
+
def _apply_inline_resources(self, message: EmailMessage) -> None:
|
|
590
|
+
if not self._inline:
|
|
591
|
+
return
|
|
592
|
+
html_part = message.get_body("html")
|
|
593
|
+
if html_part is None:
|
|
594
|
+
raise MailValidationError("Inline resources require an HTML body")
|
|
595
|
+
for resource in self._inline:
|
|
596
|
+
data = resource.path.read_bytes()
|
|
597
|
+
maintype, subtype = _detect_mime(resource.path)
|
|
598
|
+
html_part.add_related(
|
|
599
|
+
data,
|
|
600
|
+
maintype=maintype,
|
|
601
|
+
subtype=subtype,
|
|
602
|
+
cid=f"<{resource.cid}>",
|
|
603
|
+
filename=resource.path.name,
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
def _apply_file_attachments(self, message: EmailMessage) -> None:
|
|
607
|
+
for attachment in self._attachments:
|
|
608
|
+
data = attachment.read_bytes()
|
|
609
|
+
maintype, subtype = _detect_mime(attachment)
|
|
610
|
+
message.add_attachment(
|
|
611
|
+
data,
|
|
612
|
+
maintype=maintype,
|
|
613
|
+
subtype=subtype,
|
|
614
|
+
filename=attachment.name,
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _detect_mime(path: Path) -> tuple[str, str]:
|
|
619
|
+
guessed, _ = mimetypes.guess_type(path.name)
|
|
620
|
+
if not guessed:
|
|
621
|
+
return "application", "octet-stream"
|
|
622
|
+
maintype, subtype = guessed.split("/", 1)
|
|
623
|
+
return maintype, subtype
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
__all__ = ["MailBuilder", "NotifyResult"]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Custom exceptions for the mail module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MailError(Exception):
|
|
7
|
+
"""Base class for mail related errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MailValidationError(MailError):
|
|
11
|
+
"""Raised when provided mail data fails validation checks."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MailTransportError(MailError):
|
|
15
|
+
"""Raised when a transport backend cannot deliver a message."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MailConfigurationError(MailError):
|
|
19
|
+
"""Raised when the mail builder is missing required configuration."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"MailConfigurationError",
|
|
24
|
+
"MailError",
|
|
25
|
+
"MailTransportError",
|
|
26
|
+
"MailValidationError",
|
|
27
|
+
]
|