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,963 @@
1
+ """
2
+ Cascading configuration loader for kstlib.
3
+
4
+ Features:
5
+ - Object-oriented ConfigLoader class for clean architecture
6
+ - Dot notation everywhere (using Box)
7
+ - 'include' key for recursive multi-format includes (yaml, toml, JSON, ini)
8
+ - Deep merge for overrides
9
+ - Fallback to package default config
10
+ - Backward-compatible functional API
11
+
12
+ Examples:
13
+ Modern class-based approach (recommended)::
14
+
15
+ >>> from kstlib.config import ConfigLoader # doctest: +SKIP
16
+ >>> config = ConfigLoader.from_file("config.yml") # doctest: +SKIP
17
+ >>> config = ConfigLoader(strict_format=True).load_from_file("config.yml") # doctest: +SKIP
18
+
19
+ Functional API (backward compatible)::
20
+
21
+ >>> from kstlib.config import load_from_file, get_config # doctest: +SKIP
22
+ >>> config = load_from_file("config.yml") # doctest: +SKIP
23
+ >>> config = get_config() # doctest: +SKIP
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import os
30
+ import pathlib
31
+ import time
32
+ from configparser import ConfigParser
33
+ from dataclasses import dataclass
34
+ from typing import Any, Literal, cast
35
+
36
+ import yaml
37
+ from box import Box
38
+
39
+ try:
40
+ import tomli
41
+ except ImportError:
42
+ tomli = None # type: ignore[assignment]
43
+
44
+ from kstlib.config.exceptions import (
45
+ ConfigCircularIncludeError,
46
+ ConfigFileNotFoundError,
47
+ ConfigFormatError,
48
+ ConfigIncludeDepthError,
49
+ ConfigNotLoadedError,
50
+ )
51
+ from kstlib.utils.dict import deep_merge
52
+
53
+ CONFIG_FILENAME = "kstlib.conf.yml"
54
+ USER_CONFIG_DIR = ".config"
55
+ DEFAULT_ENCODING = "utf-8"
56
+
57
+ # Deep defense: Maximum include depth to prevent resource exhaustion
58
+ MAX_INCLUDE_DEPTH = 10
59
+
60
+
61
+ # ============================================================================
62
+ # Internal loader functions (format-specific)
63
+ # ============================================================================
64
+
65
+
66
+ def _load_yaml_file(path: pathlib.Path, encoding: str = DEFAULT_ENCODING) -> dict[str, Any]:
67
+ """
68
+ Load a YAML configuration file and return its contents as a dictionary.
69
+
70
+ Args:
71
+ path: Path to the YAML file.
72
+ encoding: File encoding.
73
+
74
+ Returns:
75
+ dict: Parsed YAML content.
76
+
77
+ Raises:
78
+ ConfigFileNotFoundError: If the file does not exist.
79
+ """
80
+ if not path.is_file():
81
+ raise ConfigFileNotFoundError(f"Config file not found: {path}")
82
+ with path.open("r", encoding=encoding) as f:
83
+ return yaml.safe_load(f) or {}
84
+
85
+
86
+ def _load_toml_file(path: pathlib.Path) -> dict[str, Any]:
87
+ """
88
+ Load a TOML configuration file and return its contents as a dictionary.
89
+
90
+ Args:
91
+ path: Path to the TOML file.
92
+
93
+ Returns:
94
+ dict: Parsed TOML content.
95
+
96
+ Raises:
97
+ ConfigFileNotFoundError: If the file does not exist.
98
+ ConfigFormatError: If tomli package is not installed.
99
+ """
100
+ if not path.is_file():
101
+ raise ConfigFileNotFoundError(f"Config file not found: {path}")
102
+ if tomli is None:
103
+ raise ConfigFormatError("TOML support requires the 'tomli' package. Install it with: pip install tomli")
104
+ with path.open("rb") as f:
105
+ return tomli.load(f)
106
+
107
+
108
+ def _load_json_file(path: pathlib.Path, encoding: str = DEFAULT_ENCODING) -> dict[str, Any]:
109
+ """
110
+ Load a JSON configuration file and return its contents as a dictionary.
111
+
112
+ Args:
113
+ path: Path to the JSON file.
114
+ encoding: File encoding.
115
+
116
+ Returns:
117
+ dict: Parsed JSON content.
118
+
119
+ Raises:
120
+ ConfigFileNotFoundError: If the file does not exist.
121
+ """
122
+ if not path.is_file():
123
+ raise ConfigFileNotFoundError(f"Config file not found: {path}")
124
+ with path.open("r", encoding=encoding) as f:
125
+ data = json.load(f)
126
+ return data if isinstance(data, dict) else {}
127
+
128
+
129
+ def _load_ini_file(path: pathlib.Path, encoding: str = DEFAULT_ENCODING) -> dict[str, Any]:
130
+ """
131
+ Load an INI configuration file and return its contents as a dictionary.
132
+
133
+ Args:
134
+ path: Path to the INI file.
135
+ encoding: File encoding.
136
+
137
+ Returns:
138
+ dict: Parsed INI content.
139
+
140
+ Raises:
141
+ ConfigFileNotFoundError: If the file does not exist.
142
+ """
143
+ if not path.is_file():
144
+ raise ConfigFileNotFoundError(f"Config file not found: {path}")
145
+ parser = ConfigParser()
146
+ parser.read(path, encoding=encoding)
147
+ return {s: dict(parser.items(s)) for s in parser.sections()}
148
+
149
+
150
+ def _try_sops_decrypt(path: pathlib.Path) -> str | None:
151
+ """Attempt SOPS decryption, returning content or None on failure."""
152
+ # Lazy import to avoid circular dependencies
153
+ import logging
154
+
155
+ from kstlib.config.exceptions import ConfigSopsError, ConfigSopsNotAvailableError
156
+ from kstlib.config.sops import get_decryptor
157
+
158
+ _logger = logging.getLogger(__name__)
159
+ try:
160
+ content = get_decryptor().decrypt_file(path)
161
+ _logger.debug("Decrypted SOPS file: %s", path.name)
162
+ return content
163
+ except ConfigSopsNotAvailableError as exc:
164
+ _logger.warning("SOPS not available, loading raw: %s", exc)
165
+ except ConfigSopsError as exc:
166
+ _logger.warning("SOPS decryption failed: %s", exc)
167
+ return None
168
+
169
+
170
+ def _parse_content_by_format(
171
+ content: str,
172
+ ext: str,
173
+ path: pathlib.Path,
174
+ encoding: str,
175
+ ) -> dict[str, Any]:
176
+ """Parse decrypted content based on file extension."""
177
+ if ext in (".yml", ".yaml"):
178
+ return yaml.safe_load(content) or {}
179
+ if ext == ".toml":
180
+ if tomli is None:
181
+ raise ConfigFormatError("TOML support requires the 'tomli' package. Install it with: pip install tomli")
182
+ return tomli.loads(content)
183
+ if ext == ".json":
184
+ parsed = json.loads(content)
185
+ return parsed if isinstance(parsed, dict) else {}
186
+ if ext == ".ini":
187
+ parser = ConfigParser()
188
+ parser.read_string(content)
189
+ return {s: dict(parser.items(s)) for s in parser.sections()}
190
+ raise ConfigFormatError(f"Unsupported config file type: {path}")
191
+
192
+
193
+ def _load_file_by_format(
194
+ path: pathlib.Path,
195
+ ext: str,
196
+ encoding: str,
197
+ ) -> dict[str, Any]:
198
+ """Load file directly based on extension."""
199
+ if ext in (".yml", ".yaml"):
200
+ return _load_yaml_file(path, encoding)
201
+ if ext == ".toml":
202
+ return _load_toml_file(path)
203
+ if ext == ".json":
204
+ return _load_json_file(path, encoding)
205
+ if ext == ".ini":
206
+ return _load_ini_file(path, encoding)
207
+ raise ConfigFormatError(f"Unsupported config file type: {path}")
208
+
209
+
210
+ def _warn_encrypted_values(data: dict[str, Any], path: pathlib.Path) -> None:
211
+ """Warn if ENC[...] values found in non-decrypted data."""
212
+ import logging
213
+
214
+ from kstlib.config.sops import has_encrypted_values
215
+
216
+ enc_keys = has_encrypted_values(data)
217
+ if enc_keys:
218
+ _logger = logging.getLogger(__name__)
219
+ _logger.warning(
220
+ "Found ENC[...] values at %s in %s. Use a .sops.yml file for auto-decryption.",
221
+ enc_keys[:3],
222
+ path.name,
223
+ )
224
+
225
+
226
+ def _load_any_config_file(
227
+ path: pathlib.Path,
228
+ encoding: str = DEFAULT_ENCODING,
229
+ *,
230
+ sops_decrypt: bool = True,
231
+ ) -> dict[str, Any]:
232
+ """
233
+ Load a configuration file in any supported format (YAML, TOML, JSON, INI).
234
+
235
+ Supports automatic SOPS decryption for files with .sops.* extensions.
236
+
237
+ Args:
238
+ path: Path to the configuration file.
239
+ encoding: File encoding.
240
+ sops_decrypt: If True, auto-decrypt .sops.* files via SOPS binary.
241
+
242
+ Returns:
243
+ dict: Parsed content of the file.
244
+
245
+ Raises:
246
+ ConfigFormatError: If the file extension is not supported.
247
+ """
248
+ from kstlib.config.sops import get_real_extension, is_sops_file
249
+
250
+ content: str | None = None
251
+ is_sops = is_sops_file(path)
252
+
253
+ # Handle SOPS-encrypted files
254
+ if sops_decrypt and is_sops:
255
+ content = _try_sops_decrypt(path)
256
+
257
+ # Get real extension for parsing (strips .sops prefix)
258
+ ext = get_real_extension(path) if is_sops else path.suffix.lower()
259
+
260
+ # Parse content or load file
261
+ if content is not None:
262
+ data = _parse_content_by_format(content, ext, path, encoding)
263
+ else:
264
+ data = _load_file_by_format(path, ext, encoding)
265
+ _warn_encrypted_values(data, path)
266
+
267
+ return data
268
+
269
+
270
+ def _load_with_includes(
271
+ path: pathlib.Path,
272
+ loaded_paths: set[pathlib.Path] | None = None,
273
+ strict_format: bool = False,
274
+ encoding: str = DEFAULT_ENCODING,
275
+ *,
276
+ sops_decrypt: bool = True,
277
+ _depth: int = 0,
278
+ ) -> dict[str, Any]:
279
+ """
280
+ Recursively load a config file and all files specified in its 'include' key.
281
+
282
+ Args:
283
+ path: Path to the main config file.
284
+ loaded_paths: Set of already loaded paths to prevent cycles.
285
+ strict_format: If True, included files must have the same format as parent file.
286
+ encoding: File encoding.
287
+ sops_decrypt: If True, auto-decrypt .sops.* files via SOPS binary.
288
+ _depth: Internal counter for recursion depth (do not set manually).
289
+
290
+ Returns:
291
+ Final merged configuration dictionary.
292
+
293
+ Raises:
294
+ ConfigCircularIncludeError: If a circular include is detected.
295
+ ConfigIncludeDepthError: If include depth exceeds MAX_INCLUDE_DEPTH.
296
+ ConfigFormatError: If format mismatch occurs (strict_format=True).
297
+ """
298
+ # Lazy import for SOPS extension detection
299
+ from kstlib.config.sops import get_real_extension, is_sops_file
300
+
301
+ # Deep defense: prevent excessive recursion
302
+ if _depth > MAX_INCLUDE_DEPTH:
303
+ raise ConfigIncludeDepthError(
304
+ f"Include depth exceeds maximum ({MAX_INCLUDE_DEPTH}). "
305
+ "Check for deeply nested includes or misconfiguration."
306
+ )
307
+
308
+ if loaded_paths is None:
309
+ loaded_paths = set()
310
+ path = path.resolve()
311
+ if path in loaded_paths:
312
+ raise ConfigCircularIncludeError(f"Circular include detected for {path}")
313
+ loaded_paths.add(path)
314
+
315
+ data = _load_any_config_file(path, encoding, sops_decrypt=sops_decrypt)
316
+ includes = data.pop("include", [])
317
+ if isinstance(includes, str):
318
+ includes = [includes]
319
+
320
+ # Get real extension (handles .sops.yml -> .yml)
321
+ parent_ext = get_real_extension(path) if is_sops_file(path) else path.suffix.lower()
322
+ merged: dict[str, Any] = {}
323
+ for inc in includes:
324
+ inc_path = (path.parent / inc).resolve()
325
+
326
+ # Validate format consistency if strict mode enabled
327
+ if strict_format:
328
+ inc_ext = get_real_extension(inc_path) if is_sops_file(inc_path) else inc_path.suffix.lower()
329
+ if inc_ext != parent_ext:
330
+ raise ConfigFormatError(
331
+ f"Include format mismatch: parent is {parent_ext}, include is {inc_ext} (file: {inc_path})"
332
+ )
333
+
334
+ merged = deep_merge(
335
+ merged,
336
+ _load_with_includes(
337
+ inc_path, loaded_paths, strict_format, encoding, sops_decrypt=sops_decrypt, _depth=_depth + 1
338
+ ),
339
+ )
340
+ merged = deep_merge(merged, data)
341
+ return merged
342
+
343
+
344
+ def _load_default_config(encoding: str = DEFAULT_ENCODING) -> dict[str, Any]:
345
+ """
346
+ Load the package's default configuration.
347
+
348
+ Args:
349
+ encoding: File encoding.
350
+
351
+ Returns:
352
+ dict: Default configuration as parsed from kstlib.conf.yml, or empty dict if missing.
353
+ """
354
+ config_path = pathlib.Path(__file__).resolve().parent.parent / CONFIG_FILENAME
355
+ if not config_path.is_file():
356
+ return {}
357
+ return _load_yaml_file(config_path, encoding)
358
+
359
+
360
+ # ============================================================================
361
+ # ConfigLoader Class (Modern OOP API)
362
+ # ============================================================================
363
+
364
+
365
+ AutoDiscoverySource = Literal["cascading", "env", "file"]
366
+
367
+
368
+ @dataclass(slots=True)
369
+ class AutoDiscoveryConfig:
370
+ """Encapsulate auto-discovery options for ``ConfigLoader``."""
371
+
372
+ enabled: bool
373
+ source: AutoDiscoverySource
374
+ filename: str
375
+ env_var: str
376
+ path: pathlib.Path | None
377
+
378
+
379
+ class ConfigLoader:
380
+ """
381
+ Configuration loader with support for multiple formats and sources.
382
+
383
+ This class provides a clean, object-oriented interface for loading
384
+ configuration from various sources with customizable behavior.
385
+
386
+ Attributes:
387
+ strict_format: If True, included files must match parent format.
388
+ encoding: File encoding for text-based formats.
389
+ sops_decrypt: If True, auto-decrypt .sops.* files via SOPS binary.
390
+ auto_discovery: Whether the constructor should immediately hydrate the config.
391
+ auto_source: Source used for auto-discovery (cascading/env/file).
392
+ auto_filename: Filename searched when auto-discovery cascades.
393
+ auto_env_var: Environment variable used when auto_source is ``"env"``.
394
+ auto_path: Explicit path used when auto_source is ``"file"``.
395
+ auto: :class:`AutoDiscoveryConfig` carrying the effective auto-discovery options.
396
+
397
+ Examples:
398
+ Instance-based usage with custom settings::
399
+
400
+ >>> loader = ConfigLoader(strict_format=True, encoding='utf-8') # doctest: +SKIP
401
+ >>> config = loader.load_from_file("config.yml") # doctest: +SKIP
402
+ >>> print(config.app.name) # doctest: +SKIP
403
+
404
+ Factory methods (one-liner convenience)::
405
+
406
+ >>> config = ConfigLoader.from_file("config.yml") # doctest: +SKIP
407
+ >>> config = ConfigLoader.from_env("CONFIG_PATH") # doctest: +SKIP
408
+ >>> config = ConfigLoader.from_cascading("myapp.yml") # doctest: +SKIP
409
+
410
+ Multiple independent configs::
411
+
412
+ >>> dev_config = ConfigLoader().load_from_file("dev.yml") # doctest: +SKIP
413
+ >>> prod_config = ConfigLoader(strict_format=True).load_from_file("prod.yml") # doctest: +SKIP
414
+
415
+ Disable SOPS decryption::
416
+
417
+ >>> loader = ConfigLoader(sops_decrypt=False) # doctest: +SKIP
418
+ >>> config = loader.load_from_file("secrets.sops.yml") # Loads raw # doctest: +SKIP
419
+ """
420
+
421
+ # pylint: disable=too-many-arguments
422
+ def __init__(
423
+ self,
424
+ strict_format: bool = False,
425
+ encoding: str = DEFAULT_ENCODING,
426
+ sops_decrypt: bool = True,
427
+ *,
428
+ auto: AutoDiscoveryConfig | None = None,
429
+ **auto_kwargs: Any,
430
+ ) -> None:
431
+ """Initialize a ConfigLoader with specific settings.
432
+
433
+ Args:
434
+ strict_format: If True, included files must match parent file format.
435
+ encoding: File encoding for text-based configuration formats.
436
+ sops_decrypt: If True, auto-decrypt .sops.* files via SOPS binary.
437
+ auto: Pre-built auto-discovery options. When omitted, keyword arguments
438
+ such as ``auto_source`` or ``auto_filename`` are honoured.
439
+ auto_kwargs: Legacy keyword arguments controlling auto-discovery:
440
+ ``auto_discovery``, ``auto_source``, ``auto_filename``,
441
+ ``auto_env_var``, and ``auto_path``.
442
+ """
443
+ self.strict_format = strict_format
444
+ self.encoding = encoding
445
+ self.sops_decrypt = sops_decrypt
446
+ self._cache: Box | None = None
447
+ self._cache_timestamp: float | None = None
448
+ self.auto = self._build_auto_config(auto, auto_kwargs)
449
+
450
+ if self.auto.enabled:
451
+ self._auto_load()
452
+
453
+ def _build_auto_config(
454
+ self,
455
+ auto: AutoDiscoveryConfig | None,
456
+ auto_kwargs: dict[str, Any],
457
+ ) -> AutoDiscoveryConfig:
458
+ """Normalize legacy auto-discovery kwargs into a dataclass instance."""
459
+ if auto is not None and auto_kwargs:
460
+ raise ValueError("'auto' parameter cannot be combined with legacy auto_* keyword arguments.")
461
+ if auto is not None:
462
+ return auto
463
+
464
+ allowed = {
465
+ "auto_discovery",
466
+ "auto_source",
467
+ "auto_filename",
468
+ "auto_env_var",
469
+ "auto_path",
470
+ }
471
+ unexpected = set(auto_kwargs) - allowed
472
+ if unexpected:
473
+ raise TypeError(f"Unexpected auto configuration keywords: {sorted(unexpected)}")
474
+
475
+ auto_source = cast("AutoDiscoverySource", auto_kwargs.get("auto_source", "cascading"))
476
+ auto_path = cast("str | pathlib.Path | None", auto_kwargs.get("auto_path"))
477
+ auto_filename = cast("str", auto_kwargs.get("auto_filename", CONFIG_FILENAME))
478
+ auto_env_var = cast("str", auto_kwargs.get("auto_env_var", "CONFIG_PATH"))
479
+ auto_discovery = bool(auto_kwargs.get("auto_discovery", True))
480
+ resolved_path = pathlib.Path(auto_path).resolve() if auto_path else None
481
+ return AutoDiscoveryConfig(
482
+ enabled=auto_discovery,
483
+ source=auto_source,
484
+ filename=auto_filename,
485
+ env_var=auto_env_var,
486
+ path=resolved_path,
487
+ )
488
+
489
+ @property
490
+ def cache(self) -> Box | None:
491
+ """Return the cached configuration instance, if any."""
492
+ return self._cache
493
+
494
+ @cache.setter
495
+ def cache(self, value: Box | None) -> None:
496
+ """Update the cached configuration instance."""
497
+ self._cache = value
498
+ self._cache_timestamp = time.time() if value is not None else None
499
+
500
+ @property
501
+ def cache_timestamp(self) -> float | None:
502
+ """Return the epoch timestamp when the cache was last refreshed."""
503
+ return self._cache_timestamp
504
+
505
+ @property
506
+ def config(self) -> Box:
507
+ """Return the currently loaded configuration or raise if missing."""
508
+ if self._cache is None:
509
+ raise ConfigNotLoadedError(
510
+ "Configuration not loaded. Enable auto_discovery or call a load_* method before accessing data."
511
+ )
512
+ return self._cache
513
+
514
+ def __getattr__(self, item: str) -> Any: # pragma: no cover - thin proxy
515
+ """Proxy attribute access to the cached configuration."""
516
+ try:
517
+ return getattr(self.config, item)
518
+ except ConfigNotLoadedError as exc: # pragma: no cover - attribute fallback
519
+ raise AttributeError(str(exc)) from exc
520
+
521
+ def __getitem__(self, key: str) -> Any:
522
+ """Provide dict-style access to the cached configuration."""
523
+ return self.config[key]
524
+
525
+ def _auto_load(self) -> None:
526
+ if self.auto.source == "cascading":
527
+ self.load(self.auto.filename)
528
+ return
529
+ if self.auto.source == "env":
530
+ self.load_from_env(self.auto.env_var)
531
+ return
532
+ if self.auto.source == "file":
533
+ if self.auto.path is None:
534
+ raise ConfigNotLoadedError(
535
+ "auto_path must be provided when auto_source='file'. Set auto_discovery=False to skip auto load."
536
+ )
537
+ self.load_from_file(self.auto.path)
538
+ return
539
+ raise ConfigFormatError(f"Unsupported auto_discovery source: {self.auto.source}")
540
+
541
+ def _merge_into_cache(self, conf: Box, purge_cache: bool) -> Box:
542
+ if purge_cache or self._cache is None:
543
+ self.cache = conf
544
+ return conf
545
+
546
+ existing = self._cache.to_dict()
547
+ merged = deep_merge(existing, conf.to_dict())
548
+ new_box = Box(merged, default_box=True, default_box_attr=None)
549
+ self.cache = new_box
550
+ return new_box
551
+
552
+ def load_from_file(self, path: str | pathlib.Path, *, purge_cache: bool = True) -> Box:
553
+ """
554
+ Load configuration from a specific file path.
555
+
556
+ Args:
557
+ path: Path to configuration file (str or Path object).
558
+ purge_cache: If True, replace the cached config with the freshly loaded data.
559
+
560
+ Returns:
561
+ Configuration object with dot notation support.
562
+
563
+ Raises:
564
+ ConfigFileNotFoundError: If the specified file doesn't exist.
565
+ ConfigFormatError: On unsupported format or format mismatch.
566
+ ConfigCircularIncludeError: On circular includes.
567
+
568
+ Examples:
569
+ >>> loader = ConfigLoader() # doctest: +SKIP
570
+ >>> config = loader.load_from_file("/opt/myapp/config.yml") # doctest: +SKIP
571
+ >>> print(config.database.host) # doctest: +SKIP
572
+ """
573
+ path = pathlib.Path(path).resolve()
574
+ if not path.is_file():
575
+ raise ConfigFileNotFoundError(f"Config file not found: {path}")
576
+ conf = _load_with_includes(
577
+ path,
578
+ strict_format=self.strict_format,
579
+ encoding=self.encoding,
580
+ sops_decrypt=self.sops_decrypt,
581
+ )
582
+ box_conf = Box(conf, default_box=True, default_box_attr=None)
583
+ return self._merge_into_cache(box_conf, purge_cache)
584
+
585
+ def load_from_env(self, env_var: str = "CONFIG_PATH", *, purge_cache: bool = True) -> Box:
586
+ """
587
+ Load configuration from path specified in an environment variable.
588
+
589
+ Args:
590
+ env_var: Name of environment variable containing config file path.
591
+ purge_cache: If True, replace the cached config with the freshly loaded data.
592
+
593
+ Returns:
594
+ Configuration object with dot notation support.
595
+
596
+ Raises:
597
+ ValueError: If environment variable is not set or empty.
598
+ ConfigFileNotFoundError: If the path in environment variable doesn't exist.
599
+
600
+ Examples:
601
+ >>> import os # doctest: +SKIP
602
+ >>> os.environ["CONFIG_PATH"] = "/opt/config.yml" # doctest: +SKIP
603
+ >>> loader = ConfigLoader() # doctest: +SKIP
604
+ >>> config = loader.load_from_env() # doctest: +SKIP
605
+ """
606
+ path_str = os.getenv(env_var)
607
+ if not path_str:
608
+ raise ValueError(f"Environment variable '{env_var}' is not set or empty")
609
+ return self.load_from_file(path_str, purge_cache=purge_cache)
610
+
611
+ def load(self, filename: str = CONFIG_FILENAME, *, purge_cache: bool = True) -> Box:
612
+ """
613
+ Load configuration using cascading search across multiple locations.
614
+
615
+ Search order (priority from lowest to highest):
616
+ 1. Package default config (lowest priority - base layer)
617
+ 2. User's config directory (e.g., ~/.config/kstlib.conf.yml)
618
+ 3. User's home directory (e.g., ~/kstlib.conf.yml)
619
+ 4. Current working directory (highest priority - overrides all)
620
+
621
+ Args:
622
+ filename: Config filename to search for.
623
+ purge_cache: If True, replace the cached config with the freshly loaded data.
624
+
625
+ Returns:
626
+ Configuration object with dot notation support.
627
+
628
+ Raises:
629
+ ConfigFileNotFoundError: If no config file is found in any location.
630
+
631
+ Examples:
632
+ >>> loader = ConfigLoader() # doctest: +SKIP
633
+ >>> config = loader.load("myapp.yml") # doctest: +SKIP
634
+ """
635
+ # Start with package default config
636
+ _config = _load_default_config(self.encoding)
637
+
638
+ # Search multiple locations
639
+ home = pathlib.Path.home()
640
+ search_paths = [
641
+ home / USER_CONFIG_DIR / filename,
642
+ home / filename,
643
+ pathlib.Path.cwd() / filename,
644
+ ]
645
+
646
+ for search_path in search_paths:
647
+ if search_path.is_file():
648
+ conf = _load_with_includes(
649
+ search_path,
650
+ strict_format=self.strict_format,
651
+ encoding=self.encoding,
652
+ sops_decrypt=self.sops_decrypt,
653
+ )
654
+ _config = deep_merge(_config, conf)
655
+
656
+ if not _config:
657
+ raise ConfigFileNotFoundError(
658
+ f"No configuration file found in working directory, home, {USER_CONFIG_DIR}, "
659
+ f"or package data (searched for '{filename}')."
660
+ )
661
+ box_conf = Box(_config, default_box=True, default_box_attr=None)
662
+ return self._merge_into_cache(box_conf, purge_cache)
663
+
664
+ # Factory methods for one-liner convenience
665
+
666
+ @classmethod
667
+ def from_file(
668
+ cls,
669
+ path: str | pathlib.Path,
670
+ strict_format: bool = False,
671
+ encoding: str = DEFAULT_ENCODING,
672
+ sops_decrypt: bool = True,
673
+ ) -> Box:
674
+ """
675
+ Create loader and load file in one call (factory method).
676
+
677
+ Args:
678
+ path: Path to configuration file.
679
+ strict_format: If True, included files must match parent format.
680
+ encoding: File encoding.
681
+ sops_decrypt: If True, auto-decrypt .sops.* files via SOPS binary.
682
+
683
+ Returns:
684
+ Configuration object with dot notation support.
685
+
686
+ Examples:
687
+ >>> config = ConfigLoader.from_file("config.yml") # doctest: +SKIP
688
+ >>> config = ConfigLoader.from_file("config.yml", strict_format=True) # doctest: +SKIP
689
+ """
690
+ return cls(strict_format=strict_format, encoding=encoding, sops_decrypt=sops_decrypt).load_from_file(path)
691
+
692
+ @classmethod
693
+ def from_env(
694
+ cls,
695
+ env_var: str = "CONFIG_PATH",
696
+ strict_format: bool = False,
697
+ encoding: str = DEFAULT_ENCODING,
698
+ sops_decrypt: bool = True,
699
+ ) -> Box:
700
+ """
701
+ Create loader and load from environment variable in one call (factory method).
702
+
703
+ Args:
704
+ env_var: Name of environment variable containing config file path.
705
+ strict_format: If True, included files must match parent format.
706
+ encoding: File encoding.
707
+ sops_decrypt: If True, auto-decrypt .sops.* files via SOPS binary.
708
+
709
+ Returns:
710
+ Configuration object with dot notation support.
711
+
712
+ Examples:
713
+ >>> config = ConfigLoader.from_env("CONFIG_PATH") # doctest: +SKIP
714
+ >>> config = ConfigLoader.from_env("MYAPP_CONFIG", strict_format=True) # doctest: +SKIP
715
+ """
716
+ return cls(strict_format=strict_format, encoding=encoding, sops_decrypt=sops_decrypt).load_from_env(env_var)
717
+
718
+ @classmethod
719
+ def from_cascading(
720
+ cls,
721
+ filename: str = CONFIG_FILENAME,
722
+ strict_format: bool = False,
723
+ encoding: str = DEFAULT_ENCODING,
724
+ sops_decrypt: bool = True,
725
+ ) -> Box:
726
+ """
727
+ Create loader and perform cascading search in one call (factory method).
728
+
729
+ Args:
730
+ filename: Config filename to search for.
731
+ strict_format: If True, included files must match parent format.
732
+ encoding: File encoding.
733
+ sops_decrypt: If True, auto-decrypt .sops.* files via SOPS binary.
734
+
735
+ Returns:
736
+ Configuration object with dot notation support.
737
+
738
+ Examples:
739
+ >>> config = ConfigLoader.from_cascading("myapp.yml") # doctest: +SKIP
740
+ >>> config = ConfigLoader.from_cascading(strict_format=True) # doctest: +SKIP
741
+ """
742
+ return cls(strict_format=strict_format, encoding=encoding, sops_decrypt=sops_decrypt).load(filename)
743
+
744
+
745
+ # ============================================================================
746
+ # Backward-compatible functional API
747
+ # ============================================================================
748
+
749
+ # Global singleton for backward compatibility
750
+ _default_loader = ConfigLoader(auto_discovery=False)
751
+
752
+
753
+ def load_config(
754
+ filename: str = CONFIG_FILENAME,
755
+ path: pathlib.Path | None = None,
756
+ strict_format: bool = False,
757
+ sops_decrypt: bool = True,
758
+ ) -> Box:
759
+ """
760
+ Load configuration either from cascading search or from an explicit file path.
761
+
762
+ Two modes of operation:
763
+ 1. Cascading mode: Searches multiple locations and merges configs
764
+ 2. Direct mode: Loads from specific file path only
765
+
766
+ Cascading search order (priority from lowest to highest):
767
+ 1. Package default config (lowest priority - base layer)
768
+ 2. User's config directory (e.g., ~/.config/kstlib.conf.yml)
769
+ 3. User's home directory (e.g., ~/kstlib.conf.yml)
770
+ 4. Current working directory (highest priority - overrides all)
771
+
772
+ Note: Files are merged using deep merge, so later files override earlier ones.
773
+ The current working directory config has final say on all values.
774
+
775
+ This is a backward-compatible wrapper. For new code, prefer ConfigLoader class.
776
+
777
+ Args:
778
+ filename: Config filename to search for (cascading mode only).
779
+ path: Explicit path to config file (direct mode). If set, cascading is disabled.
780
+ strict_format: If True, included files must match parent file format.
781
+ sops_decrypt: If True, auto-decrypt .sops.* files via SOPS binary.
782
+
783
+ Returns:
784
+ Configuration object with dot notation support (Box object).
785
+ Missing keys return empty Box() instead of raising AttributeError.
786
+
787
+ Raises:
788
+ ConfigFileNotFoundError: If no config file is found or specified path doesn't exist.
789
+ ConfigFormatError: On unsupported format or format mismatch.
790
+ ConfigCircularIncludeError: On circular includes.
791
+
792
+ Examples:
793
+ Cascading search (default)::
794
+
795
+ >>> config = load_config("myapp.yml") # doctest: +SKIP
796
+
797
+ Direct load from specific path::
798
+
799
+ >>> config = load_config(path="/opt/myapp/config.yml") # doctest: +SKIP
800
+
801
+ Direct load with strict format enforcement::
802
+
803
+ >>> config = load_config(path="/etc/app.yml", strict_format=True) # doctest: +SKIP
804
+ """
805
+ loader = ConfigLoader(strict_format=strict_format, sops_decrypt=sops_decrypt)
806
+ if path is not None:
807
+ return loader.load_from_file(path)
808
+ return loader.load(filename)
809
+
810
+
811
+ def get_config(
812
+ filename: str = CONFIG_FILENAME,
813
+ force_reload: bool = False,
814
+ max_age: float | None = None,
815
+ ) -> Box:
816
+ """
817
+ Returns the current kstlib configuration object (singleton).
818
+
819
+ Loads the configuration only once, unless `force_reload=True` is set.
820
+
821
+ This is a backward-compatible wrapper. For new code, prefer ConfigLoader class.
822
+
823
+ Args:
824
+ filename: Name of the config file to search for.
825
+ force_reload: Force reloading the configuration from disk.
826
+ max_age: Optional cache lifetime in seconds; refreshes automatically
827
+ when the cached configuration is older than this value.
828
+
829
+ Returns:
830
+ Box: Configuration object (dot notation enabled).
831
+
832
+ Raises:
833
+ ConfigFileNotFoundError: If no configuration file is found in any location.
834
+
835
+ Examples:
836
+ >>> config = get_config() # doctest: +SKIP
837
+ >>> config = get_config(force_reload=True) # doctest: +SKIP
838
+ """
839
+ cache_stale = False
840
+ if not force_reload and max_age is not None and _default_loader.cache is not None:
841
+ loaded_at = _default_loader.cache_timestamp
842
+ cache_stale = loaded_at is None or (time.time() - loaded_at > max_age)
843
+
844
+ if _default_loader.cache is None or force_reload or cache_stale:
845
+ _default_loader.cache = _default_loader.load(filename)
846
+ return _default_loader.cache
847
+
848
+
849
+ def require_config() -> Box:
850
+ """
851
+ Returns the configuration object, raising an exception if not loaded.
852
+
853
+ Use this when you need to ensure a config is available.
854
+
855
+ This is a backward-compatible wrapper. For new code, prefer ConfigLoader class.
856
+
857
+ Returns:
858
+ Loaded configuration.
859
+
860
+ Raises:
861
+ ConfigNotLoadedError: If configuration has not been loaded yet.
862
+
863
+ Examples:
864
+ >>> config = require_config() # doctest: +SKIP
865
+ """
866
+ if _default_loader.cache is None:
867
+ raise ConfigNotLoadedError("Configuration not loaded yet. Call get_config() before accessing the config.")
868
+ return _default_loader.cache
869
+
870
+
871
+ def load_from_file(
872
+ path: str | pathlib.Path,
873
+ strict_format: bool = False,
874
+ sops_decrypt: bool = True,
875
+ ) -> Box:
876
+ """
877
+ Load configuration from a specific file path.
878
+
879
+ Convenience wrapper for load_config(path=...).
880
+
881
+ This is a backward-compatible wrapper. For new code, prefer ConfigLoader.from_file().
882
+
883
+ Args:
884
+ path: Path to configuration file (str or Path object).
885
+ strict_format: If True, included files must match parent file format.
886
+ sops_decrypt: If True, auto-decrypt .sops.* files via SOPS binary.
887
+
888
+ Returns:
889
+ Configuration object with dot notation support.
890
+
891
+ Raises:
892
+ ConfigFileNotFoundError: If the specified file doesn't exist.
893
+ ConfigFormatError: On unsupported format or format mismatch.
894
+ ConfigCircularIncludeError: On circular includes.
895
+
896
+ Examples:
897
+ >>> config = load_from_file("/opt/myapp/config.yml") # doctest: +SKIP
898
+ >>> config = load_from_file("/etc/app.yml", strict_format=True) # doctest: +SKIP
899
+ """
900
+ return ConfigLoader(strict_format=strict_format, sops_decrypt=sops_decrypt).load_from_file(path)
901
+
902
+
903
+ def load_from_env(
904
+ env_var: str = "CONFIG_PATH",
905
+ strict_format: bool = False,
906
+ sops_decrypt: bool = True,
907
+ ) -> Box:
908
+ """
909
+ Load configuration from path specified in an environment variable.
910
+
911
+ This is a backward-compatible wrapper. For new code, prefer ConfigLoader.from_env().
912
+
913
+ Args:
914
+ env_var: Name of environment variable containing config file path.
915
+ strict_format: If True, included files must match parent file format.
916
+ sops_decrypt: If True, auto-decrypt .sops.* files via SOPS binary.
917
+
918
+ Returns:
919
+ Configuration object with dot notation support.
920
+
921
+ Raises:
922
+ ValueError: If environment variable is not set or empty.
923
+ ConfigFileNotFoundError: If the path in environment variable doesn't exist.
924
+
925
+ Examples:
926
+ With CONFIG_PATH=/opt/myapp.yml::
927
+
928
+ >>> config = load_from_env() # doctest: +SKIP
929
+
930
+ With MYAPP_CONFIG=/etc/app.yml::
931
+
932
+ >>> config = load_from_env("MYAPP_CONFIG") # doctest: +SKIP
933
+
934
+ With strict format enforcement::
935
+
936
+ >>> config = load_from_env("CONFIG_PATH", strict_format=True) # doctest: +SKIP
937
+ """
938
+ return ConfigLoader(strict_format=strict_format, sops_decrypt=sops_decrypt).load_from_env(env_var)
939
+
940
+
941
+ def clear_config() -> None:
942
+ """
943
+ Clear the singleton configuration cache.
944
+
945
+ Useful for testing or when you need to reload configuration.
946
+
947
+ Examples:
948
+ >>> clear_config() # doctest: +SKIP
949
+ >>> config = get_config() # Will reload from disk # doctest: +SKIP
950
+ """
951
+ _default_loader.cache = None
952
+
953
+
954
+ __all__ = [
955
+ "AutoDiscoveryConfig",
956
+ "ConfigLoader",
957
+ "clear_config",
958
+ "get_config",
959
+ "load_config",
960
+ "load_from_env",
961
+ "load_from_file",
962
+ "require_config",
963
+ ]