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>
344 lines
12 KiB
Python
344 lines
12 KiB
Python
"""
|
|
Command-line interface for gallery generation.
|
|
|
|
Provides a CLI entry point for gallery generation and config management.
|
|
Shell autocomplete: add the following line to your .bashrc / .zshrc:
|
|
eval "$(register-python-argcomplete gallery)"
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import List, Optional, cast
|
|
|
|
import argcomplete
|
|
|
|
from gallery import generate
|
|
from gallery.config import (
|
|
ConfigManager,
|
|
GalleryConfig,
|
|
GallerySource,
|
|
ensure_user_config,
|
|
get_active_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 --path /path/to/plots
|
|
|
|
3. Generate your gallery:
|
|
gallery generate
|
|
|
|
Config commands:
|
|
gallery config list Show all settings
|
|
gallery config get <key> Get a single value (e.g. gallery.png_dpi)
|
|
gallery config set <key> <value> Update a setting (e.g. paths.web_folder /my/web)
|
|
gallery config add-source --path P Add a plot source (--name defaults to dir name)
|
|
gallery config remove-source <name> 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
|
|
|
|
Shell autocomplete (run once after install):
|
|
gallery install-completion
|
|
|
|
Config file: {config_path}
|
|
"""
|
|
|
|
|
|
def _set_completer(action: argparse.Action, completer) -> None:
|
|
"""argcomplete reads `.completer` dynamically; argparse.Action has no such attribute."""
|
|
setattr(action, "completer", completer)
|
|
|
|
|
|
def _is_configured(config_path: Path) -> bool:
|
|
"""Return True if web_folder is set to a non-empty value."""
|
|
try:
|
|
web_folder = ConfigManager(config_path).get("paths.web_folder")
|
|
return bool(web_folder and str(web_folder).strip())
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _source_names(prefix, parsed_args, **kwargs):
|
|
"""Autocomplete helper: return configured source names."""
|
|
try:
|
|
config_path = Path(parsed_args.config) if getattr(parsed_args, "config", None) else get_active_config_path()
|
|
return [s["name"] for s in ConfigManager(config_path).list_sources()]
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
def _config_keys(prefix, parsed_args, **kwargs):
|
|
"""Autocomplete helper: return known dot-notation config keys."""
|
|
try:
|
|
config_path = Path(parsed_args.config) if getattr(parsed_args, "config", None) else get_active_config_path()
|
|
data = ConfigManager(config_path).list_all()
|
|
keys = []
|
|
for section, values in data.items():
|
|
if isinstance(values, dict):
|
|
for k in values:
|
|
keys.append(f"{section}.{k}")
|
|
else:
|
|
keys.append(section)
|
|
return [k for k in keys if k.startswith(prefix)]
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Parser construction (separated so TUI can reuse the structure)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(
|
|
prog="gallery",
|
|
description="Scientific Plot Gallery Generator",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
)
|
|
_set_completer(
|
|
parser.add_argument(
|
|
"--config",
|
|
type=str,
|
|
default=None,
|
|
metavar="FILE",
|
|
help="Path to config file (default: package bundled config)",
|
|
),
|
|
argcomplete.completers.FilesCompleter(["yaml", "yml"]),
|
|
)
|
|
|
|
sub = parser.add_subparsers(dest="command", metavar="COMMAND")
|
|
|
|
# --- generate -----------------------------------------------------------
|
|
gen = sub.add_parser(
|
|
"generate",
|
|
help="Generate the gallery",
|
|
description="Generate scientific gallery from plot collections",
|
|
)
|
|
gen.add_argument("--clean", action="store_true", help="Clean gallery directory before generation")
|
|
_set_completer(
|
|
gen.add_argument(
|
|
"--source",
|
|
type=str,
|
|
default=None,
|
|
metavar="DIR",
|
|
help="Only recompute a specific source directory (name defaults to dir name)",
|
|
),
|
|
argcomplete.completers.DirectoriesCompleter(),
|
|
)
|
|
gen.add_argument("-v", "--verbose", action="store_true", help="Print verbose output")
|
|
|
|
# --- config -------------------------------------------------------------
|
|
cfg = sub.add_parser(
|
|
"config",
|
|
help="Read and write configuration",
|
|
description="Read and write gallery configuration",
|
|
)
|
|
cfg_sub = cfg.add_subparsers(dest="action", metavar="ACTION")
|
|
|
|
sub.add_parser("install-completion", help="Install shell tab-completion (bash/zsh)")
|
|
sub.add_parser("tui", help="Launch the interactive TUI")
|
|
|
|
cfg_sub.add_parser("list", help="Print all config values")
|
|
cfg_sub.add_parser("path", help="Print the resolved config file path")
|
|
cfg_sub.add_parser("sources", help="List all configured sources")
|
|
|
|
p_get = cfg_sub.add_parser("get", help="Get a config value")
|
|
_set_completer(p_get.add_argument("key", help="Dot-separated key, e.g. gallery.png_dpi"), _config_keys)
|
|
|
|
p_set = cfg_sub.add_parser("set", help="Set a config value")
|
|
_set_completer(p_set.add_argument("key", help="Dot-separated key, e.g. gallery.png_dpi"), _config_keys)
|
|
p_set.add_argument("value", help="New value (YAML-parsed: use true/false for bools)")
|
|
|
|
p_add = cfg_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)")
|
|
_set_completer(
|
|
p_add.add_argument("--path", required=True, metavar="DIR", help="Path to source directory"),
|
|
argcomplete.completers.DirectoriesCompleter(),
|
|
)
|
|
|
|
p_rm = cfg_sub.add_parser("remove-source", help="Remove a plot source by name")
|
|
_set_completer(p_rm.add_argument("name", help="Source name to remove"), _source_names)
|
|
|
|
return parser
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Command handlers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _run_generate(args: argparse.Namespace) -> int:
|
|
config_path = Path(args.config) if args.config else get_active_config_path()
|
|
try:
|
|
config = GalleryConfig.from_yaml(config_path)
|
|
|
|
source_to_update: Optional[GallerySource] = None
|
|
if args.source:
|
|
source_path = Path(args.source).resolve()
|
|
# config.sources is always List[GallerySource] after GalleryConfig.__post_init__ normalizes it.
|
|
typed_sources = cast(List[GallerySource], config.sources)
|
|
matching = next((s for s in typed_sources if Path(s.path).resolve() == source_path), None)
|
|
if matching is None:
|
|
source_to_update = GallerySource(name=source_path.name, path=source_path)
|
|
config.sources.append(source_to_update)
|
|
if args.verbose:
|
|
print(f"Source {args.source} not in config. Adding temporarily as '{source_path.name}'")
|
|
else:
|
|
source_to_update = matching
|
|
|
|
success = generate(
|
|
config=config,
|
|
clean_first=args.clean,
|
|
verbose=args.verbose,
|
|
source_to_update=source_to_update,
|
|
)
|
|
return 0 if success else 1
|
|
|
|
except (FileNotFoundError, ValueError) as e:
|
|
print(f"Error: {e}", file=sys.stderr)
|
|
return 1
|
|
except Exception as e:
|
|
print(f"Error: {e}", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
def _run_config(args: argparse.Namespace) -> int:
|
|
if args.config:
|
|
config_path = Path(args.config)
|
|
elif args.action in ("set", "add-source", "remove-source"):
|
|
# Writes go to the user config; create it from the template if needed
|
|
config_path = ensure_user_config()
|
|
else:
|
|
config_path = get_active_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 == "sources":
|
|
sources = mgr.list_sources()
|
|
if not sources:
|
|
print("No sources configured.")
|
|
else:
|
|
for s in sources:
|
|
print(f" {s['name']}: {s['path']}")
|
|
|
|
elif args.action == "get":
|
|
try:
|
|
print(mgr.get(args.key))
|
|
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
|
|
|
|
else:
|
|
# `gallery config` with no action → show config help
|
|
build_parser().parse_args(["config", "--help"])
|
|
|
|
return 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# install-completion handler
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_COMPLETION_LINE = 'eval "$(register-python-argcomplete gallery)"'
|
|
|
|
_SHELL_RC = {
|
|
"zsh": ".zshrc",
|
|
"bash": ".bashrc",
|
|
"fish": ".config/fish/config.fish",
|
|
}
|
|
|
|
|
|
def _run_install_completion() -> int:
|
|
shell = Path(os.environ.get("SHELL", "")).name # e.g. "bash", "zsh"
|
|
rc_name = _SHELL_RC.get(shell, ".bashrc")
|
|
rc = Path.home() / rc_name
|
|
|
|
if rc.exists() and _COMPLETION_LINE in rc.read_text():
|
|
print(f"Shell completion already configured in {rc}")
|
|
return 0
|
|
|
|
with open(rc, "a") as f:
|
|
f.write(f"\n# gallery shell completion\n{_COMPLETION_LINE}\n")
|
|
|
|
print(f"Shell completion installed to {rc}")
|
|
print(f"Reload with: source {rc}")
|
|
return 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Entry point
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def main():
|
|
parser = build_parser()
|
|
argcomplete.autocomplete(parser) # no-op when not completing; exits during completion
|
|
args = parser.parse_args()
|
|
|
|
if args.command == "generate":
|
|
sys.exit(_run_generate(args))
|
|
elif args.command == "config":
|
|
sys.exit(_run_config(args))
|
|
elif args.command == "install-completion":
|
|
sys.exit(_run_install_completion())
|
|
elif args.command == "tui":
|
|
from gallery.tui import GalleryTUI
|
|
|
|
config_path = Path(args.config) if args.config else None
|
|
GalleryTUI(config_path=config_path).run()
|
|
sys.exit(0)
|
|
else:
|
|
print(_WELCOME.format(config_path=get_active_config_path()))
|
|
sys.exit(0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|