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,341 @@
1
+ """Simplified Monitoring API with decorator-based collectors.
2
+
3
+ This module provides a streamlined API for monitoring dashboards:
4
+
5
+ - Config in YAML (template, delivery settings)
6
+ - Collectors in Python (via @decorator)
7
+ - Simple run() to collect, render, and deliver
8
+
9
+ Examples:
10
+ Basic usage with decorators:
11
+
12
+ >>> from kstlib.monitoring import Monitoring, MonitorKV
13
+ >>> mon = Monitoring(template="<p>{{ info | render }}</p>")
14
+ >>> @mon.collector
15
+ ... def info():
16
+ ... return MonitorKV(items={"status": "OK"})
17
+ >>> result = mon.run_sync()
18
+ >>> "OK" in result.html
19
+ True
20
+
21
+ Load from config:
22
+
23
+ >>> mon = Monitoring.from_config() # doctest: +SKIP
24
+ >>> @mon.collector # doctest: +SKIP
25
+ ... def metrics(): # doctest: +SKIP
26
+ ... return collect_metrics() # doctest: +SKIP
27
+ >>> mon.run_sync() # doctest: +SKIP
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import asyncio
33
+ from collections.abc import Callable
34
+ from pathlib import Path
35
+ from typing import TYPE_CHECKING, Any, TypeVar
36
+
37
+ from kstlib.monitoring.service import Collector, MonitoringResult, MonitoringService
38
+
39
+ if TYPE_CHECKING:
40
+ from kstlib.monitoring.delivery import DeliveryBackend, DeliveryResult
41
+
42
+ F = TypeVar("F", bound=Callable[..., Any])
43
+
44
+
45
+ class Monitoring:
46
+ """Simplified monitoring with decorator-based collectors.
47
+
48
+ This class provides a cleaner API than MonitoringService:
49
+
50
+ - Collectors are registered via ``@mon.collector`` decorator
51
+ - Config loaded from ``kstlib.conf.yml`` section ``monitoring:``
52
+ - Automatic template_file resolution
53
+ - Integrated delivery
54
+
55
+ Args:
56
+ template: Jinja2 template string (mutually exclusive with template_file).
57
+ template_file: Path to template file (mutually exclusive with template).
58
+ inline_css: Use inline CSS for email compatibility (default True).
59
+ fail_fast: Raise on first collector error (default False).
60
+ delivery: Optional delivery backend (FileDelivery or MailDelivery).
61
+ name: Dashboard name (for delivery subject, default "monitoring").
62
+
63
+ Examples:
64
+ Direct instantiation:
65
+
66
+ >>> mon = Monitoring(template="<p>{{ msg }}</p>")
67
+ >>> @mon.collector
68
+ ... def msg():
69
+ ... return "Hello"
70
+ >>> mon.run_sync().html
71
+ '<p>Hello</p>'
72
+
73
+ From config file:
74
+
75
+ >>> mon = Monitoring.from_config() # doctest: +SKIP
76
+ """
77
+
78
+ def __init__( # noqa: PLR0913
79
+ self,
80
+ *,
81
+ template: str | None = None,
82
+ template_file: str | Path | None = None,
83
+ inline_css: bool = True,
84
+ fail_fast: bool = False,
85
+ delivery: DeliveryBackend | _DeferredMailDelivery | None = None,
86
+ name: str = "monitoring",
87
+ ) -> None:
88
+ """Initialize monitoring instance."""
89
+ if template is None and template_file is None:
90
+ raise ValueError("Either 'template' or 'template_file' is required")
91
+ if template is not None and template_file is not None:
92
+ raise ValueError("Cannot specify both 'template' and 'template_file'")
93
+
94
+ # Resolve template
95
+ if template_file is not None:
96
+ path = Path(template_file)
97
+ if not path.exists():
98
+ raise FileNotFoundError(f"Template not found: {path}")
99
+ template = path.read_text(encoding="utf-8")
100
+
101
+ # After validation, template is guaranteed to be str
102
+ assert template is not None # for mypy
103
+ self._template: str = template
104
+ self._inline_css = inline_css
105
+ self._fail_fast = fail_fast
106
+ self._delivery: DeliveryBackend | _DeferredMailDelivery | None = delivery
107
+ self._name = name
108
+ self._collectors: dict[str, Collector] = {}
109
+
110
+ @classmethod
111
+ def from_config(
112
+ cls,
113
+ config_section: str = "monitoring",
114
+ *,
115
+ base_dir: Path | None = None,
116
+ ) -> Monitoring:
117
+ """Create Monitoring instance from kstlib.conf.yml.
118
+
119
+ Loads the ``monitoring:`` section from the config file.
120
+
121
+ Args:
122
+ config_section: Config section name (default "monitoring").
123
+ base_dir: Base directory for resolving template_file paths.
124
+ Defaults to current working directory.
125
+
126
+ Returns:
127
+ Configured Monitoring instance.
128
+
129
+ Raises:
130
+ ValueError: If config section is missing or invalid.
131
+
132
+ Examples:
133
+ >>> mon = Monitoring.from_config() # doctest: +SKIP
134
+ >>> @mon.collector # doctest: +SKIP
135
+ ... def data(): # doctest: +SKIP
136
+ ... return {"key": "value"} # doctest: +SKIP
137
+ """
138
+ from kstlib.config import get_config, load_config
139
+
140
+ # Load config if not already loaded
141
+ try:
142
+ config = get_config()
143
+ except Exception:
144
+ config = load_config()
145
+
146
+ # Get monitoring section (Box.get is untyped)
147
+ mon_config: dict[str, Any] = config.get(config_section, {}) # type: ignore[no-untyped-call]
148
+ if not mon_config:
149
+ raise ValueError(f"Config section '{config_section}' not found or empty")
150
+
151
+ # Resolve base directory
152
+ if base_dir is None:
153
+ base_dir = Path.cwd()
154
+
155
+ # Extract settings
156
+ template = mon_config.get("template")
157
+ template_file = mon_config.get("template_file")
158
+
159
+ # Resolve template_file path
160
+ if template_file is not None:
161
+ template_file = base_dir / template_file
162
+
163
+ # Build delivery backend if configured
164
+ delivery = None
165
+ delivery_config = mon_config.get("delivery")
166
+ if delivery_config:
167
+ delivery = cls._build_delivery(delivery_config)
168
+
169
+ return cls(
170
+ template=template,
171
+ template_file=template_file,
172
+ inline_css=mon_config.get("inline_css", True),
173
+ fail_fast=mon_config.get("fail_fast", False),
174
+ delivery=delivery,
175
+ name=mon_config.get("name", "monitoring"),
176
+ )
177
+
178
+ @staticmethod
179
+ def _build_delivery(config: dict[str, Any]) -> DeliveryBackend | _DeferredMailDelivery:
180
+ """Build delivery backend from config dict."""
181
+ from kstlib.monitoring.delivery import FileDelivery
182
+
183
+ delivery_type = config.get("type", "file")
184
+
185
+ if delivery_type == "file":
186
+ return FileDelivery(
187
+ output_dir=config.get("output_dir", "./reports"),
188
+ max_files=config.get("max_files", 100),
189
+ )
190
+
191
+ if delivery_type == "mail":
192
+ # Mail delivery requires transport - defer to run() time
193
+ # Store config for later
194
+ return _DeferredMailDelivery(config)
195
+
196
+ raise ValueError(f"Unknown delivery type: {delivery_type}")
197
+
198
+ @property
199
+ def name(self) -> str:
200
+ """Return dashboard name."""
201
+ return self._name
202
+
203
+ @property
204
+ def collector_names(self) -> list[str]:
205
+ """Return list of registered collector names."""
206
+ return list(self._collectors)
207
+
208
+ def collector(self, func: F) -> F:
209
+ """Decorator to register a collector function.
210
+
211
+ The function name becomes the template variable name.
212
+
213
+ Args:
214
+ func: Function returning data for the template.
215
+
216
+ Returns:
217
+ The original function (unmodified).
218
+
219
+ Examples:
220
+ >>> mon = Monitoring(template="{{ status }}")
221
+ >>> @mon.collector
222
+ ... def status():
223
+ ... return "OK"
224
+ >>> mon.run_sync().html
225
+ 'OK'
226
+ """
227
+ self._collectors[func.__name__] = func
228
+ return func
229
+
230
+ def add_collector(self, name: str, func: Collector) -> Monitoring:
231
+ """Add a collector with explicit name.
232
+
233
+ Use this when you need a different name than the function name.
234
+
235
+ Args:
236
+ name: Name to use in template.
237
+ func: Collector function.
238
+
239
+ Returns:
240
+ Self for chaining.
241
+ """
242
+ self._collectors[name] = func
243
+ return self
244
+
245
+ def _create_service(self) -> MonitoringService:
246
+ """Create the underlying MonitoringService."""
247
+ return MonitoringService(
248
+ template=self._template,
249
+ collectors=self._collectors,
250
+ inline_css=self._inline_css,
251
+ fail_fast=self._fail_fast,
252
+ )
253
+
254
+ async def run(self, *, deliver: bool = True) -> MonitoringResult:
255
+ """Collect data, render template, and optionally deliver.
256
+
257
+ Args:
258
+ deliver: If True and delivery is configured, send the result.
259
+
260
+ Returns:
261
+ MonitoringResult with HTML and metadata.
262
+ """
263
+ service = self._create_service()
264
+ result = await service.run()
265
+
266
+ # Deliver if configured and requested
267
+ if deliver and self._delivery is not None:
268
+ await self._deliver(result)
269
+
270
+ return result
271
+
272
+ def run_sync(self, *, deliver: bool = True) -> MonitoringResult:
273
+ """Synchronous version of run().
274
+
275
+ Args:
276
+ deliver: If True and delivery is configured, send the result.
277
+
278
+ Returns:
279
+ MonitoringResult with HTML and metadata.
280
+ """
281
+ try:
282
+ loop = asyncio.get_running_loop()
283
+ except RuntimeError:
284
+ loop = None
285
+
286
+ if loop is not None:
287
+ import concurrent.futures
288
+
289
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
290
+ future = executor.submit(asyncio.run, self.run(deliver=deliver))
291
+ return future.result()
292
+
293
+ return asyncio.run(self.run(deliver=deliver))
294
+
295
+ async def _deliver(self, result: MonitoringResult) -> DeliveryResult:
296
+ """Deliver the result using configured backend."""
297
+ if self._delivery is None:
298
+ raise RuntimeError("No delivery backend configured")
299
+
300
+ if isinstance(self._delivery, _DeferredMailDelivery):
301
+ # Build actual mail delivery with transport
302
+ delivery = await self._delivery.build()
303
+ return await delivery.deliver(result, self._name)
304
+
305
+ return await self._delivery.deliver(result, self._name)
306
+
307
+
308
+ class _DeferredMailDelivery:
309
+ """Placeholder for mail delivery that needs OAuth token at runtime."""
310
+
311
+ def __init__(self, config: dict[str, Any]) -> None:
312
+ self._config = config
313
+
314
+ async def build(self) -> DeliveryBackend:
315
+ """Build actual MailDelivery with OAuth transport."""
316
+ from kstlib.auth import OAuth2Provider
317
+ from kstlib.mail.transports import GmailTransport
318
+ from kstlib.monitoring.delivery import MailDelivery, MailDeliveryConfig
319
+
320
+ # Get OAuth token
321
+ provider = OAuth2Provider.from_config("google")
322
+ token = provider.get_token()
323
+ if token is None or token.is_expired:
324
+ raise RuntimeError("Gmail token not available or expired. Run 'kstlib auth login google' first.")
325
+
326
+ transport = GmailTransport(token=token)
327
+
328
+ config = MailDeliveryConfig(
329
+ sender=self._config.get("sender", ""),
330
+ recipients=self._config.get("recipients", []),
331
+ cc=self._config.get("cc", []),
332
+ bcc=self._config.get("bcc", []),
333
+ subject_template=self._config.get("subject_template", "Monitoring: {name}"),
334
+ )
335
+
336
+ return MailDelivery(transport=transport, config=config)
337
+
338
+
339
+ __all__ = [
340
+ "Monitoring",
341
+ ]
@@ -0,0 +1,139 @@
1
+ """Jinja2 rendering integration for monitoring render types.
2
+
3
+ Provides a Jinja2 filter ``render`` that dispatches to ``Renderable.render()``
4
+ for monitoring objects, and HTML-escapes primitives. Includes a pre-configured
5
+ environment factory and a standalone template rendering helper.
6
+
7
+ Examples:
8
+ Render a template with monitoring objects:
9
+
10
+ >>> from kstlib.monitoring.renderer import render_template
11
+ >>> from kstlib.monitoring.cell import StatusCell
12
+ >>> from kstlib.monitoring.types import StatusLevel
13
+ >>> cell = StatusCell("UP", StatusLevel.OK)
14
+ >>> html = render_template("<p>{{ s | render }}</p>", {"s": cell})
15
+ >>> "status-ok" in html
16
+ True
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import html as _html
22
+ from typing import Any
23
+
24
+ from jinja2 import Environment
25
+ from markupsafe import Markup
26
+
27
+ from kstlib.monitoring._styles import get_css_classes
28
+ from kstlib.monitoring.types import Renderable
29
+
30
+
31
+ def render_html(value: Any, inline_css: bool = False) -> Markup:
32
+ """Jinja2 filter that renders a value as safe HTML.
33
+
34
+ When *value* implements :class:`~kstlib.monitoring.types.Renderable`,
35
+ its ``.render()`` method is called. Otherwise the value is converted
36
+ to a string and HTML-escaped.
37
+
38
+ Register this filter under the name ``render`` on a Jinja2 environment
39
+ so templates can use ``{{ data | render }}``.
40
+
41
+ Args:
42
+ value: Any value to render. ``Renderable`` objects are dispatched
43
+ to their own ``render()`` method; everything else is escaped.
44
+ inline_css: Forwarded to ``Renderable.render(inline_css=...)``.
45
+
46
+ Returns:
47
+ A :class:`jinja2.Markup` instance (marked safe to prevent
48
+ double-escaping by Jinja2 autoescape).
49
+
50
+ Examples:
51
+ >>> from kstlib.monitoring.renderer import render_html
52
+ >>> str(render_html("<b>bold</b>"))
53
+ '&lt;b&gt;bold&lt;/b&gt;'
54
+ """
55
+ if isinstance(value, Renderable):
56
+ return Markup(value.render(inline_css=inline_css)) # noqa: S704
57
+ return Markup(_html.escape(str(value))) # noqa: S704
58
+
59
+
60
+ def create_environment(**kwargs: Any) -> Environment:
61
+ """Create a Jinja2 :class:`~jinja2.Environment` with monitoring filters.
62
+
63
+ The returned environment has:
64
+
65
+ - ``autoescape=True`` by default (overridable via *kwargs*).
66
+ - The ``render`` filter bound to :func:`render_html`.
67
+
68
+ Args:
69
+ **kwargs: Forwarded to :class:`jinja2.Environment`. Common
70
+ options include ``loader``, ``autoescape``, ``trim_blocks``.
71
+
72
+ Returns:
73
+ A configured :class:`jinja2.Environment`.
74
+
75
+ Examples:
76
+ >>> from kstlib.monitoring.renderer import create_environment
77
+ >>> env = create_environment()
78
+ >>> "render" in env.filters
79
+ True
80
+ """
81
+ kwargs.setdefault("autoescape", True)
82
+ env = Environment(**kwargs) # noqa: S701
83
+ env.filters["render"] = render_html
84
+ return env
85
+
86
+
87
+ def render_template(
88
+ source: str,
89
+ context: dict[str, Any] | None = None,
90
+ *,
91
+ inline_css: bool = False,
92
+ ) -> str:
93
+ """Render a Jinja2 template string with monitoring support.
94
+
95
+ This is a high-level convenience function that creates a temporary
96
+ environment, compiles *source* as a template, and renders it with
97
+ *context*.
98
+
99
+ When ``inline_css=False`` (default), the CSS class definitions from
100
+ :func:`~kstlib.monitoring._styles.get_css_classes` are prepended to
101
+ the output so that class-based rendering works out of the box.
102
+
103
+ Args:
104
+ source: Jinja2 template source string.
105
+ context: Template variables. ``None`` is treated as an empty dict.
106
+ inline_css: If ``True``, skip the ``<style>`` block prepend.
107
+ Useful when styles are inlined into each element.
108
+
109
+ Returns:
110
+ Rendered HTML string.
111
+
112
+ Raises:
113
+ TypeError: If *source* is not a ``str`` or *context* is not
114
+ a ``dict`` (or ``None``).
115
+
116
+ Examples:
117
+ >>> from kstlib.monitoring.renderer import render_template
118
+ >>> render_template("Hello {{ name }}", {"name": "World"}, inline_css=True)
119
+ 'Hello World'
120
+ """
121
+ if not isinstance(source, str):
122
+ raise TypeError(f"source must be str, got {type(source).__name__}")
123
+ if context is not None and not isinstance(context, dict):
124
+ raise TypeError(f"context must be dict or None, got {type(context).__name__}")
125
+
126
+ env = create_environment()
127
+ template = env.from_string(source)
128
+ rendered = template.render(**(context or {}))
129
+
130
+ if inline_css:
131
+ return rendered
132
+ return get_css_classes() + "\n" + rendered
133
+
134
+
135
+ __all__ = [
136
+ "create_environment",
137
+ "render_html",
138
+ "render_template",
139
+ ]