diff --git a/CLAUDE.md b/CLAUDE.md index 66fef6d..fe01a3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,9 +68,8 @@ generate() [api.py] | `gallery/utils/metadata.py` | Load/merge/cache YAML+JSON metadata; per-plot metadata resolution | | `gallery/utils/stats.py` | Directory size/count statistics | | `gallery/templates/gallery.html` | Single Jinja2 template for all gallery pages | -| `assets/js/` | Vanilla JS modules loaded as ES modules; `GalleryApp` in `gallery-app.js` orchestrates all managers | -| `assets/css/` | Modular CSS; `main.css` imports all others via `@import` | -| `generate_gallery.py` | Legacy CLI wrapper — kept for backward compatibility | +| `gallery/assets/js/` | Vanilla JS modules loaded as ES modules; `GalleryApp` in `gallery-app.js` orchestrates all managers | +| `gallery/assets/css/` | Modular CSS; `main.css` imports all others via `@import` | | `config.yaml` | Local deployment config (paths are machine-specific) | ### Config File Format @@ -105,8 +104,6 @@ When `source_to_update` is passed to `generate()`, only that source's subdirecto The frontend is vanilla ES modules — no build step. `assets/js/main.js` imports `GalleryApp` from `gallery-app.js`, which instantiates all manager classes (`ThemeManager`, `SearchManager`, `NavigationManager`, etc.). Each manager is self-contained. The template embeds gallery data as JSON in the page; JS reads it at runtime. -Assets are served from `/assets/` relative to gallery pages. The Python code calculates the correct `../` depth per page when rendering the template. - ### Deployment The project ships a `Singularity.def` / `web.sif` Apptainer container for HPC environments. CI (`.gitlab-ci.yml`) builds the container and runs pytest inside it. For local development the `.venv` is sufficient. diff --git a/gallery/cli.py b/gallery/cli.py index c3bfcec..970a577 100644 --- a/gallery/cli.py +++ b/gallery/cli.py @@ -1,7 +1,7 @@ """ Command-line interface for gallery generation. -Provides a CLI entry point for gallery generation when using git clone setup. +Provides a CLI entry point for gallery generation and config management. """ import argparse @@ -9,113 +9,235 @@ import sys from pathlib import Path from gallery import generate -from gallery.config import GalleryConfig +from gallery.config import ConfigManager, GalleryConfig, GallerySource, default_config_path + +_WELCOME = """\ +Gallery - Scientific Plot Gallery Generator +=========================================== + +Generates responsive static HTML galleries from collections of PDFs and HTMLs. + +Getting started: + 1. Set your web output directory: + gallery config set paths.web_folder /path/to/your/public_html + + 2. Add one or more plot sources: + gallery config add-source --name my_plots --path /path/to/plots + + 3. Generate your gallery: + gallery generate + +Config commands: + gallery config list Show all settings + gallery config get Get a single value (e.g. gallery.png_dpi) + gallery config set Update a setting (e.g. paths.web_folder /my/web) + gallery config add-source --name X --path P Add a plot source + gallery config remove-source Remove a plot source + gallery config sources List configured sources + gallery config path Show config file location + +Generate commands: + gallery generate Generate gallery from config + gallery generate --config myconfig.yaml Use a custom config file + gallery generate --clean Clean and regenerate everything + gallery generate --source /path/to/plots Regenerate one source only + gallery generate --verbose Print detailed output + +Config file: {config_path} +""" -def main(): - """ - Main CLI entry point for gallery generation. +def _is_configured(config_path: Path) -> bool: + """Return True if web_folder is set to a non-empty value.""" + try: + mgr = ConfigManager(config_path) + web_folder = mgr.get("paths.web_folder") + return bool(web_folder and str(web_folder).strip()) + except Exception: + return False - Supports: - - Loading config from YAML file (default: config.yaml) - - Clean gallery directory before generation - - Override to process only a specific source directory - """ + +# --------------------------------------------------------------------------- +# Config subcommand +# --------------------------------------------------------------------------- + +def _cmd_config(argv: list) -> int: parser = argparse.ArgumentParser( - description='Generate scientific gallery from plot collections', + prog="gallery config", + description="Read and write gallery configuration", + ) + parser.add_argument( + "--config", + type=str, + default=None, + help="Path to config file (default: package config)", + ) + sub = parser.add_subparsers(dest="action", metavar="ACTION") + + sub.add_parser("list", help="Print all config values") + sub.add_parser("path", help="Print the resolved config file path") + + p_get = sub.add_parser("get", help="Get a config value by key") + p_get.add_argument("key", help="Dot-separated key, e.g. gallery.png_dpi") + + p_set = sub.add_parser("set", help="Set a config value") + p_set.add_argument("key", help="Dot-separated key, e.g. gallery.png_dpi") + p_set.add_argument("value", help="New value (YAML-parsed: use true/false for bools)") + + p_add = sub.add_parser("add-source", help="Add a plot source") + p_add.add_argument("--name", default=None, help="Source name (default: bottom-level directory name)") + p_add.add_argument("--path", required=True, help="Path to source directory") + + p_rm = sub.add_parser("remove-source", help="Remove a plot source by name") + p_rm.add_argument("name", help="Source name to remove") + + sub.add_parser("sources", help="List all configured sources") + + args = parser.parse_args(argv) + + config_path = Path(args.config) if args.config else default_config_path() + mgr = ConfigManager(config_path) + + if args.action == "path": + print(mgr.path) + + elif args.action == "list": + import yaml + print(yaml.dump(mgr.list_all(), default_flow_style=False).rstrip()) + + elif args.action == "get": + try: + value = mgr.get(args.key) + print(value) + except KeyError as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + elif args.action == "set": + try: + mgr.set(args.key, args.value) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + elif args.action == "add-source": + try: + name = args.name or Path(args.path).resolve().name + mgr.add_source(name, args.path) + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + elif args.action == "remove-source": + try: + mgr.remove_source(args.name) + except KeyError as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + elif args.action == "sources": + sources = mgr.list_sources() + if not sources: + print("No sources configured.") + else: + for s in sources: + print(f" {s['name']}: {s['path']}") + + else: + parser.print_help() + + return 0 + + +# --------------------------------------------------------------------------- +# Generate subcommand (existing behaviour) +# --------------------------------------------------------------------------- + +def _cmd_generate(argv: list) -> int: + parser = argparse.ArgumentParser( + prog="gallery generate", + description="Generate scientific gallery from plot collections", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: - gallery # Generate from config.yaml - gallery --config myconfig.yaml # Use custom config file - gallery --clean # Clean and regenerate - gallery --source /path/to/plots # Generate only specific source - """ + gallery generate # Generate using package config + gallery generate --config myconfig.yaml # Use custom config file + gallery generate --clean # Clean and regenerate + gallery generate --source /path/to/plots # Generate only specific source + """, ) parser.add_argument( - '--config', - type=str, - default='config.yaml', - help='Path to config.yaml file (default: config.yaml)' - ) - - parser.add_argument( - '--clean', - action='store_true', - help='Clean gallery directory before generation' - ) - - parser.add_argument( - '--source', + "--config", type=str, default=None, - help='Override to only recompute a specific source directory. ' - 'If the directory is not in config, it will be added ' - 'temporarily.' + help="Path to config.yaml (default: package bundled config)", ) - + parser.add_argument("--clean", action="store_true", help="Clean gallery directory before generation") parser.add_argument( - '-v', '--verbose', - action='store_true', - help='Print verbose output' + "--source", + type=str, + default=None, + help="Only recompute a specific source directory", ) + parser.add_argument("-v", "--verbose", action="store_true", help="Print verbose output") - args = parser.parse_args() + args = parser.parse_args(argv) + + config_path = Path(args.config) if args.config else default_config_path() try: - # Load config from file - config = GalleryConfig.from_yaml(args.config) + config = GalleryConfig.from_yaml(config_path) - # Handle source override source_to_update = None if args.source: - from gallery.config import GallerySource source_path = Path(args.source).resolve() - - # Check if source is in config matching_source = None for source in config.sources: if Path(source.path).resolve() == source_path: matching_source = source break - - # If not in config, create a temporary source entry if matching_source is None: source_name = source_path.name - source_to_update = GallerySource( - name=source_name, - path=source_path - ) + source_to_update = GallerySource(name=source_name, path=source_path) config.sources.append(source_to_update) if args.verbose: - print( - f"Source {args.source} not in config. " - f"Adding temporarily as '{source_name}'" - ) + print(f"Source {args.source} not in config. Adding temporarily as '{source_name}'") else: source_to_update = matching_source - # Generate gallery success = generate( config=config, clean_first=args.clean, verbose=args.verbose, - source_to_update=source_to_update + source_to_update=source_to_update, ) - - sys.exit(0 if success else 1) + return 0 if success else 1 except FileNotFoundError as e: print(f"Error: {e}", file=sys.stderr) - sys.exit(1) + return 1 except ValueError as e: print(f"Error: {e}", file=sys.stderr) - sys.exit(1) + return 1 except Exception as e: print(f"Error: {e}", file=sys.stderr) - sys.exit(1) + return 1 -if __name__ == '__main__': +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def main(): + if len(sys.argv) > 1 and sys.argv[1] == "config": + sys.exit(_cmd_config(sys.argv[2:])) + elif len(sys.argv) > 1 and sys.argv[1] == "generate": + sys.exit(_cmd_generate(sys.argv[2:])) + else: + print(_WELCOME.format(config_path=default_config_path())) + sys.exit(0) + + +if __name__ == "__main__": main() diff --git a/gallery/config.py b/gallery/config.py deleted file mode 100644 index 9746b9e..0000000 --- a/gallery/config.py +++ /dev/null @@ -1,164 +0,0 @@ -""" -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 List, Dict, Any, Union -import yaml - - -@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): - """Convert path to Path object if needed.""" - 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): - """Normalize and validate configuration.""" - # Convert web_folder to Path - if isinstance(self.web_folder, str): - self.web_folder = Path(self.web_folder) - - # Convert sources to GallerySource objects if they're dicts - 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, " - f"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. - - Args: - yaml_file: Path to the YAML configuration file - - Returns: - GalleryConfig instance with loaded settings - - Raises: - FileNotFoundError: If the YAML file doesn't exist - yaml.YAMLError: If the YAML file is malformed - """ - 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 = {} - - # Extract relevant sections - 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", []) - - # Build sources list - 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. - - Args: - yaml_file: Path where to save the YAML configuration - - Raises: - IOError: If unable to write to the specified 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) diff --git a/gallery/config/__init__.py b/gallery/config/__init__.py new file mode 100644 index 0000000..3c029b7 --- /dev/null +++ b/gallery/config/__init__.py @@ -0,0 +1,206 @@ +""" +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) diff --git a/gallery/config/config.yaml b/gallery/config/config.yaml new file mode 100644 index 0000000..833c266 --- /dev/null +++ b/gallery/config/config.yaml @@ -0,0 +1,14 @@ +gallery: + backup_folder: '' + plot_root: gallery + png_dpi: 400 +metadata: + cache_enabled: true + inherit_from_parent: true +paths: + web_folder: '' + work_dir: '' +sources: [] +ui: + max_recent_plots: 20 + search_debounce_ms: 300 diff --git a/pyproject.toml b/pyproject.toml index 1a7a2c9..6a96f5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,8 +51,8 @@ dev = [ gallery = "gallery.cli:main" [tool.setuptools] -packages = ["gallery", "gallery.utils"] -package-data = {gallery = ["templates/*", "assets/css/*", "assets/js/*"]} +packages = ["gallery", "gallery.utils", "gallery.config"] +package-data = {gallery = ["templates/*", "assets/css/*", "assets/js/*", "config/*"]} include-package-data = true [tool.black]