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,424 @@
1
+ """Configuration loader for monitoring dashboards.
2
+
3
+ This module provides tools to load monitoring configurations from YAML files,
4
+ with auto-discovery of ``*.monitor.yml`` files in specified directories.
5
+
6
+ Examples:
7
+ Load a single monitoring config:
8
+
9
+ >>> from kstlib.monitoring.config import load_monitoring_config
10
+ >>> config = load_monitoring_config("dashboard.monitor.yml") # doctest: +SKIP
11
+ >>> service = config.to_service() # doctest: +SKIP
12
+
13
+ Discover all monitoring configs in a directory:
14
+
15
+ >>> from kstlib.monitoring.config import discover_monitoring_configs
16
+ >>> configs = discover_monitoring_configs("./configs") # doctest: +SKIP
17
+ >>> for name, config in configs.items(): # doctest: +SKIP
18
+ ... print(f"Found: {name}") # doctest: +SKIP
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import importlib
24
+ import pathlib
25
+ import re
26
+ from dataclasses import dataclass, field
27
+ from typing import Any
28
+
29
+ import yaml
30
+
31
+ from kstlib.monitoring.exceptions import MonitoringConfigError
32
+ from kstlib.monitoring.service import Collector, MonitoringService
33
+
34
+ # File pattern for monitoring configs
35
+ MONITORING_CONFIG_PATTERN = "*.monitor.yml"
36
+ MONITORING_CONFIG_SUFFIX = ".monitor.yml"
37
+
38
+ # Deep defense: Security limits
39
+ MAX_CONFIG_FILE_SIZE = 1024 * 1024 # 1 MB max config file size
40
+ MAX_COLLECTORS = 100 # Maximum number of collectors per config
41
+ MAX_NAME_LENGTH = 128 # Maximum length for names (config name, collector names)
42
+ MAX_TEMPLATE_SIZE = 512 * 1024 # 512 KB max template size
43
+
44
+ # Module import restrictions for callable collectors
45
+ BLOCKED_MODULE_PREFIXES = (
46
+ "os.",
47
+ "sys.",
48
+ "subprocess",
49
+ "shutil",
50
+ "socket",
51
+ "pickle",
52
+ "marshal",
53
+ "__",
54
+ )
55
+ ALLOWED_MODULE_PATTERN = r"^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)*$"
56
+
57
+
58
+ class MonitoringConfigFileNotFoundError(MonitoringConfigError, FileNotFoundError):
59
+ """Monitoring configuration file not found."""
60
+
61
+
62
+ class MonitoringConfigFormatError(MonitoringConfigError, ValueError):
63
+ """Invalid monitoring configuration format."""
64
+
65
+
66
+ class MonitoringConfigCollectorError(MonitoringConfigError):
67
+ """Error loading a collector from configuration."""
68
+
69
+
70
+ @dataclass
71
+ class CollectorConfig:
72
+ """Configuration for a single collector.
73
+
74
+ Attributes:
75
+ name: Name of the collector (used as template variable).
76
+ collector_type: Type of collector ("static", "callable", "env").
77
+ value: Static value (for type="static").
78
+ module: Module path (for type="callable").
79
+ function: Function name (for type="callable").
80
+ env_var: Environment variable name (for type="env").
81
+ default: Default value if env var not set (for type="env").
82
+ """
83
+
84
+ name: str
85
+ collector_type: str = "static"
86
+ value: Any = None
87
+ module: str | None = None
88
+ function: str | None = None
89
+ env_var: str | None = None
90
+ default: Any = None
91
+
92
+ def to_collector(self) -> Collector:
93
+ """Convert config to a collector callable.
94
+
95
+ Returns:
96
+ Callable that can be used as a MonitoringService collector.
97
+
98
+ Raises:
99
+ MonitoringConfigCollectorError: If collector cannot be created.
100
+ """
101
+ if self.collector_type == "static":
102
+ return self._create_static_collector()
103
+ if self.collector_type == "callable":
104
+ return self._create_callable_collector()
105
+ if self.collector_type == "env":
106
+ return self._create_env_collector()
107
+ raise MonitoringConfigCollectorError(f"Unknown collector type: {self.collector_type}")
108
+
109
+ def _create_static_collector(self) -> Collector:
110
+ """Create a static value collector."""
111
+ value = self.value
112
+
113
+ def collector() -> Any:
114
+ return value
115
+
116
+ return collector
117
+
118
+ def _create_callable_collector(self) -> Collector:
119
+ """Create a collector from a module.function reference."""
120
+ if not self.module or not self.function:
121
+ raise MonitoringConfigCollectorError(
122
+ f"Collector '{self.name}' type='callable' requires 'module' and 'function'"
123
+ )
124
+ # Deep defense: Validate module name format
125
+ if not re.match(ALLOWED_MODULE_PATTERN, self.module):
126
+ raise MonitoringConfigCollectorError(f"Invalid module name format: '{self.module}'")
127
+ # Deep defense: Block dangerous modules
128
+ for prefix in BLOCKED_MODULE_PREFIXES:
129
+ if self.module.startswith(prefix) or self.module == prefix.rstrip("."):
130
+ raise MonitoringConfigCollectorError(f"Module '{self.module}' is blocked for security reasons")
131
+ try:
132
+ mod = importlib.import_module(self.module)
133
+ func: Collector = getattr(mod, self.function)
134
+ if not callable(func):
135
+ raise MonitoringConfigCollectorError(f"'{self.module}.{self.function}' is not callable")
136
+ return func
137
+ except ImportError as e:
138
+ raise MonitoringConfigCollectorError(f"Cannot import module '{self.module}': {e}") from e
139
+ except AttributeError as e:
140
+ raise MonitoringConfigCollectorError(f"Function '{self.function}' not found in '{self.module}': {e}") from e
141
+
142
+ def _create_env_collector(self) -> Collector:
143
+ """Create a collector that reads from environment variable."""
144
+ import os
145
+
146
+ env_var = self.env_var
147
+ default = self.default
148
+ name = self.name
149
+
150
+ if not env_var:
151
+ raise MonitoringConfigCollectorError(f"Collector '{name}' type='env' requires 'env_var'")
152
+
153
+ def collector() -> Any:
154
+ return os.environ.get(env_var, default)
155
+
156
+ return collector
157
+
158
+
159
+ @dataclass
160
+ class MonitoringConfig:
161
+ """Parsed monitoring configuration.
162
+
163
+ Attributes:
164
+ name: Dashboard name (defaults to filename without extension).
165
+ template: Jinja2 template string for rendering.
166
+ collectors: List of collector configurations.
167
+ inline_css: Whether to use inline CSS (default True).
168
+ fail_fast: Whether to fail on first collector error (default True).
169
+ source_path: Path to the source config file (if loaded from file).
170
+ metadata: Additional metadata from the config file.
171
+ """
172
+
173
+ name: str
174
+ template: str
175
+ collectors: list[CollectorConfig] = field(default_factory=list)
176
+ inline_css: bool = True
177
+ fail_fast: bool = True
178
+ source_path: pathlib.Path | None = None
179
+ metadata: dict[str, Any] = field(default_factory=dict)
180
+
181
+ def to_service(self) -> MonitoringService:
182
+ """Create a MonitoringService from this configuration.
183
+
184
+ Returns:
185
+ Configured MonitoringService instance.
186
+
187
+ Raises:
188
+ MonitoringConfigCollectorError: If any collector cannot be created.
189
+ """
190
+ collectors: dict[str, Collector] = {}
191
+ for collector_config in self.collectors:
192
+ collectors[collector_config.name] = collector_config.to_collector()
193
+
194
+ return MonitoringService(
195
+ template=self.template,
196
+ collectors=collectors,
197
+ inline_css=self.inline_css,
198
+ fail_fast=self.fail_fast,
199
+ )
200
+
201
+ @classmethod
202
+ def _parse_collectors(cls, collectors_data: Any) -> list[CollectorConfig]:
203
+ """Parse collectors from config data with validation."""
204
+ if not isinstance(collectors_data, dict):
205
+ raise MonitoringConfigFormatError("'collectors' must be a dictionary mapping names to collector configs")
206
+
207
+ # Deep defense: Limit number of collectors
208
+ if len(collectors_data) > MAX_COLLECTORS:
209
+ raise MonitoringConfigFormatError(f"Too many collectors ({len(collectors_data)} > {MAX_COLLECTORS})")
210
+
211
+ collectors: list[CollectorConfig] = []
212
+ for name, collector_data in collectors_data.items():
213
+ # Deep defense: Validate collector name length
214
+ if len(str(name)) > MAX_NAME_LENGTH:
215
+ raise MonitoringConfigFormatError(
216
+ f"Collector name '{name[:20]}...' exceeds maximum length ({MAX_NAME_LENGTH})"
217
+ )
218
+ if not isinstance(collector_data, dict):
219
+ # Simple static value
220
+ collectors.append(CollectorConfig(name=name, collector_type="static", value=collector_data))
221
+ else:
222
+ collectors.append(
223
+ CollectorConfig(
224
+ name=name,
225
+ collector_type=collector_data.get("type", "static"),
226
+ value=collector_data.get("value"),
227
+ module=collector_data.get("module"),
228
+ function=collector_data.get("function"),
229
+ env_var=collector_data.get("env_var"),
230
+ default=collector_data.get("default"),
231
+ )
232
+ )
233
+ return collectors
234
+
235
+ @classmethod
236
+ def from_dict(
237
+ cls,
238
+ data: dict[str, Any],
239
+ *,
240
+ source_path: pathlib.Path | None = None,
241
+ ) -> MonitoringConfig:
242
+ """Create a MonitoringConfig from a dictionary.
243
+
244
+ Args:
245
+ data: Configuration dictionary.
246
+ source_path: Optional path to source file.
247
+
248
+ Returns:
249
+ Parsed MonitoringConfig.
250
+
251
+ Raises:
252
+ MonitoringConfigFormatError: If required fields are missing.
253
+ """
254
+ # Validate required fields
255
+ if "template" not in data:
256
+ raise MonitoringConfigFormatError("Monitoring config must have a 'template' field")
257
+
258
+ # Deep defense: Validate template size
259
+ template = data["template"]
260
+ if not isinstance(template, str):
261
+ raise MonitoringConfigFormatError("'template' must be a string")
262
+ if len(template) > MAX_TEMPLATE_SIZE:
263
+ raise MonitoringConfigFormatError(f"Template exceeds maximum size ({MAX_TEMPLATE_SIZE} bytes)")
264
+
265
+ # Deep defense: Validate config name length
266
+ config_name = data.get("name")
267
+ if config_name and len(str(config_name)) > MAX_NAME_LENGTH:
268
+ raise MonitoringConfigFormatError(f"Config name exceeds maximum length ({MAX_NAME_LENGTH})")
269
+
270
+ # Parse collectors
271
+ collectors = cls._parse_collectors(data.get("collectors", {}))
272
+
273
+ # Determine name
274
+ name = data.get("name")
275
+ if not name and source_path:
276
+ # Use filename without .monitor.yml suffix
277
+ name = source_path.name
278
+ name = name.removesuffix(MONITORING_CONFIG_SUFFIX)
279
+
280
+ # Extract known fields for metadata
281
+ known_fields = {
282
+ "name",
283
+ "template",
284
+ "collectors",
285
+ "inline_css",
286
+ "fail_fast",
287
+ }
288
+ metadata = {k: v for k, v in data.items() if k not in known_fields}
289
+
290
+ return cls(
291
+ name=name or "unnamed",
292
+ template=data["template"],
293
+ collectors=collectors,
294
+ inline_css=data.get("inline_css", True),
295
+ fail_fast=data.get("fail_fast", True),
296
+ source_path=source_path,
297
+ metadata=metadata,
298
+ )
299
+
300
+
301
+ def load_monitoring_config(
302
+ path: str | pathlib.Path,
303
+ *,
304
+ encoding: str = "utf-8",
305
+ ) -> MonitoringConfig:
306
+ """Load a monitoring configuration from a YAML file.
307
+
308
+ Args:
309
+ path: Path to the monitoring config file.
310
+ encoding: File encoding (default UTF-8).
311
+
312
+ Returns:
313
+ Parsed MonitoringConfig.
314
+
315
+ Raises:
316
+ MonitoringConfigFileNotFoundError: If file does not exist.
317
+ MonitoringConfigFormatError: If file format is invalid.
318
+
319
+ Examples:
320
+ >>> config = load_monitoring_config("dashboard.monitor.yml") # doctest: +SKIP
321
+ >>> service = config.to_service() # doctest: +SKIP
322
+ >>> result = service.run_sync() # doctest: +SKIP
323
+ """
324
+ path = pathlib.Path(path)
325
+ if not path.is_file():
326
+ raise MonitoringConfigFileNotFoundError(f"Config file not found: {path}")
327
+
328
+ # Deep defense: Check file size before reading
329
+ file_size = path.stat().st_size
330
+ if file_size > MAX_CONFIG_FILE_SIZE:
331
+ raise MonitoringConfigFormatError(f"Config file too large ({file_size} > {MAX_CONFIG_FILE_SIZE} bytes)")
332
+
333
+ try:
334
+ with path.open("r", encoding=encoding) as f:
335
+ data = yaml.safe_load(f)
336
+ except yaml.YAMLError as e:
337
+ raise MonitoringConfigFormatError(f"Invalid YAML in {path}: {e}") from e
338
+
339
+ if not isinstance(data, dict):
340
+ raise MonitoringConfigFormatError(f"Monitoring config must be a YAML dictionary, got {type(data).__name__}")
341
+
342
+ return MonitoringConfig.from_dict(data, source_path=path.resolve())
343
+
344
+
345
+ def discover_monitoring_configs(
346
+ directory: str | pathlib.Path,
347
+ *,
348
+ recursive: bool = False,
349
+ encoding: str = "utf-8",
350
+ ) -> dict[str, MonitoringConfig]:
351
+ """Discover and load all monitoring configs in a directory.
352
+
353
+ Searches for files matching ``*.monitor.yml`` pattern.
354
+
355
+ Args:
356
+ directory: Directory to search.
357
+ recursive: If True, search subdirectories recursively.
358
+ encoding: File encoding (default UTF-8).
359
+
360
+ Returns:
361
+ Dictionary mapping config names to MonitoringConfig objects.
362
+
363
+ Raises:
364
+ FileNotFoundError: If directory does not exist.
365
+ MonitoringConfigFormatError: If any config file is invalid.
366
+
367
+ Examples:
368
+ >>> configs = discover_monitoring_configs("./monitoring") # doctest: +SKIP
369
+ >>> for name, config in configs.items(): # doctest: +SKIP
370
+ ... print(f"Loaded: {name}") # doctest: +SKIP
371
+ """
372
+ directory = pathlib.Path(directory)
373
+ if not directory.is_dir():
374
+ raise FileNotFoundError(f"Directory not found: {directory}")
375
+
376
+ pattern = f"**/{MONITORING_CONFIG_PATTERN}" if recursive else MONITORING_CONFIG_PATTERN
377
+ configs: dict[str, MonitoringConfig] = {}
378
+
379
+ for config_path in directory.glob(pattern):
380
+ config = load_monitoring_config(config_path, encoding=encoding)
381
+ configs[config.name] = config
382
+
383
+ return configs
384
+
385
+
386
+ def create_services_from_directory(
387
+ directory: str | pathlib.Path,
388
+ *,
389
+ recursive: bool = False,
390
+ encoding: str = "utf-8",
391
+ ) -> dict[str, MonitoringService]:
392
+ """Discover configs and create MonitoringService instances.
393
+
394
+ Convenience function that combines discover_monitoring_configs
395
+ with to_service() for each config.
396
+
397
+ Args:
398
+ directory: Directory to search for ``*.monitor.yml`` files.
399
+ recursive: If True, search subdirectories recursively.
400
+ encoding: File encoding (default UTF-8).
401
+
402
+ Returns:
403
+ Dictionary mapping config names to MonitoringService instances.
404
+
405
+ Examples:
406
+ >>> services = create_services_from_directory("./monitoring") # doctest: +SKIP
407
+ >>> for name, service in services.items(): # doctest: +SKIP
408
+ ... result = service.run_sync() # doctest: +SKIP
409
+ ... print(f"{name}: {result.success}") # doctest: +SKIP
410
+ """
411
+ configs = discover_monitoring_configs(directory, recursive=recursive, encoding=encoding)
412
+ return {name: config.to_service() for name, config in configs.items()}
413
+
414
+
415
+ __all__ = [
416
+ "CollectorConfig",
417
+ "MonitoringConfig",
418
+ "MonitoringConfigCollectorError",
419
+ "MonitoringConfigFileNotFoundError",
420
+ "MonitoringConfigFormatError",
421
+ "create_services_from_directory",
422
+ "discover_monitoring_configs",
423
+ "load_monitoring_config",
424
+ ]