kstlib 0.0.1a0__py3-none-any.whl → 1.0.1__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.
Files changed (166) hide show
  1. kstlib/__init__.py +266 -1
  2. kstlib/__main__.py +16 -0
  3. kstlib/alerts/__init__.py +110 -0
  4. kstlib/alerts/channels/__init__.py +36 -0
  5. kstlib/alerts/channels/base.py +197 -0
  6. kstlib/alerts/channels/email.py +227 -0
  7. kstlib/alerts/channels/slack.py +389 -0
  8. kstlib/alerts/exceptions.py +72 -0
  9. kstlib/alerts/manager.py +651 -0
  10. kstlib/alerts/models.py +142 -0
  11. kstlib/alerts/throttle.py +263 -0
  12. kstlib/auth/__init__.py +139 -0
  13. kstlib/auth/callback.py +399 -0
  14. kstlib/auth/config.py +502 -0
  15. kstlib/auth/errors.py +127 -0
  16. kstlib/auth/models.py +316 -0
  17. kstlib/auth/providers/__init__.py +14 -0
  18. kstlib/auth/providers/base.py +393 -0
  19. kstlib/auth/providers/oauth2.py +645 -0
  20. kstlib/auth/providers/oidc.py +821 -0
  21. kstlib/auth/session.py +338 -0
  22. kstlib/auth/token.py +482 -0
  23. kstlib/cache/__init__.py +50 -0
  24. kstlib/cache/decorator.py +261 -0
  25. kstlib/cache/strategies.py +516 -0
  26. kstlib/cli/__init__.py +8 -0
  27. kstlib/cli/app.py +195 -0
  28. kstlib/cli/commands/__init__.py +5 -0
  29. kstlib/cli/commands/auth/__init__.py +39 -0
  30. kstlib/cli/commands/auth/common.py +122 -0
  31. kstlib/cli/commands/auth/login.py +325 -0
  32. kstlib/cli/commands/auth/logout.py +74 -0
  33. kstlib/cli/commands/auth/providers.py +57 -0
  34. kstlib/cli/commands/auth/status.py +291 -0
  35. kstlib/cli/commands/auth/token.py +199 -0
  36. kstlib/cli/commands/auth/whoami.py +106 -0
  37. kstlib/cli/commands/config.py +89 -0
  38. kstlib/cli/commands/ops/__init__.py +39 -0
  39. kstlib/cli/commands/ops/attach.py +49 -0
  40. kstlib/cli/commands/ops/common.py +269 -0
  41. kstlib/cli/commands/ops/list_sessions.py +252 -0
  42. kstlib/cli/commands/ops/logs.py +49 -0
  43. kstlib/cli/commands/ops/start.py +98 -0
  44. kstlib/cli/commands/ops/status.py +138 -0
  45. kstlib/cli/commands/ops/stop.py +60 -0
  46. kstlib/cli/commands/rapi/__init__.py +60 -0
  47. kstlib/cli/commands/rapi/call.py +341 -0
  48. kstlib/cli/commands/rapi/list.py +99 -0
  49. kstlib/cli/commands/rapi/show.py +206 -0
  50. kstlib/cli/commands/secrets/__init__.py +35 -0
  51. kstlib/cli/commands/secrets/common.py +425 -0
  52. kstlib/cli/commands/secrets/decrypt.py +88 -0
  53. kstlib/cli/commands/secrets/doctor.py +743 -0
  54. kstlib/cli/commands/secrets/encrypt.py +242 -0
  55. kstlib/cli/commands/secrets/shred.py +96 -0
  56. kstlib/cli/common.py +86 -0
  57. kstlib/config/__init__.py +76 -0
  58. kstlib/config/exceptions.py +110 -0
  59. kstlib/config/export.py +225 -0
  60. kstlib/config/loader.py +963 -0
  61. kstlib/config/sops.py +287 -0
  62. kstlib/db/__init__.py +54 -0
  63. kstlib/db/aiosqlcipher.py +137 -0
  64. kstlib/db/cipher.py +112 -0
  65. kstlib/db/database.py +367 -0
  66. kstlib/db/exceptions.py +25 -0
  67. kstlib/db/pool.py +302 -0
  68. kstlib/helpers/__init__.py +35 -0
  69. kstlib/helpers/exceptions.py +11 -0
  70. kstlib/helpers/time_trigger.py +396 -0
  71. kstlib/kstlib.conf.yml +890 -0
  72. kstlib/limits.py +963 -0
  73. kstlib/logging/__init__.py +108 -0
  74. kstlib/logging/manager.py +633 -0
  75. kstlib/mail/__init__.py +42 -0
  76. kstlib/mail/builder.py +626 -0
  77. kstlib/mail/exceptions.py +27 -0
  78. kstlib/mail/filesystem.py +248 -0
  79. kstlib/mail/transport.py +224 -0
  80. kstlib/mail/transports/__init__.py +19 -0
  81. kstlib/mail/transports/gmail.py +268 -0
  82. kstlib/mail/transports/resend.py +324 -0
  83. kstlib/mail/transports/smtp.py +326 -0
  84. kstlib/meta.py +72 -0
  85. kstlib/metrics/__init__.py +88 -0
  86. kstlib/metrics/decorators.py +1090 -0
  87. kstlib/metrics/exceptions.py +14 -0
  88. kstlib/monitoring/__init__.py +116 -0
  89. kstlib/monitoring/_styles.py +163 -0
  90. kstlib/monitoring/cell.py +57 -0
  91. kstlib/monitoring/config.py +424 -0
  92. kstlib/monitoring/delivery.py +579 -0
  93. kstlib/monitoring/exceptions.py +63 -0
  94. kstlib/monitoring/image.py +220 -0
  95. kstlib/monitoring/kv.py +79 -0
  96. kstlib/monitoring/list.py +69 -0
  97. kstlib/monitoring/metric.py +88 -0
  98. kstlib/monitoring/monitoring.py +341 -0
  99. kstlib/monitoring/renderer.py +139 -0
  100. kstlib/monitoring/service.py +392 -0
  101. kstlib/monitoring/table.py +129 -0
  102. kstlib/monitoring/types.py +56 -0
  103. kstlib/ops/__init__.py +86 -0
  104. kstlib/ops/base.py +148 -0
  105. kstlib/ops/container.py +577 -0
  106. kstlib/ops/exceptions.py +209 -0
  107. kstlib/ops/manager.py +407 -0
  108. kstlib/ops/models.py +176 -0
  109. kstlib/ops/tmux.py +372 -0
  110. kstlib/ops/validators.py +287 -0
  111. kstlib/py.typed +0 -0
  112. kstlib/rapi/__init__.py +118 -0
  113. kstlib/rapi/client.py +875 -0
  114. kstlib/rapi/config.py +861 -0
  115. kstlib/rapi/credentials.py +887 -0
  116. kstlib/rapi/exceptions.py +213 -0
  117. kstlib/resilience/__init__.py +101 -0
  118. kstlib/resilience/circuit_breaker.py +440 -0
  119. kstlib/resilience/exceptions.py +95 -0
  120. kstlib/resilience/heartbeat.py +491 -0
  121. kstlib/resilience/rate_limiter.py +506 -0
  122. kstlib/resilience/shutdown.py +417 -0
  123. kstlib/resilience/watchdog.py +637 -0
  124. kstlib/secrets/__init__.py +29 -0
  125. kstlib/secrets/exceptions.py +19 -0
  126. kstlib/secrets/models.py +62 -0
  127. kstlib/secrets/providers/__init__.py +79 -0
  128. kstlib/secrets/providers/base.py +58 -0
  129. kstlib/secrets/providers/environment.py +66 -0
  130. kstlib/secrets/providers/keyring.py +107 -0
  131. kstlib/secrets/providers/kms.py +223 -0
  132. kstlib/secrets/providers/kwargs.py +101 -0
  133. kstlib/secrets/providers/sops.py +209 -0
  134. kstlib/secrets/resolver.py +221 -0
  135. kstlib/secrets/sensitive.py +130 -0
  136. kstlib/secure/__init__.py +23 -0
  137. kstlib/secure/fs.py +194 -0
  138. kstlib/secure/permissions.py +70 -0
  139. kstlib/ssl.py +347 -0
  140. kstlib/ui/__init__.py +23 -0
  141. kstlib/ui/exceptions.py +26 -0
  142. kstlib/ui/panels.py +484 -0
  143. kstlib/ui/spinner.py +864 -0
  144. kstlib/ui/tables.py +382 -0
  145. kstlib/utils/__init__.py +48 -0
  146. kstlib/utils/dict.py +36 -0
  147. kstlib/utils/formatting.py +338 -0
  148. kstlib/utils/http_trace.py +237 -0
  149. kstlib/utils/lazy.py +49 -0
  150. kstlib/utils/secure_delete.py +205 -0
  151. kstlib/utils/serialization.py +247 -0
  152. kstlib/utils/text.py +56 -0
  153. kstlib/utils/validators.py +124 -0
  154. kstlib/websocket/__init__.py +97 -0
  155. kstlib/websocket/exceptions.py +214 -0
  156. kstlib/websocket/manager.py +1102 -0
  157. kstlib/websocket/models.py +361 -0
  158. kstlib-1.0.1.dist-info/METADATA +201 -0
  159. kstlib-1.0.1.dist-info/RECORD +163 -0
  160. {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.dist-info}/WHEEL +1 -1
  161. kstlib-1.0.1.dist-info/entry_points.txt +2 -0
  162. kstlib-1.0.1.dist-info/licenses/LICENSE.md +9 -0
  163. kstlib-0.0.1a0.dist-info/METADATA +0 -29
  164. kstlib-0.0.1a0.dist-info/RECORD +0 -6
  165. kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
  166. {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.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
+ ]