e_lib.config
A small utility class for reading and writing configuration files in YAML or JSON format. The class supports optional validation using a Pydantic model.
The implementation intentionally avoids external dependencies such as
watchdog – the set_auto_reload method is kept for API compatibility but
simply logs that auto‑reload is not available.
1# -*- coding: utf-8 -*- 2"""e_lib.config 3 4A small utility class for reading and writing configuration files in YAML or 5JSON format. The class supports optional validation using a Pydantic model. 6 7The implementation intentionally avoids external dependencies such as 8``watchdog`` – the ``set_auto_reload`` method is kept for API compatibility but 9simply logs that auto‑reload is not available. 10""" 11 12from __future__ import annotations 13 14import json 15import asyncio 16from pathlib import Path 17from typing import Any, Dict, Optional, Type, Union 18 19import yaml 20 21# from loguru import logger 22from pydantic import BaseModel, ValidationError 23 24# --------------------------------------------------------------------------- 25# Logging configuration – plain‑text to stderr with context information. 26# --------------------------------------------------------------------------- 27import logging 28 29logger = logging.getLogger(__name__) 30 31PathLike = Union[str, Path] 32 33 34class Config: 35 """Load and validate configuration files. 36 37 Parameters 38 ---------- 39 path: 40 Path to the configuration file. 41 fmt: 42 ``"yaml"`` or ``"json"``. Defaults to ``"yaml"``. 43 model: 44 Optional :class:`pydantic.BaseModel` subclass used for validation. 45 """ 46 47 def __init__( 48 self, 49 path: PathLike, 50 fmt: str = "yaml", 51 model: Optional[Type[BaseModel]] = None, 52 ) -> None: 53 self.path = Path(path).expanduser().resolve() 54 if not self.path.is_file(): 55 raise FileNotFoundError(f"Configuration file {self.path} does not exist") 56 57 fmt = fmt.lower() 58 if fmt not in ("yaml", "json"): 59 raise ValueError("fmt must be either 'yaml' or 'json'") 60 self.fmt = fmt 61 self._model = model 62 self._data: Dict[str, Any] = {} 63 self.load() 64 65 # ------------------------------------------------------------------ 66 # Internal helpers 67 # ------------------------------------------------------------------ 68 def _load_file(self) -> Dict[str, Any]: 69 """Read the file and return a dict. 70 71 Supports an ``include`` key that lists additional files to merge. Paths are 72 resolved relative to the current file. Includes are processed recursively. 73 Circular includes are detected and raise ValueError. 74 """ 75 76 def _load(path: Path, visited: set[Path]) -> Dict[str, Any]: 77 resolved = path.resolve() 78 if resolved in visited: 79 raise ValueError(f"Circular include detected: {resolved}") 80 visited = visited | {resolved} 81 with open(path, "r", encoding="utf-8") as f: 82 raw = ( 83 yaml.safe_load(f) 84 if path.suffix.lower() in (".yaml", ".yml") 85 else json.load(f) 86 ) 87 if not isinstance(raw, dict): 88 raise ValueError( 89 "Configuration file must contain a mapping at top level" 90 ) 91 includes = raw.pop("include", []) 92 if isinstance(includes, str): 93 includes = [includes] 94 for inc in includes: 95 inc_path = (path.parent / inc).resolve() 96 if not inc_path.is_file(): 97 raise FileNotFoundError( 98 f"Included config file {inc_path} does not exist" 99 ) 100 raw.update(_load(inc_path, visited)) 101 return raw 102 103 return _load(self.path, set()) 104 105 def _validate(self, data: Dict[str, Any]) -> bool: 106 if not self._model: 107 return True 108 try: 109 self._model(**data) 110 return True 111 except ValidationError as exc: 112 logger.error("Config validation failed for %s: %s", self.path, exc) 113 raise ValueError(f"Validation failed for {self.path}: {exc}") 114 115 # ------------------------------------------------------------------ 116 # Public API 117 # ------------------------------------------------------------------ 118 def load(self) -> None: 119 new_data = self._load_file() 120 if self._validate(new_data): 121 self._data = new_data 122 123 async def async_load(self) -> None: 124 # Delegate to the synchronous loader in a thread 125 new_data = await asyncio.to_thread(self._load_file) 126 if self._validate(new_data): 127 self._data = new_data 128 129 def save( 130 self, 131 data: Optional[Dict[str, Any]] = None, 132 output_path: Optional[PathLike] = None, 133 ) -> None: 134 target = data if data is not None else self._data 135 if not isinstance(target, dict): 136 raise TypeError("save() expects a dict or None") 137 content = ( 138 yaml.safe_dump(target) 139 if self.fmt == "yaml" 140 else json.dumps(target, indent=2) 141 ) 142 dest = Path(output_path) if output_path is not None else self.path 143 tmp_path = dest.parent / f"{dest.name}.tmp" 144 try: 145 tmp_path.write_text(content, encoding="utf-8") 146 tmp_path.replace(dest) 147 except Exception: 148 if tmp_path.exists(): 149 tmp_path.unlink(missing_ok=True) 150 raise 151 152 async def async_save( 153 self, 154 data: Optional[Dict[str, Any]] = None, 155 output_path: Optional[PathLike] = None, 156 ) -> None: 157 target = data if data is not None else self._data 158 content = ( 159 yaml.safe_dump(target) 160 if self.fmt == "yaml" 161 else json.dumps(target, indent=2) 162 ) 163 dest = Path(output_path) if output_path is not None else self.path 164 tmp_path = dest.parent / f"{dest.name}.tmp" 165 try: 166 tmp_path.write_text(content, encoding="utf-8") 167 await asyncio.to_thread(tmp_path.replace, dest) 168 except Exception: 169 if tmp_path.exists(): 170 tmp_path.unlink(missing_ok=True) 171 raise 172 173 # ------------------------------------------------------------------ 174 # Auto‑reload placeholder – no watchdog integration. 175 # ------------------------------------------------------------------ 176 def set_auto_reload(self, enable: bool) -> None: 177 if enable: 178 logger.warning("Auto‑reload is not implemented in this simplified version") 179 180 # ------------------------------------------------------------------ 181 # Convenience accessors 182 # ------------------------------------------------------------------ 183 @property 184 def data(self) -> Dict[str, Any]: 185 return self._data.copy() 186 187 def get(self, key: str, default: Any = None) -> Any: 188 return self._data.get(key, default) 189 190 def set(self, key: str, value: Any) -> None: 191 self._data[key] = value 192 193 # ------------------------------------------------------------------ 194 # Context‑manager support 195 # ------------------------------------------------------------------ 196 def __enter__(self) -> "Config": 197 return self 198 199 def __exit__( 200 self, 201 exc_type: Optional[type], 202 exc_val: Optional[Exception], 203 exc_tb: Optional[Any], 204 ) -> None: 205 pass # nothing to clean up in this simplified version 206 207 208# End of config.py
logger =
<Logger e_lib.config (WARNING)>
PathLike =
typing.Union[str, pathlib.Path]
class
Config:
35class Config: 36 """Load and validate configuration files. 37 38 Parameters 39 ---------- 40 path: 41 Path to the configuration file. 42 fmt: 43 ``"yaml"`` or ``"json"``. Defaults to ``"yaml"``. 44 model: 45 Optional :class:`pydantic.BaseModel` subclass used for validation. 46 """ 47 48 def __init__( 49 self, 50 path: PathLike, 51 fmt: str = "yaml", 52 model: Optional[Type[BaseModel]] = None, 53 ) -> None: 54 self.path = Path(path).expanduser().resolve() 55 if not self.path.is_file(): 56 raise FileNotFoundError(f"Configuration file {self.path} does not exist") 57 58 fmt = fmt.lower() 59 if fmt not in ("yaml", "json"): 60 raise ValueError("fmt must be either 'yaml' or 'json'") 61 self.fmt = fmt 62 self._model = model 63 self._data: Dict[str, Any] = {} 64 self.load() 65 66 # ------------------------------------------------------------------ 67 # Internal helpers 68 # ------------------------------------------------------------------ 69 def _load_file(self) -> Dict[str, Any]: 70 """Read the file and return a dict. 71 72 Supports an ``include`` key that lists additional files to merge. Paths are 73 resolved relative to the current file. Includes are processed recursively. 74 Circular includes are detected and raise ValueError. 75 """ 76 77 def _load(path: Path, visited: set[Path]) -> Dict[str, Any]: 78 resolved = path.resolve() 79 if resolved in visited: 80 raise ValueError(f"Circular include detected: {resolved}") 81 visited = visited | {resolved} 82 with open(path, "r", encoding="utf-8") as f: 83 raw = ( 84 yaml.safe_load(f) 85 if path.suffix.lower() in (".yaml", ".yml") 86 else json.load(f) 87 ) 88 if not isinstance(raw, dict): 89 raise ValueError( 90 "Configuration file must contain a mapping at top level" 91 ) 92 includes = raw.pop("include", []) 93 if isinstance(includes, str): 94 includes = [includes] 95 for inc in includes: 96 inc_path = (path.parent / inc).resolve() 97 if not inc_path.is_file(): 98 raise FileNotFoundError( 99 f"Included config file {inc_path} does not exist" 100 ) 101 raw.update(_load(inc_path, visited)) 102 return raw 103 104 return _load(self.path, set()) 105 106 def _validate(self, data: Dict[str, Any]) -> bool: 107 if not self._model: 108 return True 109 try: 110 self._model(**data) 111 return True 112 except ValidationError as exc: 113 logger.error("Config validation failed for %s: %s", self.path, exc) 114 raise ValueError(f"Validation failed for {self.path}: {exc}") 115 116 # ------------------------------------------------------------------ 117 # Public API 118 # ------------------------------------------------------------------ 119 def load(self) -> None: 120 new_data = self._load_file() 121 if self._validate(new_data): 122 self._data = new_data 123 124 async def async_load(self) -> None: 125 # Delegate to the synchronous loader in a thread 126 new_data = await asyncio.to_thread(self._load_file) 127 if self._validate(new_data): 128 self._data = new_data 129 130 def save( 131 self, 132 data: Optional[Dict[str, Any]] = None, 133 output_path: Optional[PathLike] = None, 134 ) -> None: 135 target = data if data is not None else self._data 136 if not isinstance(target, dict): 137 raise TypeError("save() expects a dict or None") 138 content = ( 139 yaml.safe_dump(target) 140 if self.fmt == "yaml" 141 else json.dumps(target, indent=2) 142 ) 143 dest = Path(output_path) if output_path is not None else self.path 144 tmp_path = dest.parent / f"{dest.name}.tmp" 145 try: 146 tmp_path.write_text(content, encoding="utf-8") 147 tmp_path.replace(dest) 148 except Exception: 149 if tmp_path.exists(): 150 tmp_path.unlink(missing_ok=True) 151 raise 152 153 async def async_save( 154 self, 155 data: Optional[Dict[str, Any]] = None, 156 output_path: Optional[PathLike] = None, 157 ) -> None: 158 target = data if data is not None else self._data 159 content = ( 160 yaml.safe_dump(target) 161 if self.fmt == "yaml" 162 else json.dumps(target, indent=2) 163 ) 164 dest = Path(output_path) if output_path is not None else self.path 165 tmp_path = dest.parent / f"{dest.name}.tmp" 166 try: 167 tmp_path.write_text(content, encoding="utf-8") 168 await asyncio.to_thread(tmp_path.replace, dest) 169 except Exception: 170 if tmp_path.exists(): 171 tmp_path.unlink(missing_ok=True) 172 raise 173 174 # ------------------------------------------------------------------ 175 # Auto‑reload placeholder – no watchdog integration. 176 # ------------------------------------------------------------------ 177 def set_auto_reload(self, enable: bool) -> None: 178 if enable: 179 logger.warning("Auto‑reload is not implemented in this simplified version") 180 181 # ------------------------------------------------------------------ 182 # Convenience accessors 183 # ------------------------------------------------------------------ 184 @property 185 def data(self) -> Dict[str, Any]: 186 return self._data.copy() 187 188 def get(self, key: str, default: Any = None) -> Any: 189 return self._data.get(key, default) 190 191 def set(self, key: str, value: Any) -> None: 192 self._data[key] = value 193 194 # ------------------------------------------------------------------ 195 # Context‑manager support 196 # ------------------------------------------------------------------ 197 def __enter__(self) -> "Config": 198 return self 199 200 def __exit__( 201 self, 202 exc_type: Optional[type], 203 exc_val: Optional[Exception], 204 exc_tb: Optional[Any], 205 ) -> None: 206 pass # nothing to clean up in this simplified version
Load and validate configuration files.
Parameters
path:
Path to the configuration file.
fmt:
"yaml" or "json". Defaults to "yaml".
model:
Optional pydantic.BaseModel subclass used for validation.
Config( path: Union[str, pathlib.Path], fmt: str = 'yaml', model: Optional[Type[pydantic.main.BaseModel]] = None)
48 def __init__( 49 self, 50 path: PathLike, 51 fmt: str = "yaml", 52 model: Optional[Type[BaseModel]] = None, 53 ) -> None: 54 self.path = Path(path).expanduser().resolve() 55 if not self.path.is_file(): 56 raise FileNotFoundError(f"Configuration file {self.path} does not exist") 57 58 fmt = fmt.lower() 59 if fmt not in ("yaml", "json"): 60 raise ValueError("fmt must be either 'yaml' or 'json'") 61 self.fmt = fmt 62 self._model = model 63 self._data: Dict[str, Any] = {} 64 self.load()
def
save( self, data: Optional[Dict[str, Any]] = None, output_path: Union[str, pathlib.Path, NoneType] = None) -> None:
130 def save( 131 self, 132 data: Optional[Dict[str, Any]] = None, 133 output_path: Optional[PathLike] = None, 134 ) -> None: 135 target = data if data is not None else self._data 136 if not isinstance(target, dict): 137 raise TypeError("save() expects a dict or None") 138 content = ( 139 yaml.safe_dump(target) 140 if self.fmt == "yaml" 141 else json.dumps(target, indent=2) 142 ) 143 dest = Path(output_path) if output_path is not None else self.path 144 tmp_path = dest.parent / f"{dest.name}.tmp" 145 try: 146 tmp_path.write_text(content, encoding="utf-8") 147 tmp_path.replace(dest) 148 except Exception: 149 if tmp_path.exists(): 150 tmp_path.unlink(missing_ok=True) 151 raise
async def
async_save( self, data: Optional[Dict[str, Any]] = None, output_path: Union[str, pathlib.Path, NoneType] = None) -> None:
153 async def async_save( 154 self, 155 data: Optional[Dict[str, Any]] = None, 156 output_path: Optional[PathLike] = None, 157 ) -> None: 158 target = data if data is not None else self._data 159 content = ( 160 yaml.safe_dump(target) 161 if self.fmt == "yaml" 162 else json.dumps(target, indent=2) 163 ) 164 dest = Path(output_path) if output_path is not None else self.path 165 tmp_path = dest.parent / f"{dest.name}.tmp" 166 try: 167 tmp_path.write_text(content, encoding="utf-8") 168 await asyncio.to_thread(tmp_path.replace, dest) 169 except Exception: 170 if tmp_path.exists(): 171 tmp_path.unlink(missing_ok=True) 172 raise