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.
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.0.dist-info/METADATA +201 -0
  159. kstlib-1.0.0.dist-info/RECORD +163 -0
  160. {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/WHEEL +1 -1
  161. kstlib-1.0.0.dist-info/entry_points.txt +2 -0
  162. kstlib-1.0.0.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.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,579 @@
1
+ """Delivery backends for monitoring results.
2
+
3
+ This module provides delivery mechanisms for MonitoringResult outputs:
4
+
5
+ - **FileDelivery**: Save HTML to local files with rotation
6
+ - **MailDelivery**: Send via kstlib.mail transports (wrapper)
7
+
8
+ Examples:
9
+ Save to file:
10
+
11
+ >>> from kstlib.monitoring.delivery import FileDelivery
12
+ >>> delivery = FileDelivery(output_dir="./reports") # doctest: +SKIP
13
+ >>> result = await delivery.deliver(monitoring_result, "daily") # doctest: +SKIP
14
+ >>> print(result.path) # doctest: +SKIP
15
+
16
+ Send via email:
17
+
18
+ >>> from kstlib.monitoring.delivery import MailDelivery
19
+ >>> delivery = MailDelivery( # doctest: +SKIP
20
+ ... transport=gmail_transport,
21
+ ... sender="bot@example.com",
22
+ ... recipients=["team@example.com"],
23
+ ... )
24
+ >>> result = await delivery.deliver(monitoring_result, "Daily Report") # doctest: +SKIP
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import asyncio
30
+ import pathlib
31
+ import re
32
+ from abc import ABC, abstractmethod
33
+ from dataclasses import dataclass, field
34
+ from datetime import datetime, timezone
35
+ from email.message import EmailMessage
36
+ from typing import TYPE_CHECKING, Any
37
+
38
+ from kstlib.monitoring.exceptions import MonitoringError
39
+
40
+ if TYPE_CHECKING:
41
+ from kstlib.mail.transport import AsyncMailTransport, MailTransport
42
+ from kstlib.monitoring.service import MonitoringResult
43
+
44
+ # Deep defense: Security limits
45
+ MAX_OUTPUT_DIR_DEPTH = 10 # Maximum directory depth from cwd
46
+ MAX_FILENAME_LENGTH = 200 # Maximum filename length
47
+ MAX_FILES_PER_DIR = 1000 # Maximum files to keep in output directory
48
+ MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB max output file size
49
+ MAX_RECIPIENTS = 50 # Maximum email recipients
50
+ MAX_SUBJECT_LENGTH = 200 # Maximum email subject length
51
+
52
+ # Filename validation pattern (alphanumeric, dash, underscore, dot)
53
+ SAFE_FILENAME_PATTERN = re.compile(r"^[a-zA-Z0-9_\-\.]+$")
54
+
55
+
56
+ class DeliveryError(MonitoringError):
57
+ """Base exception for delivery errors."""
58
+
59
+
60
+ class DeliveryConfigError(DeliveryError, ValueError):
61
+ """Invalid delivery configuration."""
62
+
63
+
64
+ class DeliveryIOError(DeliveryError, OSError):
65
+ """I/O error during delivery."""
66
+
67
+
68
+ @dataclass(frozen=True, slots=True)
69
+ class DeliveryResult:
70
+ """Result of a delivery operation.
71
+
72
+ Attributes:
73
+ success: Whether delivery succeeded.
74
+ timestamp: When delivery was attempted.
75
+ path: Output file path (for file delivery).
76
+ message_id: Email message ID (for mail delivery).
77
+ error: Error message if delivery failed.
78
+ metadata: Additional delivery metadata.
79
+ """
80
+
81
+ success: bool
82
+ timestamp: datetime
83
+ path: pathlib.Path | None = None
84
+ message_id: str | None = None
85
+ error: str | None = None
86
+ metadata: dict[str, Any] = field(default_factory=dict)
87
+
88
+
89
+ class DeliveryBackend(ABC):
90
+ """Abstract base class for delivery backends."""
91
+
92
+ @abstractmethod
93
+ async def deliver(
94
+ self,
95
+ result: MonitoringResult,
96
+ name: str,
97
+ ) -> DeliveryResult:
98
+ """Deliver a monitoring result.
99
+
100
+ Args:
101
+ result: The MonitoringResult to deliver.
102
+ name: Name/subject for this delivery.
103
+
104
+ Returns:
105
+ DeliveryResult with success status and metadata.
106
+ """
107
+
108
+
109
+ def _validate_path_safety(path: pathlib.Path, base_dir: pathlib.Path) -> None:
110
+ """Validate path is within allowed directory (deep defense)."""
111
+ try:
112
+ resolved = path.resolve()
113
+ base_resolved = base_dir.resolve()
114
+ resolved.relative_to(base_resolved)
115
+ except ValueError as e:
116
+ raise DeliveryConfigError(f"Path traversal detected: {path}") from e
117
+
118
+
119
+ def _sanitize_filename(name: str, timestamp: datetime) -> str:
120
+ """Create a safe filename from name and timestamp."""
121
+ # Remove unsafe characters
122
+ safe_name = re.sub(r"[^a-zA-Z0-9_\-]", "_", name)
123
+ # Limit length
124
+ safe_name = safe_name[:50]
125
+ # Add timestamp
126
+ ts = timestamp.strftime("%Y%m%d_%H%M%S")
127
+ return f"{safe_name}_{ts}.html"
128
+
129
+
130
+ @dataclass
131
+ class FileDeliveryConfig:
132
+ """Configuration for file delivery.
133
+
134
+ Attributes:
135
+ output_dir: Directory to save files.
136
+ filename_template: Template for filenames (supports {name}, {timestamp}).
137
+ create_dirs: Create output directory if missing.
138
+ max_files: Maximum files to keep (oldest deleted, 0=unlimited).
139
+ encoding: File encoding.
140
+ """
141
+
142
+ output_dir: str | pathlib.Path
143
+ filename_template: str = "{name}_{timestamp}.html"
144
+ create_dirs: bool = True
145
+ max_files: int = 100
146
+ encoding: str = "utf-8"
147
+
148
+ def __post_init__(self) -> None:
149
+ """Validate configuration after initialization."""
150
+ if isinstance(self.output_dir, str):
151
+ object.__setattr__(self, "output_dir", pathlib.Path(self.output_dir))
152
+
153
+ # Deep defense: Validate max_files
154
+ if self.max_files < 0:
155
+ raise DeliveryConfigError("max_files cannot be negative")
156
+ if self.max_files > MAX_FILES_PER_DIR:
157
+ raise DeliveryConfigError(f"max_files exceeds limit ({MAX_FILES_PER_DIR})")
158
+
159
+
160
+ class FileDelivery(DeliveryBackend):
161
+ """Deliver monitoring results to local files.
162
+
163
+ Saves HTML output to files with automatic rotation and cleanup.
164
+
165
+ Args:
166
+ output_dir: Directory to save files (str or Path).
167
+ filename_template: Template for filenames.
168
+ create_dirs: Create output directory if missing.
169
+ max_files: Maximum files to keep (oldest deleted when exceeded).
170
+ encoding: File encoding.
171
+
172
+ Examples:
173
+ >>> delivery = FileDelivery(output_dir="./reports") # doctest: +SKIP
174
+ >>> result = await delivery.deliver(monitoring_result, "daily") # doctest: +SKIP
175
+ >>> print(f"Saved to: {result.path}") # doctest: +SKIP
176
+
177
+ With rotation (keep last 7 files):
178
+
179
+ >>> delivery = FileDelivery( # doctest: +SKIP
180
+ ... output_dir="./reports",
181
+ ... max_files=7,
182
+ ... )
183
+ """
184
+
185
+ def __init__(
186
+ self,
187
+ output_dir: str | pathlib.Path,
188
+ *,
189
+ filename_template: str = "{name}_{timestamp}.html",
190
+ create_dirs: bool = True,
191
+ max_files: int = 100,
192
+ encoding: str = "utf-8",
193
+ ) -> None:
194
+ """Initialize file delivery backend."""
195
+ self._config = FileDeliveryConfig(
196
+ output_dir=pathlib.Path(output_dir),
197
+ filename_template=filename_template,
198
+ create_dirs=create_dirs,
199
+ max_files=max_files,
200
+ encoding=encoding,
201
+ )
202
+ self._last_result: DeliveryResult | None = None
203
+
204
+ @property
205
+ def config(self) -> FileDeliveryConfig:
206
+ """Return the delivery configuration."""
207
+ return self._config
208
+
209
+ @property
210
+ def last_result(self) -> DeliveryResult | None:
211
+ """Return the last delivery result."""
212
+ return self._last_result
213
+
214
+ def _generate_filename(self, name: str, timestamp: datetime) -> str:
215
+ """Generate filename from template."""
216
+ ts_str = timestamp.strftime("%Y%m%d_%H%M%S")
217
+ # Sanitize name
218
+ safe_name = re.sub(r"[^a-zA-Z0-9_\-]", "_", name)[:50]
219
+ filename = self._config.filename_template.format(
220
+ name=safe_name,
221
+ timestamp=ts_str,
222
+ )
223
+ # Deep defense: Validate final filename
224
+ if len(filename) > MAX_FILENAME_LENGTH:
225
+ raise DeliveryConfigError(f"Generated filename too long ({len(filename)} > {MAX_FILENAME_LENGTH})")
226
+ return filename
227
+
228
+ def _cleanup_old_files(self, output_dir: pathlib.Path) -> int:
229
+ """Remove oldest files if max_files exceeded. Returns count deleted."""
230
+ if self._config.max_files == 0:
231
+ return 0
232
+
233
+ html_files = sorted(
234
+ output_dir.glob("*.html"),
235
+ key=lambda p: p.stat().st_mtime,
236
+ )
237
+ to_delete = len(html_files) - self._config.max_files
238
+ deleted = 0
239
+
240
+ if to_delete > 0:
241
+ for old_file in html_files[:to_delete]:
242
+ try:
243
+ old_file.unlink()
244
+ deleted += 1
245
+ except OSError:
246
+ pass # Best effort cleanup
247
+
248
+ return deleted
249
+
250
+ def _validate_output_dir(self, output_dir: pathlib.Path) -> None:
251
+ """Validate output directory depth and existence (deep defense)."""
252
+ try:
253
+ cwd = pathlib.Path.cwd().resolve()
254
+ rel_path = output_dir.relative_to(cwd)
255
+ if len(rel_path.parts) > MAX_OUTPUT_DIR_DEPTH:
256
+ raise DeliveryConfigError(f"Output directory too deep ({len(rel_path.parts)} > {MAX_OUTPUT_DIR_DEPTH})")
257
+ except ValueError:
258
+ # Path not relative to cwd, check absolute depth
259
+ if len(output_dir.parts) > MAX_OUTPUT_DIR_DEPTH + 5:
260
+ raise DeliveryConfigError("Output directory path too deep") from None
261
+
262
+ # Create directory if needed
263
+ if self._config.create_dirs:
264
+ output_dir.mkdir(parents=True, exist_ok=True)
265
+
266
+ if not output_dir.is_dir():
267
+ raise DeliveryConfigError(f"Output directory does not exist: {output_dir}")
268
+
269
+ async def deliver(
270
+ self,
271
+ result: MonitoringResult,
272
+ name: str,
273
+ ) -> DeliveryResult:
274
+ """Save monitoring result HTML to a file.
275
+
276
+ Args:
277
+ result: The MonitoringResult to save.
278
+ name: Name for this report (used in filename).
279
+
280
+ Returns:
281
+ DeliveryResult with file path on success.
282
+
283
+ Raises:
284
+ DeliveryIOError: If file cannot be written.
285
+ DeliveryConfigError: If configuration is invalid.
286
+ """
287
+ timestamp = datetime.now(timezone.utc)
288
+ delivery_result: DeliveryResult | None = None
289
+
290
+ try:
291
+ output_dir = pathlib.Path(self._config.output_dir).resolve()
292
+
293
+ # Deep defense: Validate directory
294
+ self._validate_output_dir(output_dir)
295
+
296
+ # Generate filename
297
+ filename = self._generate_filename(name, timestamp)
298
+ output_path = output_dir / filename
299
+
300
+ # Deep defense: Validate path safety
301
+ _validate_path_safety(output_path, output_dir)
302
+
303
+ # Deep defense: Check content size
304
+ html_bytes = result.html.encode(self._config.encoding)
305
+ if len(html_bytes) > MAX_FILE_SIZE:
306
+ raise DeliveryConfigError(f"Output too large ({len(html_bytes)} > {MAX_FILE_SIZE} bytes)")
307
+
308
+ # Write file (run in executor for async compatibility)
309
+ loop = asyncio.get_running_loop()
310
+ await loop.run_in_executor(
311
+ None,
312
+ lambda: output_path.write_bytes(html_bytes),
313
+ )
314
+
315
+ # Cleanup old files
316
+ deleted = await loop.run_in_executor(
317
+ None,
318
+ lambda: self._cleanup_old_files(output_dir),
319
+ )
320
+
321
+ delivery_result = DeliveryResult(
322
+ success=True,
323
+ timestamp=timestamp,
324
+ path=output_path,
325
+ metadata={
326
+ "size_bytes": len(html_bytes),
327
+ "files_deleted": deleted,
328
+ "encoding": self._config.encoding,
329
+ },
330
+ )
331
+
332
+ except DeliveryError as e:
333
+ delivery_result = DeliveryResult(
334
+ success=False,
335
+ timestamp=timestamp,
336
+ error=str(e),
337
+ )
338
+ raise
339
+ except OSError as e:
340
+ delivery_result = DeliveryResult(
341
+ success=False,
342
+ timestamp=timestamp,
343
+ error=f"I/O error: {e}",
344
+ )
345
+ raise DeliveryIOError(f"Failed to write file: {e}") from e
346
+ except Exception as e:
347
+ delivery_result = DeliveryResult(
348
+ success=False,
349
+ timestamp=timestamp,
350
+ error=str(e),
351
+ )
352
+ raise DeliveryError(f"Unexpected error during delivery: {e}") from e
353
+ finally:
354
+ if delivery_result is not None:
355
+ self._last_result = delivery_result
356
+
357
+ return delivery_result
358
+
359
+
360
+ @dataclass
361
+ class MailDeliveryConfig:
362
+ """Configuration for mail delivery.
363
+
364
+ Attributes:
365
+ sender: Sender email address.
366
+ recipients: List of recipient addresses.
367
+ cc: List of CC addresses.
368
+ bcc: List of BCC addresses.
369
+ subject_template: Subject template (supports {name}).
370
+ include_plain_text: Include plain text version.
371
+ """
372
+
373
+ sender: str
374
+ recipients: list[str]
375
+ cc: list[str] = field(default_factory=list)
376
+ bcc: list[str] = field(default_factory=list)
377
+ subject_template: str = "Monitoring Report: {name}"
378
+ include_plain_text: bool = True
379
+
380
+ def __post_init__(self) -> None:
381
+ """Validate configuration after initialization."""
382
+ if not self.sender:
383
+ raise DeliveryConfigError("Sender address is required")
384
+ if not self.recipients:
385
+ raise DeliveryConfigError("At least one recipient is required")
386
+
387
+ # Deep defense: Limit recipients
388
+ total_recipients = len(self.recipients) + len(self.cc) + len(self.bcc)
389
+ if total_recipients > MAX_RECIPIENTS:
390
+ raise DeliveryConfigError(f"Too many recipients ({total_recipients} > {MAX_RECIPIENTS})")
391
+
392
+
393
+ class MailDelivery(DeliveryBackend):
394
+ """Deliver monitoring results via email.
395
+
396
+ Wraps kstlib.mail transports for monitoring delivery.
397
+
398
+ Args:
399
+ transport: Mail transport (sync or async).
400
+ sender: Sender email address.
401
+ recipients: List of recipient addresses.
402
+ cc: Optional CC addresses.
403
+ bcc: Optional BCC addresses.
404
+ subject_template: Subject template with {name} placeholder.
405
+ include_plain_text: Include plain text version of HTML.
406
+
407
+ Examples:
408
+ >>> from kstlib.mail.transports.gmail import GmailTransport
409
+ >>> transport = GmailTransport(...) # doctest: +SKIP
410
+ >>> delivery = MailDelivery( # doctest: +SKIP
411
+ ... transport=transport,
412
+ ... sender="bot@example.com",
413
+ ... recipients=["team@example.com"],
414
+ ... )
415
+ >>> result = await delivery.deliver(monitoring_result, "Daily Report") # doctest: +SKIP
416
+ """
417
+
418
+ def __init__(
419
+ self,
420
+ transport: MailTransport | AsyncMailTransport,
421
+ config: MailDeliveryConfig,
422
+ ) -> None:
423
+ """Initialize mail delivery backend.
424
+
425
+ Args:
426
+ transport: Mail transport (sync or async).
427
+ config: Mail delivery configuration.
428
+ """
429
+ self._transport = transport
430
+ self._config = config
431
+ self._last_result: DeliveryResult | None = None
432
+
433
+ @classmethod
434
+ def create(
435
+ cls,
436
+ transport: MailTransport | AsyncMailTransport,
437
+ sender: str,
438
+ recipients: list[str],
439
+ **kwargs: Any,
440
+ ) -> MailDelivery:
441
+ """Create a MailDelivery with configuration.
442
+
443
+ Convenience factory method that creates the config internally.
444
+
445
+ Args:
446
+ transport: Mail transport (sync or async).
447
+ sender: Sender email address.
448
+ recipients: List of recipient addresses.
449
+ **kwargs: Additional config options (cc, bcc, subject_template, etc.).
450
+
451
+ Returns:
452
+ Configured MailDelivery instance.
453
+ """
454
+ config = MailDeliveryConfig(
455
+ sender=sender,
456
+ recipients=list(recipients),
457
+ cc=list(kwargs.get("cc", [])) if kwargs.get("cc") else [],
458
+ bcc=list(kwargs.get("bcc", [])) if kwargs.get("bcc") else [],
459
+ subject_template=kwargs.get("subject_template", "Monitoring Report: {name}"),
460
+ include_plain_text=kwargs.get("include_plain_text", True),
461
+ )
462
+ return cls(transport, config)
463
+
464
+ @property
465
+ def config(self) -> MailDeliveryConfig:
466
+ """Return the delivery configuration."""
467
+ return self._config
468
+
469
+ @property
470
+ def last_result(self) -> DeliveryResult | None:
471
+ """Return the last delivery result."""
472
+ return self._last_result
473
+
474
+ def _build_message(self, html: str, subject: str) -> EmailMessage:
475
+ """Build EmailMessage from HTML content."""
476
+ msg = EmailMessage()
477
+ msg["From"] = self._config.sender
478
+ msg["To"] = ", ".join(self._config.recipients)
479
+ if self._config.cc:
480
+ msg["Cc"] = ", ".join(self._config.cc)
481
+ if self._config.bcc:
482
+ msg["Bcc"] = ", ".join(self._config.bcc)
483
+
484
+ # Deep defense: Validate subject length
485
+ if len(subject) > MAX_SUBJECT_LENGTH:
486
+ subject = subject[: MAX_SUBJECT_LENGTH - 3] + "..."
487
+
488
+ msg["Subject"] = subject
489
+
490
+ if self._config.include_plain_text:
491
+ # Create multipart message with plain and HTML
492
+ # Simple HTML to text conversion (strip tags)
493
+ plain_text = re.sub(r"<[^>]+>", "", html)
494
+ plain_text = re.sub(r"\s+", " ", plain_text).strip()
495
+
496
+ msg.set_content(plain_text)
497
+ msg.add_alternative(html, subtype="html")
498
+ else:
499
+ msg.set_content(html, subtype="html")
500
+
501
+ return msg
502
+
503
+ async def deliver(
504
+ self,
505
+ result: MonitoringResult,
506
+ name: str,
507
+ ) -> DeliveryResult:
508
+ """Send monitoring result via email.
509
+
510
+ Args:
511
+ result: The MonitoringResult to send.
512
+ name: Name for this report (used in subject).
513
+
514
+ Returns:
515
+ DeliveryResult with message ID on success.
516
+
517
+ Raises:
518
+ DeliveryError: If email cannot be sent.
519
+ """
520
+ import inspect
521
+
522
+ timestamp = datetime.now(timezone.utc)
523
+
524
+ try:
525
+ # Generate subject
526
+ subject = self._config.subject_template.format(name=name)
527
+
528
+ # Build message
529
+ message = self._build_message(result.html, subject)
530
+
531
+ # Send via transport
532
+ if hasattr(self._transport, "send") and inspect.iscoroutinefunction(self._transport.send):
533
+ await self._transport.send(message)
534
+ else:
535
+ # Sync transport - run in executor
536
+ loop = asyncio.get_running_loop()
537
+ await loop.run_in_executor(None, self._transport.send, message)
538
+
539
+ # Try to get message ID from transport response
540
+ message_id = None
541
+ if hasattr(self._transport, "last_response"):
542
+ resp = self._transport.last_response
543
+ if resp and hasattr(resp, "id"):
544
+ message_id = resp.id
545
+
546
+ delivery_result = DeliveryResult(
547
+ success=True,
548
+ timestamp=timestamp,
549
+ message_id=message_id,
550
+ metadata={
551
+ "recipients": len(self._config.recipients),
552
+ "subject": subject,
553
+ },
554
+ )
555
+
556
+ except Exception as e:
557
+ delivery_result = DeliveryResult(
558
+ success=False,
559
+ timestamp=timestamp,
560
+ error=str(e),
561
+ )
562
+ raise DeliveryError(f"Failed to send email: {e}") from e
563
+ finally:
564
+ self._last_result = delivery_result
565
+
566
+ return delivery_result
567
+
568
+
569
+ __all__ = [
570
+ "DeliveryBackend",
571
+ "DeliveryConfigError",
572
+ "DeliveryError",
573
+ "DeliveryIOError",
574
+ "DeliveryResult",
575
+ "FileDelivery",
576
+ "FileDeliveryConfig",
577
+ "MailDelivery",
578
+ "MailDeliveryConfig",
579
+ ]
@@ -0,0 +1,63 @@
1
+ """Specialized exceptions raised by the kstlib.monitoring module.
2
+
3
+ Exception hierarchy::
4
+
5
+ KstlibError
6
+ MonitoringError (base)
7
+ CollectorError
8
+ RenderError
9
+ MonitoringConfigError
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from kstlib.config.exceptions import KstlibError
15
+
16
+
17
+ class MonitoringError(KstlibError):
18
+ """Base exception for all monitoring errors."""
19
+
20
+
21
+ class CollectorError(MonitoringError):
22
+ """Error during data collection.
23
+
24
+ Raised when a collector callable fails during MonitoringService.collect().
25
+
26
+ Attributes:
27
+ collector_name: Name of the failed collector.
28
+ cause: The underlying exception that caused the failure.
29
+ """
30
+
31
+ def __init__(self, collector_name: str, cause: Exception) -> None:
32
+ """Initialize with collector name and underlying cause.
33
+
34
+ Args:
35
+ collector_name: Name of the failed collector.
36
+ cause: The exception that caused the failure.
37
+ """
38
+ self.collector_name = collector_name
39
+ self.cause = cause
40
+ super().__init__(f"Collector '{collector_name}' failed: {cause}")
41
+
42
+
43
+ class RenderError(MonitoringError, ValueError):
44
+ """HTML rendering failed.
45
+
46
+ Raised when a renderable object cannot produce valid HTML output,
47
+ for example due to inconsistent data dimensions or template errors.
48
+ """
49
+
50
+
51
+ class MonitoringConfigError(MonitoringError):
52
+ """Base exception for monitoring configuration errors.
53
+
54
+ Raised when a monitoring configuration file cannot be loaded or parsed.
55
+ """
56
+
57
+
58
+ __all__ = [
59
+ "CollectorError",
60
+ "MonitoringConfigError",
61
+ "MonitoringError",
62
+ "RenderError",
63
+ ]