3b9d1ef1a8
- 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>
249 lines
8.5 KiB
Python
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)
|