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
@@ -0,0 +1,651 @@
1
+ """Alert manager for orchestrating multi-channel delivery.
2
+
3
+ The AlertManager coordinates sending alerts to multiple channels with
4
+ per-channel level filtering and optional throttling.
5
+
6
+ Examples:
7
+ Basic setup::
8
+
9
+ from kstlib.alerts import AlertManager, AlertLevel
10
+ from kstlib.alerts.channels import SlackChannel, EmailChannel
11
+
12
+ manager = AlertManager()
13
+ manager.add_channel(slack_channel, min_level=AlertLevel.WARNING)
14
+ manager.add_channel(email_channel, min_level=AlertLevel.CRITICAL)
15
+
16
+ results = await manager.send(alert)
17
+
18
+ With throttling::
19
+
20
+ from kstlib.alerts.throttle import AlertThrottle
21
+
22
+ throttle = AlertThrottle(rate=10, per=60.0)
23
+ manager.add_channel(slack_channel, throttle=throttle)
24
+
25
+ Fluent API::
26
+
27
+ manager = (
28
+ AlertManager()
29
+ .add_channel(slack_channel, min_level=AlertLevel.INFO)
30
+ .add_channel(email_channel, min_level=AlertLevel.CRITICAL)
31
+ )
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import asyncio
37
+ import logging
38
+ from dataclasses import dataclass, field
39
+ from typing import TYPE_CHECKING, Any
40
+
41
+ from kstlib.alerts.channels.base import AlertChannel, AsyncAlertChannel, AsyncChannelWrapper
42
+ from kstlib.alerts.exceptions import AlertConfigurationError, AlertThrottledError
43
+ from kstlib.alerts.models import AlertLevel, AlertResult
44
+
45
+ if TYPE_CHECKING:
46
+ from collections.abc import Mapping
47
+
48
+ from typing_extensions import Self
49
+
50
+ from kstlib.alerts.models import AlertMessage
51
+ from kstlib.alerts.throttle import AlertThrottle
52
+ from kstlib.mail.transport import AsyncMailTransport, MailTransport
53
+ from kstlib.rapi.credentials import CredentialResolver
54
+
55
+ __all__ = ["AlertManager"]
56
+
57
+ log = logging.getLogger(__name__)
58
+
59
+
60
+ @dataclass
61
+ class _ChannelEntry:
62
+ """Internal entry for a registered channel."""
63
+
64
+ channel: AsyncAlertChannel
65
+ min_level: AlertLevel = AlertLevel.INFO
66
+ throttle: AlertThrottle | None = None
67
+ key: str | None = None # Config key (e.g., "hb")
68
+ alias: str | None = None # Optional alias (e.g., "heartbeat")
69
+
70
+
71
+ @dataclass
72
+ class AlertManagerStats:
73
+ """Statistics for alert manager monitoring.
74
+
75
+ Attributes:
76
+ total_sent: Total alerts successfully sent.
77
+ total_failed: Total alerts that failed delivery.
78
+ total_throttled: Total alerts dropped due to throttling.
79
+ by_channel: Per-channel statistics.
80
+ """
81
+
82
+ total_sent: int = 0
83
+ total_failed: int = 0
84
+ total_throttled: int = 0
85
+ by_channel: dict[str, dict[str, int]] = field(default_factory=dict)
86
+
87
+ def record_sent(self, channel: str) -> None:
88
+ """Record a successful send."""
89
+ self.total_sent += 1
90
+ self._ensure_channel(channel)
91
+ self.by_channel[channel]["sent"] += 1
92
+
93
+ def record_failed(self, channel: str) -> None:
94
+ """Record a failed send."""
95
+ self.total_failed += 1
96
+ self._ensure_channel(channel)
97
+ self.by_channel[channel]["failed"] += 1
98
+
99
+ def record_throttled(self, channel: str) -> None:
100
+ """Record a throttled alert."""
101
+ self.total_throttled += 1
102
+ self._ensure_channel(channel)
103
+ self.by_channel[channel]["throttled"] += 1
104
+
105
+ def _ensure_channel(self, channel: str) -> None:
106
+ """Ensure channel stats dict exists."""
107
+ if channel not in self.by_channel:
108
+ self.by_channel[channel] = {"sent": 0, "failed": 0, "throttled": 0}
109
+
110
+
111
+ class AlertManager:
112
+ """Orchestrates alert delivery to multiple channels.
113
+
114
+ Manages a collection of alert channels with per-channel level
115
+ filtering and optional throttling. Alerts are sent concurrently
116
+ to all matching channels.
117
+
118
+ Examples:
119
+ Basic usage::
120
+
121
+ manager = AlertManager()
122
+ manager.add_channel(slack_channel)
123
+ manager.add_channel(email_channel, min_level=AlertLevel.CRITICAL)
124
+
125
+ alert = AlertMessage(
126
+ title="Service Down",
127
+ body="API server not responding",
128
+ level=AlertLevel.CRITICAL,
129
+ )
130
+
131
+ results = await manager.send(alert)
132
+ for result in results:
133
+ if result.success:
134
+ print(f"{result.channel}: OK")
135
+
136
+ Fluent API::
137
+
138
+ manager = (
139
+ AlertManager()
140
+ .add_channel(slack, min_level=AlertLevel.WARNING)
141
+ .add_channel(email, min_level=AlertLevel.CRITICAL)
142
+ )
143
+
144
+ From config::
145
+
146
+ manager = AlertManager.from_config(
147
+ config=config["alerts"],
148
+ credential_resolver=resolver,
149
+ )
150
+ """
151
+
152
+ def __init__(self) -> None:
153
+ """Initialize AlertManager with no channels."""
154
+ self._channels: list[_ChannelEntry] = []
155
+ self._stats = AlertManagerStats()
156
+
157
+ @property
158
+ def stats(self) -> AlertManagerStats:
159
+ """Return statistics for this manager."""
160
+ return self._stats
161
+
162
+ @property
163
+ def channel_count(self) -> int:
164
+ """Return number of registered channels."""
165
+ return len(self._channels)
166
+
167
+ def add_channel(
168
+ self,
169
+ channel: AlertChannel | AsyncAlertChannel,
170
+ *,
171
+ min_level: AlertLevel = AlertLevel.INFO,
172
+ throttle: AlertThrottle | None = None,
173
+ key: str | None = None,
174
+ alias: str | None = None,
175
+ ) -> Self:
176
+ """Add a channel to the manager.
177
+
178
+ Args:
179
+ channel: The channel to add (sync or async).
180
+ min_level: Minimum alert level for this channel.
181
+ throttle: Optional throttle for rate limiting.
182
+ key: Config key for targeting (e.g., "hb").
183
+ alias: Human-readable alias for targeting (e.g., "heartbeat").
184
+
185
+ Returns:
186
+ Self for fluent chaining.
187
+
188
+ Examples:
189
+ >>> manager = AlertManager()
190
+ >>> manager.add_channel(slack_channel) # doctest: +SKIP
191
+ AlertManager(channels=1)
192
+ >>> manager.add_channel(email_channel, min_level=AlertLevel.CRITICAL) # doctest: +SKIP
193
+ AlertManager(channels=2)
194
+ """
195
+ # Wrap sync channels for async usage
196
+ if isinstance(channel, AlertChannel):
197
+ async_channel: AsyncAlertChannel = AsyncChannelWrapper(channel)
198
+ else:
199
+ async_channel = channel
200
+
201
+ entry = _ChannelEntry(
202
+ channel=async_channel,
203
+ min_level=min_level,
204
+ throttle=throttle,
205
+ key=key,
206
+ alias=alias,
207
+ )
208
+ self._channels.append(entry)
209
+
210
+ log.debug(
211
+ "Added channel: name=%s, min_level=%s, throttle=%s",
212
+ async_channel.name,
213
+ min_level.name,
214
+ throttle is not None,
215
+ )
216
+
217
+ return self
218
+
219
+ async def send(
220
+ self,
221
+ alert: AlertMessage | list[AlertMessage],
222
+ *,
223
+ channel: str | None = None,
224
+ ) -> list[AlertResult]:
225
+ """Send one or more alerts to matching channels.
226
+
227
+ Delivers alerts concurrently to channels where the alert level
228
+ meets the channel's minimum level. Optionally target a specific
229
+ channel by key or alias.
230
+
231
+ Args:
232
+ alert: Single alert or list of alerts to send.
233
+ channel: Optional channel key or alias to target. If None,
234
+ broadcasts to all matching channels based on level.
235
+
236
+ Returns:
237
+ Flat list of AlertResult for all alerts and channels.
238
+
239
+ Examples:
240
+ Send single alert (broadcast)::
241
+
242
+ >>> results = await manager.send(alert) # doctest: +SKIP
243
+
244
+ Send single alert to specific channel::
245
+
246
+ >>> results = await manager.send(alert, channel="hb") # doctest: +SKIP
247
+
248
+ Send multiple alerts to same channel::
249
+
250
+ >>> alerts = [alert1, alert2, alert3] # doctest: +SKIP
251
+ >>> results = await manager.send(alerts, channel="watchdog") # doctest: +SKIP
252
+ """
253
+ if not self._channels:
254
+ log.warning("No channels configured, alert not sent")
255
+ return []
256
+
257
+ # Normalize to list
258
+ alerts = [alert] if not isinstance(alert, list) else alert
259
+
260
+ if not alerts:
261
+ return []
262
+
263
+ # Get target entries (if channel specified)
264
+ target_entries: list[_ChannelEntry] | None = None
265
+ if channel is not None:
266
+ target_entries = self._find_channel(channel)
267
+ if not target_entries:
268
+ log.warning("Channel '%s' not found", channel)
269
+ return []
270
+
271
+ # Send all alerts
272
+ all_results: list[AlertResult] = []
273
+ for single_alert in alerts:
274
+ results = await self._send_alert(single_alert, target_entries)
275
+ all_results.extend(results)
276
+
277
+ return all_results
278
+
279
+ async def _send_alert(
280
+ self,
281
+ alert: AlertMessage,
282
+ target_entries: list[_ChannelEntry] | None,
283
+ ) -> list[AlertResult]:
284
+ """Send a single alert to matching channels.
285
+
286
+ Args:
287
+ alert: The alert to send.
288
+ target_entries: Specific entries to target, or None for broadcast.
289
+
290
+ Returns:
291
+ List of results for this alert.
292
+ """
293
+ # Determine matching entries
294
+ if target_entries is not None:
295
+ matching_entries = target_entries
296
+ else:
297
+ # Filter channels by level (broadcast mode)
298
+ matching_entries = [entry for entry in self._channels if alert.level >= entry.min_level]
299
+
300
+ if not matching_entries:
301
+ log.debug(
302
+ "No channels match alert level %s",
303
+ alert.level.name,
304
+ )
305
+ return []
306
+
307
+ log.debug(
308
+ "Sending alert to %d channels: level=%s, title=%r",
309
+ len(matching_entries),
310
+ alert.level.name,
311
+ alert.title[:50],
312
+ )
313
+
314
+ # Send to all matching channels concurrently
315
+ tasks = [self._send_to_entry(entry, alert) for entry in matching_entries]
316
+ results = await asyncio.gather(*tasks, return_exceptions=False)
317
+
318
+ return list(results)
319
+
320
+ def _find_channel(self, identifier: str) -> list[_ChannelEntry]:
321
+ """Find channel entry by key, alias, or channel name.
322
+
323
+ Args:
324
+ identifier: Channel key, alias, or name.
325
+
326
+ Returns:
327
+ List with matching entry, or empty list if not found.
328
+ """
329
+ for entry in self._channels:
330
+ # Match by key (e.g., "hb")
331
+ if entry.key and entry.key == identifier:
332
+ return [entry]
333
+ # Match by alias (e.g., "heartbeat")
334
+ if entry.alias and entry.alias == identifier:
335
+ return [entry]
336
+ # Match by channel name (fallback)
337
+ if entry.channel.name == identifier:
338
+ return [entry]
339
+ return []
340
+
341
+ async def _send_to_entry(
342
+ self,
343
+ entry: _ChannelEntry,
344
+ alert: AlertMessage,
345
+ ) -> AlertResult:
346
+ """Send alert to a single channel entry.
347
+
348
+ Args:
349
+ entry: The channel entry with config.
350
+ alert: The alert to send.
351
+
352
+ Returns:
353
+ AlertResult with delivery status.
354
+ """
355
+ channel_name = entry.channel.name
356
+
357
+ # Check throttle if configured
358
+ if entry.throttle is not None and not entry.throttle.try_acquire():
359
+ self._stats.record_throttled(channel_name)
360
+ log.debug("Alert throttled for channel: %s", channel_name)
361
+ return AlertResult(
362
+ channel=channel_name,
363
+ success=False,
364
+ error="Rate limit exceeded",
365
+ )
366
+
367
+ try:
368
+ result = await entry.channel.send(alert)
369
+ if result.success:
370
+ self._stats.record_sent(channel_name)
371
+ else:
372
+ self._stats.record_failed(channel_name)
373
+ return result
374
+
375
+ except AlertThrottledError as e:
376
+ self._stats.record_throttled(channel_name)
377
+ return AlertResult(
378
+ channel=channel_name,
379
+ success=False,
380
+ error=f"Throttled: retry after {e.retry_after}s",
381
+ )
382
+
383
+ except Exception as e:
384
+ self._stats.record_failed(channel_name)
385
+ log.warning(
386
+ "Channel %s failed: %s",
387
+ channel_name,
388
+ e,
389
+ )
390
+ return AlertResult(
391
+ channel=channel_name,
392
+ success=False,
393
+ error=str(e),
394
+ )
395
+
396
+ @classmethod
397
+ def from_config(
398
+ cls,
399
+ config: Mapping[str, Any],
400
+ credential_resolver: CredentialResolver | None = None,
401
+ ) -> AlertManager:
402
+ """Create AlertManager from configuration dict.
403
+
404
+ Config format::
405
+
406
+ alerts:
407
+ throttle:
408
+ rate: 10
409
+ per: 60
410
+
411
+ channels:
412
+ slack_ops:
413
+ type: slack
414
+ credentials: slack_webhook
415
+ username: "kstlib-alerts"
416
+ min_level: warning
417
+
418
+ email_critical:
419
+ type: email
420
+ transport:
421
+ type: smtp
422
+ host: smtp.example.com
423
+ sender: "alerts@example.com"
424
+ recipients: ["oncall@example.com"]
425
+ min_level: critical
426
+
427
+ Args:
428
+ config: Alerts configuration dict.
429
+ credential_resolver: Resolver for credential references.
430
+
431
+ Returns:
432
+ Configured AlertManager instance.
433
+
434
+ Raises:
435
+ AlertConfigurationError: If configuration is invalid.
436
+ """
437
+ from kstlib.alerts.channels import SlackChannel
438
+ from kstlib.alerts.throttle import AlertThrottle
439
+
440
+ manager = cls()
441
+
442
+ # Parse global throttle config
443
+ global_throttle = None
444
+ throttle_config = config.get("throttle")
445
+ if throttle_config:
446
+ global_throttle = AlertThrottle(
447
+ rate=float(throttle_config.get("rate", 10)),
448
+ per=float(throttle_config.get("per", 60)),
449
+ )
450
+
451
+ # Parse channels
452
+ channels_config = config.get("channels", {})
453
+ if not channels_config:
454
+ log.warning("No channels configured in alerts config")
455
+ return manager
456
+
457
+ for config_key, channel_config in channels_config.items():
458
+ channel_type = channel_config.get("type", "").lower()
459
+
460
+ # Extract alias from config (optional "name" field)
461
+ # If not specified, alias defaults to None (use key for targeting)
462
+ channel_alias = channel_config.get("name")
463
+
464
+ # Parse min_level
465
+ min_level_str = channel_config.get("min_level", "info").lower()
466
+ min_level = _parse_level(min_level_str)
467
+
468
+ # Parse per-channel throttle (or use global)
469
+ channel_throttle = global_throttle
470
+ if "throttle" in channel_config:
471
+ tc = channel_config["throttle"]
472
+ channel_throttle = AlertThrottle(
473
+ rate=float(tc.get("rate", 10)),
474
+ per=float(tc.get("per", 60)),
475
+ )
476
+
477
+ # Determine display name: use alias if provided, else config key
478
+ display_name = channel_alias if channel_alias else config_key
479
+
480
+ # Create channel based on type
481
+ try:
482
+ if channel_type == "slack":
483
+ channel: AlertChannel | AsyncAlertChannel = SlackChannel.from_config(
484
+ {**channel_config, "name": display_name},
485
+ credential_resolver,
486
+ )
487
+ elif channel_type == "email":
488
+ channel = _create_email_channel(
489
+ channel_config,
490
+ display_name,
491
+ credential_resolver,
492
+ )
493
+ else:
494
+ raise AlertConfigurationError(f"Unknown channel type '{channel_type}' for channel '{config_key}'")
495
+
496
+ manager.add_channel(
497
+ channel,
498
+ min_level=min_level,
499
+ throttle=channel_throttle,
500
+ key=config_key,
501
+ alias=channel_alias,
502
+ )
503
+
504
+ except Exception as e:
505
+ if isinstance(e, AlertConfigurationError):
506
+ raise
507
+ raise AlertConfigurationError(f"Failed to configure channel '{config_key}': {e}") from e
508
+
509
+ return manager
510
+
511
+ def __repr__(self) -> str:
512
+ """Return string representation."""
513
+ return f"AlertManager(channels={len(self._channels)})"
514
+
515
+
516
+ def _parse_level(level_str: str) -> AlertLevel:
517
+ """Parse alert level from string.
518
+
519
+ Args:
520
+ level_str: Level name (case-insensitive).
521
+
522
+ Returns:
523
+ AlertLevel enum value.
524
+
525
+ Raises:
526
+ AlertConfigurationError: If level is invalid.
527
+ """
528
+ level_map = {
529
+ "info": AlertLevel.INFO,
530
+ "warning": AlertLevel.WARNING,
531
+ "critical": AlertLevel.CRITICAL,
532
+ }
533
+ level = level_map.get(level_str.lower())
534
+ if level is None:
535
+ raise AlertConfigurationError(f"Invalid alert level '{level_str}'. Valid levels: {', '.join(level_map.keys())}")
536
+ return level
537
+
538
+
539
+ def _create_email_transport(
540
+ transport_config: Mapping[str, Any],
541
+ name: str,
542
+ credential_resolver: CredentialResolver | None,
543
+ ) -> MailTransport | AsyncMailTransport:
544
+ """Create a mail transport from configuration.
545
+
546
+ Args:
547
+ transport_config: Transport configuration dict.
548
+ name: Channel name for error messages.
549
+ credential_resolver: Credential resolver.
550
+
551
+ Returns:
552
+ Configured mail transport.
553
+
554
+ Raises:
555
+ AlertConfigurationError: If configuration is invalid.
556
+ """
557
+ transport_type = transport_config.get("type", "smtp").lower()
558
+
559
+ if transport_type == "smtp":
560
+ from kstlib.mail.transports import SMTPCredentials, SMTPSecurity, SMTPTransport
561
+
562
+ credentials = None
563
+ username = transport_config.get("username")
564
+ if username:
565
+ credentials = SMTPCredentials(
566
+ username=username,
567
+ password=transport_config.get("password"),
568
+ )
569
+
570
+ security = SMTPSecurity(
571
+ use_starttls=transport_config.get("use_tls", True),
572
+ )
573
+
574
+ return SMTPTransport(
575
+ host=transport_config.get("host", "localhost"),
576
+ port=int(transport_config.get("port", 587)),
577
+ credentials=credentials,
578
+ security=security,
579
+ )
580
+
581
+ if transport_type == "gmail":
582
+ # Gmail requires OAuth2 Token from kstlib.auth module
583
+ # Use GmailTransport directly with a Token object in code
584
+ raise AlertConfigurationError(
585
+ f"Gmail transport for '{name}' requires programmatic configuration. "
586
+ "Use GmailTransport(token=...) directly instead of config."
587
+ )
588
+
589
+ if transport_type == "resend":
590
+ from kstlib.mail.transports import ResendTransport
591
+
592
+ api_key = transport_config.get("api_key")
593
+ if not api_key and credential_resolver:
594
+ cred_name = transport_config.get("credentials")
595
+ if cred_name:
596
+ record = credential_resolver.resolve(cred_name)
597
+ api_key = record.value
598
+
599
+ if not api_key:
600
+ raise AlertConfigurationError(f"Resend transport for '{name}' requires 'api_key' or 'credentials'")
601
+
602
+ return ResendTransport(api_key=api_key)
603
+
604
+ raise AlertConfigurationError(f"Unknown transport type '{transport_type}' for email channel '{name}'")
605
+
606
+
607
+ def _create_email_channel(
608
+ config: Mapping[str, Any],
609
+ name: str,
610
+ credential_resolver: CredentialResolver | None,
611
+ ) -> AlertChannel | AsyncAlertChannel:
612
+ """Create an EmailChannel from config.
613
+
614
+ Args:
615
+ config: Channel configuration.
616
+ name: Channel name.
617
+ credential_resolver: Credential resolver.
618
+
619
+ Returns:
620
+ Configured EmailChannel.
621
+
622
+ Raises:
623
+ AlertConfigurationError: If configuration is invalid.
624
+ """
625
+ from kstlib.alerts.channels import EmailChannel
626
+
627
+ transport_config = config.get("transport")
628
+ if not transport_config:
629
+ raise AlertConfigurationError(f"Email channel '{name}' requires 'transport' configuration")
630
+
631
+ try:
632
+ transport = _create_email_transport(transport_config, name, credential_resolver)
633
+ except ImportError as e:
634
+ transport_type = transport_config.get("type", "smtp")
635
+ raise AlertConfigurationError(f"Missing dependency for transport '{transport_type}': {e}") from e
636
+
637
+ sender = config.get("sender")
638
+ if not sender:
639
+ raise AlertConfigurationError(f"Email channel '{name}' requires 'sender'")
640
+
641
+ recipients = config.get("recipients", [])
642
+ if not recipients:
643
+ raise AlertConfigurationError(f"Email channel '{name}' requires 'recipients'")
644
+
645
+ return EmailChannel(
646
+ transport=transport,
647
+ sender=sender,
648
+ recipients=recipients,
649
+ subject_prefix=config.get("subject_prefix", "[ALERT]"),
650
+ channel_name=name,
651
+ )