Files
ETPlot/gallery/config/__init__.py
T
lars 3b9d1ef1a8 Rewrite CI to lint/typecheck/audit/test only; add HPC deployment path
- Replace the Docker build/publish CI stages with ruff (lint + format
  check), ty (type check), pip-audit, and pytest run directly against
  python:3.11-slim; Docker remains for manual/server deployment only.
- Swap black/pylint/mypy for ruff/ty across pyproject.toml, and fix
  every resulting lint, format, and type diagnostic in gallery/ and
  plotstyle/.
- Fix tests broken/stale from before the package restructuring: wrong
  `utils.*` import paths, mock patch targets pointed at the wrong
  module, and PDF-conversion tests still assuming ImageMagick instead
  of the current PyMuPDF-first path. Drop test_container.py (obsolete
  Docker-container smoke tests, fully superseded elsewhere).
- Add a plain-venv + systemd --user timer deployment path
  (deploy/systemd/) for HPC login nodes without a Docker daemon, where
  public_html is already served by existing infrastructure.
- Document both in CLAUDE.md, including running CI's checks locally
  before committing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 15:29:55 +02:00

249 lines
8.5 KiB
Python

"""
Configuration Management for Scientific Gallery Generator
This module provides dataclasses for managing configuration, including
defaults for gallery generation settings.
"""
import shutil
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional, Union, cast
import yaml
from platformdirs import user_config_dir
def default_config_path() -> Path:
"""Return the path to the package-bundled read-only template config."""
return Path(__file__).parent / "config.yaml"
def user_config_path() -> Path:
"""Return the user-level config path (~/.config/gallery/config.yaml).
Follows the XDG Base Directory spec via platformdirs:
Linux/macOS → ~/.config/gallery/config.yaml
Windows → %APPDATA%/gallery/config.yaml
"""
return Path(user_config_dir("gallery", appauthor=False)) / "config.yaml"
def get_active_config_path() -> Path:
"""Return the config path to use, with this precedence:
1. User config (~/.config/gallery/config.yaml) — if it exists
2. Package-bundled template — fallback
"""
ucp = user_config_path()
return ucp if ucp.exists() else default_config_path()
def ensure_user_config() -> Path:
"""Ensure ~/.config/gallery/config.yaml exists, creating it from the
package template if needed. Returns the path.
"""
ucp = user_config_path()
if not ucp.exists():
ucp.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(default_config_path(), ucp)
return ucp
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: List[Union[GallerySource, Dict[str, Any]]] = []
for source in self.sources:
if isinstance(source, dict):
source_dict = cast(Dict[str, Any], source)
source = GallerySource(name=source_dict["name"], path=source_dict["path"])
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: List[Union[GallerySource, Dict[str, Any]]] = [
{"name": s["name"], "path": s["path"]} for s in sources_data
]
metadata_cfg = data.get("metadata", {})
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=metadata_cfg.get("cache_enabled", GalleryDefaults.cache_enabled),
inherit_from_parent=metadata_cfg.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": {
"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"],
},
# self.sources is always List[GallerySource] after __post_init__ normalizes it.
"sources": [{"name": s.name, "path": str(s.path)} for s in cast(List[GallerySource], self.sources)],
}
with open(yaml_path, "w") as f:
yaml.dump(data, f, default_flow_style=False)