Files
ETPlot/gallery/config/__init__.py
T

207 lines
7.1 KiB
Python

"""
Configuration Management for Scientific Gallery Generator
This module provides dataclasses for managing configuration, including
defaults for gallery generation settings.
"""
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
import yaml
def default_config_path() -> Path:
"""Return the path to the package-bundled default config."""
return Path(__file__).parent / "config.yaml"
def _load_raw(path: Path) -> Dict[str, Any]:
with open(path, "r") as f:
return yaml.safe_load(f) or {}
def _save_raw(path: Path, data: Dict[str, Any]) -> None:
with open(path, "w") as f:
yaml.dump(data, f, default_flow_style=False, allow_unicode=True)
def _get_nested(data: Dict, keys: List[str]) -> Any:
for k in keys:
if not isinstance(data, dict) or k not in data:
raise KeyError(f"Key '{'.'.join(keys)}' not found in config")
data = data[k]
return data
def _set_nested(data: Dict, keys: List[str], value: Any) -> None:
for k in keys[:-1]:
data = data.setdefault(k, {})
data[keys[-1]] = value
class ConfigManager:
"""Read/write access to a gallery config YAML file.
Intended to be reused by both the CLI and a future TUI layer.
"""
def __init__(self, config_path: Optional[Path] = None):
self.path = Path(config_path) if config_path else default_config_path()
# ------------------------------------------------------------------
# Core operations
# ------------------------------------------------------------------
def get(self, key: str) -> Any:
"""Return the value at dot-separated *key* (e.g. 'gallery.png_dpi')."""
data = _load_raw(self.path)
return _get_nested(data, key.split("."))
def set(self, key: str, value: str) -> None:
"""Set *key* to *value*, coercing type via YAML parsing."""
data = _load_raw(self.path)
parsed = yaml.safe_load(value)
_set_nested(data, key.split("."), parsed)
_save_raw(self.path, data)
def list_all(self) -> Dict[str, Any]:
"""Return the full config dict."""
return _load_raw(self.path)
# ------------------------------------------------------------------
# Source helpers
# ------------------------------------------------------------------
def add_source(self, name: str, path: Union[str, Path]) -> None:
"""Append a new source entry; raises ValueError if name already exists."""
data = _load_raw(self.path)
sources: List[Dict] = data.setdefault("sources", [])
if any(s.get("name") == name for s in sources):
raise ValueError(f"Source '{name}' already exists")
sources.append({"name": name, "path": str(path)})
_save_raw(self.path, data)
def remove_source(self, name: str) -> None:
"""Remove the source with the given name; raises KeyError if not found."""
data = _load_raw(self.path)
sources: List[Dict] = data.get("sources", [])
filtered = [s for s in sources if s.get("name") != name]
if len(filtered) == len(sources):
raise KeyError(f"Source '{name}' not found")
data["sources"] = filtered
_save_raw(self.path, data)
def list_sources(self) -> List[Dict]:
"""Return the list of source dicts."""
return _load_raw(self.path).get("sources", [])
@dataclass
class GalleryDefaults:
"""Default values for gallery generation."""
png_dpi: int = 400
plot_root: str = "gallery"
cache_enabled: bool = True
inherit_from_parent: bool = True
@dataclass
class GallerySource:
"""Represents a single data source for the gallery."""
name: str
path: Union[str, Path]
def __post_init__(self):
if isinstance(self.path, str):
self.path = Path(self.path)
@dataclass
class GalleryConfig:
"""
Main configuration for gallery generation.
Can be created programmatically or loaded from YAML.
"""
web_folder: Union[str, Path]
sources: List[Union[GallerySource, Dict[str, Any]]] = field(default_factory=list)
png_dpi: int = GalleryDefaults.png_dpi
plot_root: str = GalleryDefaults.plot_root
cache_enabled: bool = GalleryDefaults.cache_enabled
inherit_from_parent: bool = GalleryDefaults.inherit_from_parent
backup_folder: str = ""
def __post_init__(self):
if isinstance(self.web_folder, str):
self.web_folder = Path(self.web_folder)
normalized_sources = []
for source in self.sources:
if isinstance(source, dict):
source = GallerySource(**source)
elif not isinstance(source, GallerySource):
raise TypeError(f"Source must be dict or GallerySource, got {type(source)}")
normalized_sources.append(source)
self.sources = normalized_sources
@classmethod
def from_yaml(cls, yaml_file: Union[str, Path]) -> "GalleryConfig":
"""Load configuration from a YAML file."""
yaml_path = Path(yaml_file)
if not yaml_path.exists():
raise FileNotFoundError(f"Config file not found: {yaml_file}")
with open(yaml_path, "r") as f:
data = yaml.safe_load(f)
if data is None:
data = {}
web_folder = data.get("paths", {}).get("web_folder")
if not web_folder:
raise ValueError("web_folder must be specified in config under paths")
gallery_cfg = data.get("gallery", {})
sources_data = data.get("sources", [])
sources = [{"name": s["name"], "path": s["path"]} for s in sources_data]
return cls(
web_folder=web_folder,
sources=sources,
png_dpi=gallery_cfg.get("png_dpi", GalleryDefaults.png_dpi),
plot_root=gallery_cfg.get("plot_root", GalleryDefaults.plot_root),
cache_enabled=data.get("metadata", {}).get("cache_enabled", GalleryDefaults.cache_enabled),
inherit_from_parent=data.get("metadata", {}).get("inherit_from_parent", GalleryDefaults.inherit_from_parent),
backup_folder=gallery_cfg.get("backup_folder", ""),
)
def to_yaml(self, yaml_file: Union[str, Path]) -> None:
"""Save the current configuration to a YAML file."""
yaml_path = Path(yaml_file)
data = {
"paths": {
"work_dir": str(Path.cwd()),
"web_folder": str(self.web_folder),
},
"gallery": {
"plot_root": self.plot_root,
"png_dpi": self.png_dpi,
"backup_folder": self.backup_folder,
},
"ui": {
"max_recent_plots": 20,
"search_debounce_ms": 300,
},
"metadata": {
"cache_enabled": self.cache_enabled,
"inherit_from_parent": self.inherit_from_parent,
"supported_formats": [".yaml", ".yml", ".json"],
},
"sources": [{"name": s.name, "path": str(s.path)} for s in self.sources],
}
with open(yaml_path, "w") as f:
yaml.dump(data, f, default_flow_style=False)