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.
- kstlib/__init__.py +266 -1
- kstlib/__main__.py +16 -0
- kstlib/alerts/__init__.py +110 -0
- kstlib/alerts/channels/__init__.py +36 -0
- kstlib/alerts/channels/base.py +197 -0
- kstlib/alerts/channels/email.py +227 -0
- kstlib/alerts/channels/slack.py +389 -0
- kstlib/alerts/exceptions.py +72 -0
- kstlib/alerts/manager.py +651 -0
- kstlib/alerts/models.py +142 -0
- kstlib/alerts/throttle.py +263 -0
- kstlib/auth/__init__.py +139 -0
- kstlib/auth/callback.py +399 -0
- kstlib/auth/config.py +502 -0
- kstlib/auth/errors.py +127 -0
- kstlib/auth/models.py +316 -0
- kstlib/auth/providers/__init__.py +14 -0
- kstlib/auth/providers/base.py +393 -0
- kstlib/auth/providers/oauth2.py +645 -0
- kstlib/auth/providers/oidc.py +821 -0
- kstlib/auth/session.py +338 -0
- kstlib/auth/token.py +482 -0
- kstlib/cache/__init__.py +50 -0
- kstlib/cache/decorator.py +261 -0
- kstlib/cache/strategies.py +516 -0
- kstlib/cli/__init__.py +8 -0
- kstlib/cli/app.py +195 -0
- kstlib/cli/commands/__init__.py +5 -0
- kstlib/cli/commands/auth/__init__.py +39 -0
- kstlib/cli/commands/auth/common.py +122 -0
- kstlib/cli/commands/auth/login.py +325 -0
- kstlib/cli/commands/auth/logout.py +74 -0
- kstlib/cli/commands/auth/providers.py +57 -0
- kstlib/cli/commands/auth/status.py +291 -0
- kstlib/cli/commands/auth/token.py +199 -0
- kstlib/cli/commands/auth/whoami.py +106 -0
- kstlib/cli/commands/config.py +89 -0
- kstlib/cli/commands/ops/__init__.py +39 -0
- kstlib/cli/commands/ops/attach.py +49 -0
- kstlib/cli/commands/ops/common.py +269 -0
- kstlib/cli/commands/ops/list_sessions.py +252 -0
- kstlib/cli/commands/ops/logs.py +49 -0
- kstlib/cli/commands/ops/start.py +98 -0
- kstlib/cli/commands/ops/status.py +138 -0
- kstlib/cli/commands/ops/stop.py +60 -0
- kstlib/cli/commands/rapi/__init__.py +60 -0
- kstlib/cli/commands/rapi/call.py +341 -0
- kstlib/cli/commands/rapi/list.py +99 -0
- kstlib/cli/commands/rapi/show.py +206 -0
- kstlib/cli/commands/secrets/__init__.py +35 -0
- kstlib/cli/commands/secrets/common.py +425 -0
- kstlib/cli/commands/secrets/decrypt.py +88 -0
- kstlib/cli/commands/secrets/doctor.py +743 -0
- kstlib/cli/commands/secrets/encrypt.py +242 -0
- kstlib/cli/commands/secrets/shred.py +96 -0
- kstlib/cli/common.py +86 -0
- kstlib/config/__init__.py +76 -0
- kstlib/config/exceptions.py +110 -0
- kstlib/config/export.py +225 -0
- kstlib/config/loader.py +963 -0
- kstlib/config/sops.py +287 -0
- kstlib/db/__init__.py +54 -0
- kstlib/db/aiosqlcipher.py +137 -0
- kstlib/db/cipher.py +112 -0
- kstlib/db/database.py +367 -0
- kstlib/db/exceptions.py +25 -0
- kstlib/db/pool.py +302 -0
- kstlib/helpers/__init__.py +35 -0
- kstlib/helpers/exceptions.py +11 -0
- kstlib/helpers/time_trigger.py +396 -0
- kstlib/kstlib.conf.yml +890 -0
- kstlib/limits.py +963 -0
- kstlib/logging/__init__.py +108 -0
- kstlib/logging/manager.py +633 -0
- kstlib/mail/__init__.py +42 -0
- kstlib/mail/builder.py +626 -0
- kstlib/mail/exceptions.py +27 -0
- kstlib/mail/filesystem.py +248 -0
- kstlib/mail/transport.py +224 -0
- kstlib/mail/transports/__init__.py +19 -0
- kstlib/mail/transports/gmail.py +268 -0
- kstlib/mail/transports/resend.py +324 -0
- kstlib/mail/transports/smtp.py +326 -0
- kstlib/meta.py +72 -0
- kstlib/metrics/__init__.py +88 -0
- kstlib/metrics/decorators.py +1090 -0
- kstlib/metrics/exceptions.py +14 -0
- kstlib/monitoring/__init__.py +116 -0
- kstlib/monitoring/_styles.py +163 -0
- kstlib/monitoring/cell.py +57 -0
- kstlib/monitoring/config.py +424 -0
- kstlib/monitoring/delivery.py +579 -0
- kstlib/monitoring/exceptions.py +63 -0
- kstlib/monitoring/image.py +220 -0
- kstlib/monitoring/kv.py +79 -0
- kstlib/monitoring/list.py +69 -0
- kstlib/monitoring/metric.py +88 -0
- kstlib/monitoring/monitoring.py +341 -0
- kstlib/monitoring/renderer.py +139 -0
- kstlib/monitoring/service.py +392 -0
- kstlib/monitoring/table.py +129 -0
- kstlib/monitoring/types.py +56 -0
- kstlib/ops/__init__.py +86 -0
- kstlib/ops/base.py +148 -0
- kstlib/ops/container.py +577 -0
- kstlib/ops/exceptions.py +209 -0
- kstlib/ops/manager.py +407 -0
- kstlib/ops/models.py +176 -0
- kstlib/ops/tmux.py +372 -0
- kstlib/ops/validators.py +287 -0
- kstlib/py.typed +0 -0
- kstlib/rapi/__init__.py +118 -0
- kstlib/rapi/client.py +875 -0
- kstlib/rapi/config.py +861 -0
- kstlib/rapi/credentials.py +887 -0
- kstlib/rapi/exceptions.py +213 -0
- kstlib/resilience/__init__.py +101 -0
- kstlib/resilience/circuit_breaker.py +440 -0
- kstlib/resilience/exceptions.py +95 -0
- kstlib/resilience/heartbeat.py +491 -0
- kstlib/resilience/rate_limiter.py +506 -0
- kstlib/resilience/shutdown.py +417 -0
- kstlib/resilience/watchdog.py +637 -0
- kstlib/secrets/__init__.py +29 -0
- kstlib/secrets/exceptions.py +19 -0
- kstlib/secrets/models.py +62 -0
- kstlib/secrets/providers/__init__.py +79 -0
- kstlib/secrets/providers/base.py +58 -0
- kstlib/secrets/providers/environment.py +66 -0
- kstlib/secrets/providers/keyring.py +107 -0
- kstlib/secrets/providers/kms.py +223 -0
- kstlib/secrets/providers/kwargs.py +101 -0
- kstlib/secrets/providers/sops.py +209 -0
- kstlib/secrets/resolver.py +221 -0
- kstlib/secrets/sensitive.py +130 -0
- kstlib/secure/__init__.py +23 -0
- kstlib/secure/fs.py +194 -0
- kstlib/secure/permissions.py +70 -0
- kstlib/ssl.py +347 -0
- kstlib/ui/__init__.py +23 -0
- kstlib/ui/exceptions.py +26 -0
- kstlib/ui/panels.py +484 -0
- kstlib/ui/spinner.py +864 -0
- kstlib/ui/tables.py +382 -0
- kstlib/utils/__init__.py +48 -0
- kstlib/utils/dict.py +36 -0
- kstlib/utils/formatting.py +338 -0
- kstlib/utils/http_trace.py +237 -0
- kstlib/utils/lazy.py +49 -0
- kstlib/utils/secure_delete.py +205 -0
- kstlib/utils/serialization.py +247 -0
- kstlib/utils/text.py +56 -0
- kstlib/utils/validators.py +124 -0
- kstlib/websocket/__init__.py +97 -0
- kstlib/websocket/exceptions.py +214 -0
- kstlib/websocket/manager.py +1102 -0
- kstlib/websocket/models.py +361 -0
- kstlib-1.0.1.dist-info/METADATA +201 -0
- kstlib-1.0.1.dist-info/RECORD +163 -0
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.dist-info}/WHEEL +1 -1
- kstlib-1.0.1.dist-info/entry_points.txt +2 -0
- kstlib-1.0.1.dist-info/licenses/LICENSE.md +9 -0
- kstlib-0.0.1a0.dist-info/METADATA +0 -29
- kstlib-0.0.1a0.dist-info/RECORD +0 -6
- kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.dist-info}/top_level.txt +0 -0
kstlib/config/loader.py
ADDED
|
@@ -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
|
+
]
|