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.
@@ -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()