e_lib.config

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()
path
fmt
def load(self) -> None:
119    def load(self) -> None:
120        new_data = self._load_file()
121        if self._validate(new_data):
122            self._data = new_data
async def async_load(self) -> None:
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
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
def set_auto_reload(self, enable: bool) -> None:
177    def set_auto_reload(self, enable: bool) -> None:
178        if enable:
179            logger.warning("Auto‑reload is not implemented in this simplified version")
data: Dict[str, Any]
184    @property
185    def data(self) -> Dict[str, Any]:
186        return self._data.copy()
def get(self, key: str, default: Any = None) -> Any:
188    def get(self, key: str, default: Any = None) -> Any:
189        return self._data.get(key, default)
def set(self, key: str, value: Any) -> None:
191    def set(self, key: str, value: Any) -> None:
192        self._data[key] = value