lightfall-utils 0.1.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.
- lightfall_utils/__init__.py +3 -0
- lightfall_utils/_version.py +24 -0
- lightfall_utils/ca/__init__.py +9 -0
- lightfall_utils/ca/context.py +100 -0
- lightfall_utils/ca/pv.py +307 -0
- lightfall_utils/caproto_shutdown.py +89 -0
- lightfall_utils/config/__init__.py +12 -0
- lightfall_utils/config/layers.py +317 -0
- lightfall_utils/config/manager.py +246 -0
- lightfall_utils/log_buffer.py +231 -0
- lightfall_utils/logging.py +235 -0
- lightfall_utils/py.typed +0 -0
- lightfall_utils/qt_affinity.py +92 -0
- lightfall_utils/theming/__init__.py +30 -0
- lightfall_utils/theming/builtin.py +737 -0
- lightfall_utils/theming/manager.py +1030 -0
- lightfall_utils/theming/provider.py +102 -0
- lightfall_utils/theming/registry.py +195 -0
- lightfall_utils/threads.py +1071 -0
- lightfall_utils-0.1.0.dist-info/METADATA +54 -0
- lightfall_utils-0.1.0.dist-info/RECORD +24 -0
- lightfall_utils-0.1.0.dist-info/WHEEL +4 -0
- lightfall_utils-0.1.0.dist-info/licenses/LEGAL.md +13 -0
- lightfall_utils-0.1.0.dist-info/licenses/LICENSE.md +33 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Priority-layered configuration system.
|
|
2
|
+
|
|
3
|
+
Implements a priority-based configuration system where settings from
|
|
4
|
+
different sources are merged, with higher-priority layers overriding
|
|
5
|
+
lower-priority ones.
|
|
6
|
+
|
|
7
|
+
Priority order (lowest to highest):
|
|
8
|
+
Defaults -> Global -> Beamline -> User -> Session
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Iterator
|
|
14
|
+
from copy import deepcopy
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from enum import IntEnum
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import yaml
|
|
21
|
+
|
|
22
|
+
from lightfall_utils.logging import logger
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ConfigPriority(IntEnum):
|
|
26
|
+
"""Configuration layer priorities."""
|
|
27
|
+
|
|
28
|
+
DEFAULTS = 0
|
|
29
|
+
GLOBAL = 10
|
|
30
|
+
BEAMLINE = 20
|
|
31
|
+
USER = 30
|
|
32
|
+
SESSION = 40
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class ConfigLayer:
|
|
37
|
+
"""
|
|
38
|
+
A single configuration layer.
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
name: Human-readable layer name.
|
|
42
|
+
priority: Layer priority (higher overrides lower).
|
|
43
|
+
data: Configuration data dictionary.
|
|
44
|
+
source: Optional path or identifier for the data source.
|
|
45
|
+
mutable: Whether this layer can be modified at runtime.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
name: str
|
|
49
|
+
priority: ConfigPriority | int
|
|
50
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
51
|
+
source: Path | str | None = None
|
|
52
|
+
mutable: bool = False
|
|
53
|
+
|
|
54
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
55
|
+
"""Get a value from this layer using dot notation.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
key: Dot-separated key path (e.g., "ui.theme").
|
|
59
|
+
default: Default value if key not found.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
The value or default.
|
|
63
|
+
"""
|
|
64
|
+
parts = key.split(".")
|
|
65
|
+
value = self.data
|
|
66
|
+
for part in parts:
|
|
67
|
+
if isinstance(value, dict) and part in value:
|
|
68
|
+
value = value[part]
|
|
69
|
+
else:
|
|
70
|
+
return default
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
def set(self, key: str, value: Any) -> None:
|
|
74
|
+
"""Set a value in this layer using dot notation.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
key: Dot-separated key path.
|
|
78
|
+
value: Value to set.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
RuntimeError: If layer is not mutable.
|
|
82
|
+
"""
|
|
83
|
+
if not self.mutable:
|
|
84
|
+
raise RuntimeError(f"Cannot modify immutable layer '{self.name}'")
|
|
85
|
+
|
|
86
|
+
parts = key.split(".")
|
|
87
|
+
data = self.data
|
|
88
|
+
for part in parts[:-1]:
|
|
89
|
+
if part not in data:
|
|
90
|
+
data[part] = {}
|
|
91
|
+
data = data[part]
|
|
92
|
+
data[parts[-1]] = value
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def from_file(
|
|
96
|
+
cls,
|
|
97
|
+
path: Path | str,
|
|
98
|
+
name: str | None = None,
|
|
99
|
+
priority: ConfigPriority | int = ConfigPriority.USER,
|
|
100
|
+
mutable: bool = False,
|
|
101
|
+
) -> ConfigLayer:
|
|
102
|
+
"""
|
|
103
|
+
Create a config layer from a YAML file.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
path: Path to YAML configuration file.
|
|
107
|
+
name: Layer name (defaults to filename).
|
|
108
|
+
priority: Layer priority.
|
|
109
|
+
mutable: Whether layer is mutable.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
A new ConfigLayer instance.
|
|
113
|
+
"""
|
|
114
|
+
path = Path(path)
|
|
115
|
+
layer_name = name or path.stem
|
|
116
|
+
|
|
117
|
+
if not path.exists():
|
|
118
|
+
logger.debug("Config file not found: {}", path)
|
|
119
|
+
return cls(name=layer_name, priority=priority, source=path, mutable=mutable)
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
with path.open() as f:
|
|
123
|
+
data = yaml.safe_load(f) or {}
|
|
124
|
+
logger.debug("Loaded config layer '{}' from {}", layer_name, path)
|
|
125
|
+
return cls(
|
|
126
|
+
name=layer_name,
|
|
127
|
+
priority=priority,
|
|
128
|
+
data=data,
|
|
129
|
+
source=path,
|
|
130
|
+
mutable=mutable,
|
|
131
|
+
)
|
|
132
|
+
except Exception as e:
|
|
133
|
+
logger.warning("Failed to load config from {}: {}", path, e)
|
|
134
|
+
return cls(name=layer_name, priority=priority, source=path, mutable=mutable)
|
|
135
|
+
|
|
136
|
+
def save(self) -> None:
|
|
137
|
+
"""Save this layer to its source file.
|
|
138
|
+
|
|
139
|
+
Raises:
|
|
140
|
+
RuntimeError: If no file source is set.
|
|
141
|
+
"""
|
|
142
|
+
if not isinstance(self.source, Path):
|
|
143
|
+
raise RuntimeError(f"Cannot save layer '{self.name}': no file source")
|
|
144
|
+
|
|
145
|
+
self.source.parent.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
with self.source.open("w") as f:
|
|
147
|
+
yaml.safe_dump(self.data, f, default_flow_style=False, sort_keys=False)
|
|
148
|
+
logger.debug("Saved config layer '{}' to {}", self.name, self.source)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class LayeredConfig:
|
|
152
|
+
"""
|
|
153
|
+
Manages multiple configuration layers with priority-based merging.
|
|
154
|
+
|
|
155
|
+
Configuration values from higher-priority layers override those from
|
|
156
|
+
lower-priority layers. Deep merging is performed for nested dictionaries.
|
|
157
|
+
|
|
158
|
+
Example:
|
|
159
|
+
>>> config = LayeredConfig()
|
|
160
|
+
>>> config.add_layer(ConfigLayer("defaults", ConfigPriority.DEFAULTS, {"ui": {"theme": "light"}}))
|
|
161
|
+
>>> config.add_layer(ConfigLayer("user", ConfigPriority.USER, {"ui": {"theme": "dark"}}))
|
|
162
|
+
>>> config.get("ui.theme") # Returns "dark"
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
def __init__(self) -> None:
|
|
166
|
+
self._layers: list[ConfigLayer] = []
|
|
167
|
+
self._merged: dict[str, Any] = {}
|
|
168
|
+
self._dirty = True
|
|
169
|
+
|
|
170
|
+
def add_layer(self, layer: ConfigLayer) -> None:
|
|
171
|
+
"""
|
|
172
|
+
Add a configuration layer.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
layer: The layer to add.
|
|
176
|
+
"""
|
|
177
|
+
# Remove existing layer with same name
|
|
178
|
+
self._layers = [lyr for lyr in self._layers if lyr.name != layer.name]
|
|
179
|
+
self._layers.append(layer)
|
|
180
|
+
self._layers.sort(key=lambda lyr: lyr.priority)
|
|
181
|
+
self._dirty = True
|
|
182
|
+
logger.debug(
|
|
183
|
+
"Added config layer '{}' at priority {}",
|
|
184
|
+
layer.name,
|
|
185
|
+
layer.priority,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def remove_layer(self, name: str) -> bool:
|
|
189
|
+
"""
|
|
190
|
+
Remove a layer by name.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
name: Name of the layer to remove.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
True if layer was found and removed.
|
|
197
|
+
"""
|
|
198
|
+
original_count = len(self._layers)
|
|
199
|
+
self._layers = [lyr for lyr in self._layers if lyr.name != name]
|
|
200
|
+
if len(self._layers) < original_count:
|
|
201
|
+
self._dirty = True
|
|
202
|
+
logger.debug("Removed config layer '{}'", name)
|
|
203
|
+
return True
|
|
204
|
+
return False
|
|
205
|
+
|
|
206
|
+
def get_layer(self, name: str) -> ConfigLayer | None:
|
|
207
|
+
"""
|
|
208
|
+
Get a layer by name.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
name: Layer name.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
The layer or None if not found.
|
|
215
|
+
"""
|
|
216
|
+
for layer in self._layers:
|
|
217
|
+
if layer.name == name:
|
|
218
|
+
return layer
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
def layers(self) -> Iterator[ConfigLayer]:
|
|
222
|
+
"""Iterate over layers in priority order (lowest first)."""
|
|
223
|
+
yield from self._layers
|
|
224
|
+
|
|
225
|
+
def _merge(self) -> dict[str, Any]:
|
|
226
|
+
"""Merge all layers into a single configuration dict."""
|
|
227
|
+
if not self._dirty:
|
|
228
|
+
return self._merged
|
|
229
|
+
|
|
230
|
+
result: dict[str, Any] = {}
|
|
231
|
+
for layer in self._layers:
|
|
232
|
+
result = self._deep_merge(result, layer.data)
|
|
233
|
+
|
|
234
|
+
self._merged = result
|
|
235
|
+
self._dirty = False
|
|
236
|
+
return result
|
|
237
|
+
|
|
238
|
+
@staticmethod
|
|
239
|
+
def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
|
240
|
+
"""Deep merge two dictionaries.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
base: Base dictionary.
|
|
244
|
+
override: Override dictionary (takes precedence).
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
Merged dictionary.
|
|
248
|
+
"""
|
|
249
|
+
result = deepcopy(base)
|
|
250
|
+
for key, value in override.items():
|
|
251
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
252
|
+
result[key] = LayeredConfig._deep_merge(result[key], value)
|
|
253
|
+
else:
|
|
254
|
+
result[key] = deepcopy(value)
|
|
255
|
+
return result
|
|
256
|
+
|
|
257
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
258
|
+
"""
|
|
259
|
+
Get a configuration value using dot notation.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
key: Dot-separated key path (e.g., "ui.theme").
|
|
263
|
+
default: Default value if key not found.
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
The configuration value or default.
|
|
267
|
+
"""
|
|
268
|
+
merged = self._merge()
|
|
269
|
+
parts = key.split(".")
|
|
270
|
+
value = merged
|
|
271
|
+
for part in parts:
|
|
272
|
+
if isinstance(value, dict) and part in value:
|
|
273
|
+
value = value[part]
|
|
274
|
+
else:
|
|
275
|
+
return default
|
|
276
|
+
return value
|
|
277
|
+
|
|
278
|
+
def set(self, key: str, value: Any, *, layer_name: str | None = None) -> None:
|
|
279
|
+
"""
|
|
280
|
+
Set a configuration value.
|
|
281
|
+
|
|
282
|
+
If no layer is specified, uses the highest-priority mutable layer.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
key: Dot-separated key path.
|
|
286
|
+
value: Value to set.
|
|
287
|
+
layer_name: Target layer name (optional).
|
|
288
|
+
|
|
289
|
+
Raises:
|
|
290
|
+
RuntimeError: If no mutable layer is available.
|
|
291
|
+
"""
|
|
292
|
+
target_layer: ConfigLayer | None = None
|
|
293
|
+
|
|
294
|
+
if layer_name:
|
|
295
|
+
target_layer = self.get_layer(layer_name)
|
|
296
|
+
if target_layer is None:
|
|
297
|
+
raise RuntimeError(f"Layer '{layer_name}' not found")
|
|
298
|
+
else:
|
|
299
|
+
# Find highest-priority mutable layer
|
|
300
|
+
for layer in reversed(self._layers):
|
|
301
|
+
if layer.mutable:
|
|
302
|
+
target_layer = layer
|
|
303
|
+
break
|
|
304
|
+
|
|
305
|
+
if target_layer is None:
|
|
306
|
+
raise RuntimeError("No mutable configuration layer available")
|
|
307
|
+
|
|
308
|
+
target_layer.set(key, value)
|
|
309
|
+
self._dirty = True
|
|
310
|
+
|
|
311
|
+
def as_dict(self) -> dict[str, Any]:
|
|
312
|
+
"""Return the merged configuration as a dictionary."""
|
|
313
|
+
return deepcopy(self._merge())
|
|
314
|
+
|
|
315
|
+
def mark_dirty(self) -> None:
|
|
316
|
+
"""Mark the configuration as needing re-merge."""
|
|
317
|
+
self._dirty = True
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""High-level configuration manager.
|
|
2
|
+
|
|
3
|
+
Wraps LayeredConfig with pydantic validation and automatic loading from
|
|
4
|
+
platform-standard locations. The validating model class, application
|
|
5
|
+
directory name, and bundled-defaults location are supplied by the host
|
|
6
|
+
application.
|
|
7
|
+
|
|
8
|
+
Standard configuration locations (in priority order):
|
|
9
|
+
1. Bundled defaults (``defaults_path``, if given)
|
|
10
|
+
2. Global config (``/etc/<app_name>/`` or ``%PROGRAMDATA%/<app_name>/``)
|
|
11
|
+
3. User config (``~/.config/<app_name>/`` or ``%APPDATA%/<app_name>/``)
|
|
12
|
+
4. Session overrides (runtime-only)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TYPE_CHECKING, Any
|
|
20
|
+
|
|
21
|
+
from pydantic import BaseModel, ValidationError
|
|
22
|
+
|
|
23
|
+
from lightfall_utils.config.layers import ConfigLayer, ConfigPriority, LayeredConfig
|
|
24
|
+
from lightfall_utils.logging import logger
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from collections.abc import Sequence
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PermissiveModel(BaseModel):
|
|
31
|
+
"""Default schema: accepts any keys, validates nothing."""
|
|
32
|
+
|
|
33
|
+
model_config = {"extra": "allow"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _user_config_dir(app_name: str) -> Path:
|
|
37
|
+
"""Per-user configuration directory for *app_name*."""
|
|
38
|
+
if os.name == "nt":
|
|
39
|
+
base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
|
|
40
|
+
else:
|
|
41
|
+
base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
|
42
|
+
return base / app_name
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _global_config_dir(app_name: str) -> Path:
|
|
46
|
+
"""System-wide configuration directory for *app_name*."""
|
|
47
|
+
if os.name == "nt":
|
|
48
|
+
return Path(os.environ.get("PROGRAMDATA", "C:/ProgramData")) / app_name
|
|
49
|
+
return Path("/etc") / app_name
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ConfigManager:
|
|
53
|
+
"""Layered configuration with pydantic validation.
|
|
54
|
+
|
|
55
|
+
Example:
|
|
56
|
+
>>> config = ConfigManager(model_class=MyConfig, app_name="myapp")
|
|
57
|
+
>>> config.get("ui.theme")
|
|
58
|
+
"dark"
|
|
59
|
+
>>> config.model.ui.theme
|
|
60
|
+
"dark"
|
|
61
|
+
>>> config.set("ui.theme", "light", persist=True)
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
DEFAULT_CONFIG_FILENAME = "application.yaml"
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
*,
|
|
69
|
+
model_class: type[BaseModel] = PermissiveModel,
|
|
70
|
+
app_name: str = "lightfall-utils",
|
|
71
|
+
defaults_path: Path | str | None = None,
|
|
72
|
+
default_filename: str | None = None,
|
|
73
|
+
extra_paths: Sequence[Path | str] | None = None,
|
|
74
|
+
skip_standard_paths: bool = False,
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Initialize the ConfigManager.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
model_class: Pydantic model the merged config validates against.
|
|
80
|
+
app_name: Directory name under the platform config locations.
|
|
81
|
+
defaults_path: Directory holding the bundled defaults file
|
|
82
|
+
(loaded as the lowest-priority layer when given).
|
|
83
|
+
default_filename: Config filename (default "application.yaml").
|
|
84
|
+
extra_paths: Additional configuration files to load.
|
|
85
|
+
skip_standard_paths: Skip standard locations (useful for testing).
|
|
86
|
+
"""
|
|
87
|
+
self._model_class = model_class
|
|
88
|
+
self._app_name = app_name
|
|
89
|
+
self._defaults_path = Path(defaults_path) if defaults_path is not None else None
|
|
90
|
+
self._filename = default_filename or self.DEFAULT_CONFIG_FILENAME
|
|
91
|
+
self._layered = LayeredConfig()
|
|
92
|
+
self._model: BaseModel | None = None
|
|
93
|
+
self._validation_errors: list[str] = []
|
|
94
|
+
|
|
95
|
+
if not skip_standard_paths:
|
|
96
|
+
self._load_standard_layers()
|
|
97
|
+
|
|
98
|
+
if extra_paths:
|
|
99
|
+
for i, path in enumerate(extra_paths):
|
|
100
|
+
self._layered.add_layer(
|
|
101
|
+
ConfigLayer.from_file(
|
|
102
|
+
Path(path),
|
|
103
|
+
name=f"extra_{i}",
|
|
104
|
+
priority=ConfigPriority.USER + 1 + i,
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Add session layer (mutable, runtime-only)
|
|
109
|
+
self._layered.add_layer(
|
|
110
|
+
ConfigLayer(
|
|
111
|
+
name="session",
|
|
112
|
+
priority=ConfigPriority.SESSION,
|
|
113
|
+
mutable=True,
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# Initialize model
|
|
118
|
+
self._rebuild_model()
|
|
119
|
+
|
|
120
|
+
def _load_standard_layers(self) -> None:
|
|
121
|
+
"""Load configuration from standard locations."""
|
|
122
|
+
# 1. Bundled defaults
|
|
123
|
+
if self._defaults_path is not None:
|
|
124
|
+
self._layered.add_layer(
|
|
125
|
+
ConfigLayer.from_file(
|
|
126
|
+
self._defaults_path / self._filename,
|
|
127
|
+
name="defaults",
|
|
128
|
+
priority=ConfigPriority.DEFAULTS,
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# 2. Global config
|
|
133
|
+
global_path = _global_config_dir(self._app_name) / self._filename
|
|
134
|
+
self._layered.add_layer(
|
|
135
|
+
ConfigLayer.from_file(global_path, name="global", priority=ConfigPriority.GLOBAL)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# 3. User config
|
|
139
|
+
user_path = _user_config_dir(self._app_name) / self._filename
|
|
140
|
+
self._layered.add_layer(
|
|
141
|
+
ConfigLayer.from_file(
|
|
142
|
+
user_path, name="user", priority=ConfigPriority.USER, mutable=True
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def _rebuild_model(self) -> None:
|
|
147
|
+
"""Rebuild the pydantic model from current configuration."""
|
|
148
|
+
data = self._layered.as_dict()
|
|
149
|
+
self._validation_errors.clear()
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
self._model = self._model_class.model_validate(data)
|
|
153
|
+
except ValidationError as e:
|
|
154
|
+
self._validation_errors = [str(err) for err in e.errors()]
|
|
155
|
+
logger.warning("Configuration validation errors: {}", self._validation_errors)
|
|
156
|
+
# Fall back to defaults
|
|
157
|
+
self._model = self._model_class()
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def model(self) -> BaseModel:
|
|
161
|
+
"""Get the validated configuration model."""
|
|
162
|
+
if self._model is None:
|
|
163
|
+
self._rebuild_model()
|
|
164
|
+
return self._model # type: ignore[return-value]
|
|
165
|
+
|
|
166
|
+
def get_user_config_path(self) -> Path:
|
|
167
|
+
"""Get the path to the user configuration file."""
|
|
168
|
+
return _user_config_dir(self._app_name) / self._filename
|
|
169
|
+
|
|
170
|
+
def ensure_user_config_dir(self) -> Path:
|
|
171
|
+
"""Ensure user config directory exists and return its path."""
|
|
172
|
+
path = _user_config_dir(self._app_name)
|
|
173
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
174
|
+
return path
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def validation_errors(self) -> list[str]:
|
|
178
|
+
"""Get any validation errors from the last model rebuild."""
|
|
179
|
+
return list(self._validation_errors)
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def layers(self) -> LayeredConfig:
|
|
183
|
+
"""Access the underlying LayeredConfig."""
|
|
184
|
+
return self._layered
|
|
185
|
+
|
|
186
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
187
|
+
"""
|
|
188
|
+
Get a configuration value using dot notation.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
key: Dot-separated key path (e.g., "ui.theme").
|
|
192
|
+
default: Default value if key not found.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
The configuration value.
|
|
196
|
+
"""
|
|
197
|
+
return self._layered.get(key, default)
|
|
198
|
+
|
|
199
|
+
def set(self, key: str, value: Any, *, persist: bool = False) -> None:
|
|
200
|
+
"""
|
|
201
|
+
Set a configuration value.
|
|
202
|
+
|
|
203
|
+
By default, sets in the session layer (runtime-only).
|
|
204
|
+
Use persist=True to save to the user configuration file.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
key: Dot-separated key path.
|
|
208
|
+
value: Value to set.
|
|
209
|
+
persist: If True, save to user config file.
|
|
210
|
+
"""
|
|
211
|
+
if persist:
|
|
212
|
+
self._layered.set(key, value, layer_name="user")
|
|
213
|
+
user_layer = self._layered.get_layer("user")
|
|
214
|
+
if user_layer:
|
|
215
|
+
user_layer.save()
|
|
216
|
+
else:
|
|
217
|
+
self._layered.set(key, value, layer_name="session")
|
|
218
|
+
|
|
219
|
+
self._model = None # Invalidate cached model
|
|
220
|
+
|
|
221
|
+
def reload(self) -> None:
|
|
222
|
+
"""Reload configuration from all sources."""
|
|
223
|
+
# Re-load file-based layers
|
|
224
|
+
for layer in self._layered.layers():
|
|
225
|
+
if isinstance(layer.source, Path) and layer.source.exists():
|
|
226
|
+
reloaded = ConfigLayer.from_file(
|
|
227
|
+
layer.source,
|
|
228
|
+
name=layer.name,
|
|
229
|
+
priority=layer.priority,
|
|
230
|
+
mutable=layer.mutable,
|
|
231
|
+
)
|
|
232
|
+
self._layered.add_layer(reloaded)
|
|
233
|
+
|
|
234
|
+
self._rebuild_model()
|
|
235
|
+
logger.info("Configuration reloaded")
|
|
236
|
+
|
|
237
|
+
def save_user_config(self) -> None:
|
|
238
|
+
"""Save user layer to file."""
|
|
239
|
+
user_layer = self._layered.get_layer("user")
|
|
240
|
+
if user_layer:
|
|
241
|
+
user_layer.save()
|
|
242
|
+
logger.info("User configuration saved")
|
|
243
|
+
|
|
244
|
+
def as_dict(self) -> dict[str, Any]:
|
|
245
|
+
"""Return the merged configuration as a dictionary."""
|
|
246
|
+
return self._layered.as_dict()
|