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,392 @@
1
+ """MonitoringService orchestrator for data collection, rendering, and delivery.
2
+
3
+ The MonitoringService provides a high-level API for building monitoring dashboards:
4
+
5
+ 1. **Collect** - Run data collectors (sync or async callables)
6
+ 2. **Render** - Generate HTML using Jinja2 templates and render types
7
+ 3. **Deliver** - Send via email (kstlib.mail) or save to file
8
+
9
+ Examples:
10
+ Basic usage with collectors:
11
+
12
+ >>> from kstlib.monitoring import MonitoringService, StatusCell, StatusLevel
13
+ >>> service = MonitoringService(
14
+ ... template=\"\"\"<p>Status: {{ status | render }}</p>\"\"\",
15
+ ... collectors={
16
+ ... "status": lambda: StatusCell("UP", StatusLevel.OK),
17
+ ... },
18
+ ... )
19
+ >>> result = service.run_sync()
20
+ >>> "status-ok" in result.html
21
+ True
22
+
23
+ Async collectors for real-time data:
24
+
25
+ >>> async def get_metrics():
26
+ ... # Fetch from API, database, etc.
27
+ ... return {"cpu": 75.2, "memory": 8192}
28
+ >>> service = MonitoringService(
29
+ ... template=\"\"\"<p>CPU: {{ metrics.cpu }}%</p>\"\"\",
30
+ ... collectors={"metrics": get_metrics},
31
+ ... )
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import asyncio
37
+ import inspect
38
+ from collections.abc import Awaitable, Callable
39
+ from dataclasses import dataclass, field
40
+ from datetime import datetime, timezone
41
+ from typing import TYPE_CHECKING, Any
42
+
43
+ from kstlib.monitoring.exceptions import CollectorError, RenderError
44
+ from kstlib.monitoring.renderer import create_environment
45
+
46
+ if TYPE_CHECKING:
47
+ from email.message import EmailMessage
48
+
49
+ from kstlib.mail.transport import AsyncMailTransport, MailTransport
50
+
51
+ # Type alias for collectors: sync or async callables returning any data
52
+ Collector = Callable[[], Any] | Callable[[], Awaitable[Any]]
53
+
54
+
55
+ @dataclass
56
+ class MonitoringResult:
57
+ """Result of a monitoring run.
58
+
59
+ Attributes:
60
+ html: The rendered HTML string.
61
+ data: The collected data dictionary.
62
+ collected_at: Timestamp when data was collected.
63
+ rendered_at: Timestamp when HTML was rendered.
64
+ errors: List of collector errors (if fail_fast=False).
65
+ """
66
+
67
+ html: str
68
+ data: dict[str, Any]
69
+ collected_at: datetime
70
+ rendered_at: datetime
71
+ errors: list[CollectorError] = field(default_factory=list)
72
+
73
+ @property
74
+ def success(self) -> bool:
75
+ """Return True if no collector errors occurred."""
76
+ return len(self.errors) == 0
77
+
78
+
79
+ class MonitoringService:
80
+ """Orchestrator for collecting, rendering, and delivering monitoring dashboards.
81
+
82
+ The service manages the full lifecycle of a monitoring dashboard:
83
+
84
+ 1. **Collectors** - Register data sources as sync or async callables
85
+ 2. **Template** - Jinja2 template with ``| render`` filter support
86
+ 3. **Render** - Generate HTML with automatic CSS handling
87
+ 4. **Deliver** - Optional email delivery via kstlib.mail transports
88
+
89
+ Args:
90
+ template: Jinja2 template string for rendering the dashboard.
91
+ collectors: Optional dict mapping names to collector callables.
92
+ inline_css: If True (default), use inline CSS for email compatibility.
93
+ fail_fast: If True, raise on first collector error. If False, continue
94
+ and report errors in the result.
95
+
96
+ Examples:
97
+ Simple dashboard with status cell:
98
+
99
+ >>> from kstlib.monitoring import MonitoringService, StatusCell, StatusLevel
100
+ >>> service = MonitoringService(
101
+ ... template=\"\"\"<div>{{ status | render }}</div>\"\"\",
102
+ ... collectors={"status": lambda: StatusCell("OK", StatusLevel.OK)},
103
+ ... )
104
+ >>> result = service.run_sync()
105
+ >>> "OK" in result.html
106
+ True
107
+
108
+ Adding collectors after construction (chainable):
109
+
110
+ >>> service = MonitoringService(template="<p>{{ msg }}</p>")
111
+ >>> service.add_collector("msg", lambda: "Hello").run_sync().html
112
+ '<p>Hello</p>'
113
+ """
114
+
115
+ def __init__(
116
+ self,
117
+ template: str,
118
+ collectors: dict[str, Collector] | None = None,
119
+ *,
120
+ inline_css: bool = True,
121
+ fail_fast: bool = True,
122
+ ) -> None:
123
+ """Initialize the monitoring service.
124
+
125
+ Args:
126
+ template: Jinja2 template string.
127
+ collectors: Optional initial collectors dict.
128
+ inline_css: Use inline CSS for email compatibility.
129
+ fail_fast: Raise immediately on collector errors.
130
+ """
131
+ self._template = template
132
+ self._collectors: dict[str, Collector] = dict(collectors) if collectors else {}
133
+ self._inline_css = inline_css
134
+ self._fail_fast = fail_fast
135
+ self._env = create_environment()
136
+
137
+ @property
138
+ def template(self) -> str:
139
+ """Return the template string."""
140
+ return self._template
141
+
142
+ @property
143
+ def inline_css(self) -> bool:
144
+ """Return whether inline CSS is enabled."""
145
+ return self._inline_css
146
+
147
+ @property
148
+ def collector_names(self) -> list[str]:
149
+ """Return list of registered collector names."""
150
+ return list(self._collectors)
151
+
152
+ def add_collector(self, name: str, collector: Collector) -> MonitoringService:
153
+ """Add a data collector.
154
+
155
+ Collectors are callables (sync or async) that return data to be passed
156
+ to the template. The returned data can be any type including Renderable
157
+ objects like StatusCell, MonitorTable, etc.
158
+
159
+ Args:
160
+ name: Name to use in the template (e.g., "status" for {{ status }}).
161
+ collector: Callable returning the data. Can be sync or async.
162
+
163
+ Returns:
164
+ Self for method chaining.
165
+
166
+ Examples:
167
+ >>> service = MonitoringService(template="<p>{{ x }} + {{ y }}</p>")
168
+ >>> service.add_collector("x", lambda: 1).add_collector("y", lambda: 2)
169
+ <kstlib.monitoring.service.MonitoringService object at ...>
170
+ """
171
+ self._collectors[name] = collector
172
+ return self
173
+
174
+ def remove_collector(self, name: str) -> MonitoringService:
175
+ """Remove a collector by name.
176
+
177
+ Args:
178
+ name: Name of the collector to remove.
179
+
180
+ Returns:
181
+ Self for method chaining.
182
+
183
+ Raises:
184
+ KeyError: If collector name not found.
185
+ """
186
+ del self._collectors[name]
187
+ return self
188
+
189
+ def _cancel_tasks(self, tasks: dict[str, asyncio.Task[Any]], exclude: str = "") -> None:
190
+ """Cancel all pending async tasks except the excluded one."""
191
+ for name, task in tasks.items():
192
+ if name != exclude and not task.done():
193
+ task.cancel()
194
+
195
+ def _handle_collector_error(
196
+ self,
197
+ name: str,
198
+ exc: Exception,
199
+ errors: list[CollectorError],
200
+ tasks: dict[str, asyncio.Task[Any]],
201
+ ) -> CollectorError:
202
+ """Handle a collector error: cancel tasks if fail_fast, else append to errors."""
203
+ error = CollectorError(name, exc)
204
+ if self._fail_fast:
205
+ self._cancel_tasks(tasks)
206
+ raise error from exc
207
+ errors.append(error)
208
+ return error
209
+
210
+ async def collect(self) -> tuple[dict[str, Any], list[CollectorError]]:
211
+ """Run all collectors and gather data.
212
+
213
+ Collectors are run concurrently when possible. Async collectors are
214
+ awaited, sync collectors are called directly.
215
+
216
+ Returns:
217
+ Tuple of (collected data dict, list of errors).
218
+
219
+ Raises:
220
+ CollectorError: If fail_fast=True and any collector fails.
221
+ """
222
+ data: dict[str, Any] = {}
223
+ errors: list[CollectorError] = []
224
+ async_tasks: dict[str, asyncio.Task[Any]] = {}
225
+ sync_collectors: dict[str, Collector] = {}
226
+
227
+ # Separate and schedule collectors
228
+ for name, collector in self._collectors.items():
229
+ if inspect.iscoroutinefunction(collector):
230
+ async_tasks[name] = asyncio.create_task(collector())
231
+ else:
232
+ sync_collectors[name] = collector
233
+
234
+ # Run sync collectors
235
+ for name, collector in sync_collectors.items():
236
+ try:
237
+ data[name] = collector()
238
+ except Exception as e:
239
+ self._handle_collector_error(name, e, errors, async_tasks)
240
+ data[name] = None
241
+
242
+ # Await async collectors
243
+ for name, task in async_tasks.items():
244
+ try:
245
+ data[name] = await task
246
+ except asyncio.CancelledError:
247
+ raise
248
+ except Exception as e:
249
+ self._handle_collector_error(name, e, errors, async_tasks)
250
+ data[name] = None
251
+
252
+ return data, errors
253
+
254
+ def render(self, data: dict[str, Any]) -> str:
255
+ """Render the template with collected data.
256
+
257
+ Args:
258
+ data: Dictionary of data to pass to the template.
259
+
260
+ Returns:
261
+ Rendered HTML string.
262
+
263
+ Raises:
264
+ RenderError: If template rendering fails.
265
+ """
266
+ from kstlib.monitoring._styles import get_css_classes
267
+
268
+ try:
269
+ template = self._env.from_string(self._template)
270
+ html = template.render(**data)
271
+ except Exception as e:
272
+ raise RenderError(f"Template rendering failed: {e}") from e
273
+
274
+ # Prepend CSS classes if not using inline CSS
275
+ if not self._inline_css:
276
+ html = get_css_classes() + "\n" + html
277
+
278
+ return html
279
+
280
+ async def run(self) -> MonitoringResult:
281
+ """Collect data and render the dashboard.
282
+
283
+ This is the main entry point for async usage. It runs all collectors,
284
+ renders the template, and returns a MonitoringResult.
285
+
286
+ Returns:
287
+ MonitoringResult with HTML, data, timestamps, and any errors.
288
+
289
+ Examples:
290
+ >>> import asyncio
291
+ >>> service = MonitoringService(
292
+ ... template="<p>{{ msg }}</p>",
293
+ ... collectors={"msg": lambda: "Hello"},
294
+ ... )
295
+ >>> result = asyncio.run(service.run())
296
+ >>> "Hello" in result.html
297
+ True
298
+ """
299
+ collected_at = datetime.now(timezone.utc)
300
+ data, errors = await self.collect()
301
+
302
+ html = self.render(data)
303
+ rendered_at = datetime.now(timezone.utc)
304
+
305
+ return MonitoringResult(
306
+ html=html,
307
+ data=data,
308
+ collected_at=collected_at,
309
+ rendered_at=rendered_at,
310
+ errors=errors,
311
+ )
312
+
313
+ def run_sync(self) -> MonitoringResult:
314
+ """Synchronous version of run().
315
+
316
+ Convenience method for non-async contexts. Creates a new event loop
317
+ if needed.
318
+
319
+ Returns:
320
+ MonitoringResult with HTML, data, timestamps, and any errors.
321
+
322
+ Examples:
323
+ >>> service = MonitoringService(
324
+ ... template="<p>{{ msg }}</p>",
325
+ ... collectors={"msg": lambda: "World"},
326
+ ... )
327
+ >>> "World" in service.run_sync().html
328
+ True
329
+ """
330
+ try:
331
+ loop = asyncio.get_running_loop()
332
+ except RuntimeError:
333
+ loop = None
334
+
335
+ if loop is not None:
336
+ # Already in an async context - can't use asyncio.run
337
+ # Use a new thread or raise
338
+ import concurrent.futures
339
+
340
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
341
+ future = executor.submit(asyncio.run, self.run())
342
+ return future.result()
343
+
344
+ return asyncio.run(self.run())
345
+
346
+ async def deliver(
347
+ self,
348
+ transport: MailTransport | AsyncMailTransport,
349
+ message_builder: Callable[[str], EmailMessage],
350
+ ) -> MonitoringResult:
351
+ """Collect, render, and deliver via email transport.
352
+
353
+ This combines run() with email delivery. The message_builder callable
354
+ receives the rendered HTML and should return a complete EmailMessage.
355
+
356
+ Args:
357
+ transport: Mail transport (sync or async) from kstlib.mail.
358
+ message_builder: Callable that takes HTML and returns EmailMessage.
359
+
360
+ Returns:
361
+ MonitoringResult from the run.
362
+
363
+ Examples:
364
+ >>> from email.message import EmailMessage
365
+ >>> def build_message(html: str) -> EmailMessage:
366
+ ... msg = EmailMessage()
367
+ ... msg["From"] = "bot@example.com"
368
+ ... msg["To"] = "team@example.com"
369
+ ... msg["Subject"] = "Dashboard"
370
+ ... msg.set_content(html, subtype="html")
371
+ ... return msg
372
+ """
373
+ result = await self.run()
374
+
375
+ message = message_builder(result.html)
376
+
377
+ # Check if transport is async or sync
378
+ if hasattr(transport, "send") and inspect.iscoroutinefunction(transport.send):
379
+ await transport.send(message)
380
+ else:
381
+ # Sync transport - run in thread pool
382
+ loop = asyncio.get_running_loop()
383
+ await loop.run_in_executor(None, transport.send, message)
384
+
385
+ return result
386
+
387
+
388
+ __all__ = [
389
+ "Collector",
390
+ "MonitoringResult",
391
+ "MonitoringService",
392
+ ]
@@ -0,0 +1,129 @@
1
+ """MonitorTable render type for tabular status displays.
2
+
3
+ A ``MonitorTable`` renders as an HTML ``<table>`` with striped rows,
4
+ styled headers, and support for ``StatusCell`` values within cells.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import html
10
+ from dataclasses import dataclass, field
11
+ from typing import TYPE_CHECKING
12
+
13
+ from kstlib.monitoring._styles import (
14
+ TABLE_BORDER_COLOR,
15
+ TABLE_FONT_FAMILY,
16
+ TABLE_HEADER_BG,
17
+ TABLE_HEADER_TEXT,
18
+ TABLE_ROW_BG,
19
+ TABLE_ROW_TEXT,
20
+ TABLE_STRIPE_BG,
21
+ )
22
+ from kstlib.monitoring.cell import StatusCell
23
+ from kstlib.monitoring.exceptions import RenderError
24
+
25
+ if TYPE_CHECKING:
26
+ from kstlib.monitoring.types import CellValue
27
+
28
+
29
+ @dataclass(slots=True)
30
+ class MonitorTable:
31
+ """A table rendered as an HTML ``<table>`` with striped rows.
32
+
33
+ This is the only mutable render type: rows are added via :meth:`add_row`.
34
+
35
+ Attributes:
36
+ headers: Column headers.
37
+ title: Optional caption rendered above the table.
38
+
39
+ Examples:
40
+ >>> from kstlib.monitoring.table import MonitorTable
41
+ >>> t = MonitorTable(headers=["Service", "Status"])
42
+ >>> t.add_row(["API", "OK"])
43
+ >>> "<table" in t.render()
44
+ True
45
+ """
46
+
47
+ headers: list[str]
48
+ title: str = ""
49
+ _rows: list[list[CellValue | StatusCell]] = field(default_factory=list, init=False, repr=False)
50
+
51
+ def add_row(self, row: list[CellValue | StatusCell]) -> None:
52
+ """Append a row to the table.
53
+
54
+ Args:
55
+ row: List of cell values matching the number of headers.
56
+
57
+ Raises:
58
+ RenderError: If the row length does not match the header count.
59
+ """
60
+ if len(row) != len(self.headers):
61
+ msg = f"Row has {len(row)} cells but table has {len(self.headers)} headers"
62
+ raise RenderError(msg)
63
+ self._rows.append(row)
64
+
65
+ @property
66
+ def row_count(self) -> int:
67
+ """Return the number of data rows."""
68
+ return len(self._rows)
69
+
70
+ def _render_cell(self, cell: CellValue | StatusCell, *, inline_css: bool) -> str:
71
+ """Render a single cell value as an HTML ``<td>`` element."""
72
+ rendered = cell.render(inline_css=inline_css) if isinstance(cell, StatusCell) else html.escape(str(cell))
73
+ if inline_css:
74
+ td_style = f"padding:8px 12px;border-bottom:1px solid {TABLE_BORDER_COLOR}"
75
+ return f'<td style="{td_style}">{rendered}</td>'
76
+ return f"<td>{rendered}</td>"
77
+
78
+ def _render_header(self, text: str, *, inline_css: bool) -> str:
79
+ """Render a single header as an HTML ``<th>`` element."""
80
+ escaped = html.escape(text)
81
+ if inline_css:
82
+ th_style = f"background:{TABLE_HEADER_BG};color:{TABLE_HEADER_TEXT};padding:8px 12px;text-align:left"
83
+ return f'<th style="{th_style}">{escaped}</th>'
84
+ return f"<th>{escaped}</th>"
85
+
86
+ def render(self, *, inline_css: bool = False) -> str:
87
+ """Render the table as an HTML ``<table>``.
88
+
89
+ Args:
90
+ inline_css: If True, use inline styles instead of CSS classes.
91
+
92
+ Returns:
93
+ HTML ``<table>`` string.
94
+ """
95
+ parts: list[str] = []
96
+
97
+ if self.title:
98
+ escaped_title = html.escape(self.title)
99
+ parts.append(f"<h3>{escaped_title}</h3>")
100
+
101
+ if inline_css:
102
+ table_style = f"border-collapse:collapse;width:100%;font-family:{TABLE_FONT_FAMILY};background:{TABLE_ROW_BG};color:{TABLE_ROW_TEXT}"
103
+ parts.append(f'<table style="{table_style}">')
104
+ else:
105
+ parts.append('<table class="monitor-table">')
106
+
107
+ # Header row
108
+ parts.append("<thead><tr>")
109
+ parts.extend(self._render_header(h, inline_css=inline_css) for h in self.headers)
110
+ parts.append("</tr></thead>")
111
+
112
+ # Data rows
113
+ parts.append("<tbody>")
114
+ for idx, row in enumerate(self._rows):
115
+ if inline_css and idx % 2 == 1:
116
+ parts.append(f'<tr style="background:{TABLE_STRIPE_BG}">')
117
+ else:
118
+ parts.append("<tr>")
119
+ parts.extend(self._render_cell(c, inline_css=inline_css) for c in row)
120
+ parts.append("</tr>")
121
+ parts.append("</tbody>")
122
+
123
+ parts.append("</table>")
124
+ return "".join(parts)
125
+
126
+
127
+ __all__ = [
128
+ "MonitorTable",
129
+ ]
@@ -0,0 +1,56 @@
1
+ """Core types for the kstlib.monitoring module.
2
+
3
+ Defines ``StatusLevel`` enum, ``Renderable`` protocol, and ``CellValue`` alias.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from enum import IntEnum
9
+ from typing import Protocol, runtime_checkable
10
+
11
+
12
+ class StatusLevel(IntEnum):
13
+ """Severity level for monitoring status indicators.
14
+
15
+ Values are ordered by severity so comparisons work naturally:
16
+ ``StatusLevel.OK < StatusLevel.WARNING < StatusLevel.ERROR``.
17
+
18
+ Attributes:
19
+ OK: Normal operation (#16A085 green).
20
+ WARNING: Degraded but functional (#F1C40F yellow).
21
+ ERROR: Service failure (#E85A4F red).
22
+ CRITICAL: Critical failure requiring immediate action (#c0392b dark red).
23
+ """
24
+
25
+ OK = 10
26
+ WARNING = 20
27
+ ERROR = 30
28
+ CRITICAL = 40
29
+
30
+
31
+ @runtime_checkable
32
+ class Renderable(Protocol):
33
+ """Protocol for objects that can render themselves as HTML."""
34
+
35
+ def render(self, *, inline_css: bool = False) -> str:
36
+ """Render this object as an HTML string.
37
+
38
+ Args:
39
+ inline_css: If True, embed styles as inline ``style`` attributes
40
+ instead of CSS class references. Useful for email rendering.
41
+
42
+ Returns:
43
+ HTML string representation.
44
+ """
45
+ ... # pragma: no cover
46
+
47
+
48
+ #: Type alias for values accepted in monitoring cells.
49
+ #: Covers primitive scalar types that can appear in tables, KV pairs, and lists.
50
+ CellValue = str | int | float | bool
51
+
52
+ __all__ = [
53
+ "CellValue",
54
+ "Renderable",
55
+ "StatusLevel",
56
+ ]
kstlib/ops/__init__.py ADDED
@@ -0,0 +1,86 @@
1
+ """Session management with tmux and container backends.
2
+
3
+ This module provides config-driven session management for running
4
+ persistent processes like trading bots. It supports two backends:
5
+
6
+ - **tmux**: For local development and backtesting with detach/attach
7
+ - **container**: For production with Podman/Docker and persistent logs
8
+
9
+ The module is designed around the principle of backend abstraction,
10
+ allowing the same code to work with either tmux sessions or containers.
11
+
12
+ Examples:
13
+ Local development with tmux:
14
+
15
+ >>> from kstlib.ops import SessionManager
16
+ >>> session = SessionManager("astro", backend="tmux")
17
+ >>> session.start("python -m astro.bot") # doctest: +SKIP
18
+ >>> session.attach() # tmux attach-session -t astro # doctest: +SKIP
19
+
20
+ Production with containers:
21
+
22
+ >>> session = SessionManager(
23
+ ... "astro",
24
+ ... backend="container",
25
+ ... image="astro-bot:latest",
26
+ ... )
27
+ >>> session.start() # doctest: +SKIP
28
+ >>> session.attach() # podman attach astro # doctest: +SKIP
29
+
30
+ Config-driven usage:
31
+
32
+ >>> session = SessionManager.from_config("astro") # doctest: +SKIP
33
+ >>> session.start() # doctest: +SKIP
34
+
35
+ Note:
36
+ The attach() method uses os.execvp and replaces the current process.
37
+ It does not return on success.
38
+ """
39
+
40
+ from kstlib.ops.base import AbstractRunner
41
+ from kstlib.ops.container import ContainerRunner
42
+ from kstlib.ops.exceptions import (
43
+ BackendNotFoundError,
44
+ ContainerRuntimeNotFoundError,
45
+ OpsError,
46
+ SessionAmbiguousError,
47
+ SessionAttachError,
48
+ SessionError,
49
+ SessionExistsError,
50
+ SessionNotFoundError,
51
+ SessionStartError,
52
+ SessionStopError,
53
+ TmuxNotFoundError,
54
+ )
55
+ from kstlib.ops.manager import SessionConfigError, SessionManager, auto_detect_backend
56
+ from kstlib.ops.models import (
57
+ BackendType,
58
+ SessionConfig,
59
+ SessionState,
60
+ SessionStatus,
61
+ )
62
+ from kstlib.ops.tmux import TmuxRunner
63
+
64
+ __all__ = [
65
+ "AbstractRunner",
66
+ "BackendNotFoundError",
67
+ "BackendType",
68
+ "ContainerRunner",
69
+ "ContainerRuntimeNotFoundError",
70
+ "OpsError",
71
+ "SessionAmbiguousError",
72
+ "SessionAttachError",
73
+ "SessionConfig",
74
+ "SessionConfigError",
75
+ "SessionError",
76
+ "SessionExistsError",
77
+ "SessionManager",
78
+ "SessionNotFoundError",
79
+ "SessionStartError",
80
+ "SessionState",
81
+ "SessionStatus",
82
+ "SessionStopError",
83
+ "TmuxNotFoundError",
84
+ "TmuxRunner",
85
+ "auto_detect_backend",
86
+ ]