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>
This commit is contained in:
+18
-19
@@ -21,17 +21,23 @@ Example usage:
|
||||
__version__ = "0.1.0"
|
||||
__author__ = "K. Schmidt"
|
||||
|
||||
from gallery.api import generate
|
||||
from gallery.builder import build_gallery, get_template
|
||||
from gallery.config import (
|
||||
GalleryConfig,
|
||||
GallerySource,
|
||||
GalleryDefaults,
|
||||
GallerySource,
|
||||
)
|
||||
from gallery.api import generate
|
||||
|
||||
# Export utility functions for testing and advanced usage
|
||||
from gallery.utils.stats import (
|
||||
calculate_directory_stats,
|
||||
format_file_size,
|
||||
from gallery.utils.datetime_utils import (
|
||||
datetime_from_timestamp,
|
||||
strftime_filter,
|
||||
)
|
||||
from gallery.utils.metadata import (
|
||||
load_folder_metadata,
|
||||
load_metadata_file,
|
||||
merge_metadata,
|
||||
resolve_metadata_for_plot,
|
||||
save_metadata_cache,
|
||||
)
|
||||
from gallery.utils.processing import (
|
||||
convert_pdf_to_png,
|
||||
@@ -39,18 +45,12 @@ from gallery.utils.processing import (
|
||||
process_plot_files,
|
||||
render_gallery_page,
|
||||
)
|
||||
from gallery.utils.metadata import (
|
||||
load_folder_metadata,
|
||||
merge_metadata,
|
||||
save_metadata_cache,
|
||||
load_metadata_file,
|
||||
resolve_metadata_for_plot,
|
||||
|
||||
# Export utility functions for testing and advanced usage
|
||||
from gallery.utils.stats import (
|
||||
calculate_directory_stats,
|
||||
format_file_size,
|
||||
)
|
||||
from gallery.utils.datetime_utils import (
|
||||
datetime_from_timestamp,
|
||||
strftime_filter,
|
||||
)
|
||||
from gallery.builder import build_gallery, get_template
|
||||
|
||||
__all__ = [
|
||||
"generate",
|
||||
@@ -74,4 +74,3 @@ __all__ = [
|
||||
"datetime_from_timestamp",
|
||||
"strftime_filter",
|
||||
]
|
||||
|
||||
|
||||
+27
-57
@@ -6,19 +6,19 @@ Provides the primary entry point for programmatic gallery generation.
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Union, List, Dict, Any
|
||||
from typing import Any, Dict, List, Optional, Union, cast
|
||||
|
||||
from gallery.builder import build_gallery, copy_assets, get_template
|
||||
from gallery.config import GalleryConfig, GallerySource
|
||||
from gallery.builder import get_template, build_gallery, copy_assets
|
||||
|
||||
|
||||
def generate(
|
||||
config: Union[GalleryConfig, str, Path] = None,
|
||||
web_folder: Union[str, Path] = None,
|
||||
sources: List[Union[GallerySource, Dict[str, Any]]] = None,
|
||||
config: Optional[Union[GalleryConfig, str, Path]] = None,
|
||||
web_folder: Optional[Union[str, Path]] = None,
|
||||
sources: Optional[List[Union[GallerySource, Dict[str, Any]]]] = None,
|
||||
clean_first: bool = False,
|
||||
verbose: bool = False,
|
||||
source_to_update: GallerySource = None,
|
||||
source_to_update: Optional[GallerySource] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Generate a scientific gallery from plot sources.
|
||||
@@ -79,20 +79,11 @@ def generate(
|
||||
if isinstance(config, (str, Path)):
|
||||
config = GalleryConfig.from_yaml(config)
|
||||
elif not isinstance(config, GalleryConfig):
|
||||
raise TypeError(
|
||||
f"config must be GalleryConfig, str, or Path, "
|
||||
f"got {type(config)}"
|
||||
)
|
||||
raise TypeError(f"config must be GalleryConfig, str, or Path, got {type(config)}")
|
||||
else:
|
||||
if web_folder is None or sources is None:
|
||||
raise ValueError(
|
||||
"Either config or both web_folder and sources "
|
||||
"must be provided"
|
||||
)
|
||||
config = GalleryConfig(
|
||||
web_folder=web_folder,
|
||||
sources=sources or []
|
||||
)
|
||||
raise ValueError("Either config or both web_folder and sources must be provided")
|
||||
config = GalleryConfig(web_folder=web_folder, sources=sources or [])
|
||||
|
||||
# Validate configuration
|
||||
if not config.sources:
|
||||
@@ -104,10 +95,7 @@ def generate(
|
||||
web_folder_path = Path(config.web_folder)
|
||||
if not _is_writable(web_folder_path):
|
||||
if verbose:
|
||||
print(
|
||||
f"Error: Cannot write to web_folder: "
|
||||
f"{config.web_folder}"
|
||||
)
|
||||
print(f"Error: Cannot write to web_folder: {config.web_folder}")
|
||||
return False
|
||||
|
||||
# Create gallery root directory
|
||||
@@ -127,17 +115,12 @@ def generate(
|
||||
source_subdir = gallery_root / source_to_update.name
|
||||
if source_subdir.exists():
|
||||
if verbose:
|
||||
print(
|
||||
f"Updating source directory {source_to_update.name}..."
|
||||
)
|
||||
print(f"Updating source directory {source_to_update.name}...")
|
||||
try:
|
||||
shutil.rmtree(source_subdir)
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
print(
|
||||
f"Warning: Could not clean source subdirectory "
|
||||
f"{source_subdir}: {e}"
|
||||
)
|
||||
print(f"Warning: Could not clean source subdirectory {source_subdir}: {e}")
|
||||
return False
|
||||
|
||||
try:
|
||||
@@ -162,8 +145,10 @@ def generate(
|
||||
return False
|
||||
|
||||
# Process sources
|
||||
# config.sources is always List[GallerySource] after GalleryConfig.__post_init__ normalizes it.
|
||||
source_subdirs = []
|
||||
for source in config.sources:
|
||||
source = cast(GallerySource, source)
|
||||
# Skip sources not matching the update target (if specified)
|
||||
if source_to_update and source.name != source_to_update.name:
|
||||
# Still include them in the index if they exist
|
||||
@@ -178,10 +163,7 @@ def generate(
|
||||
# Validate source exists
|
||||
if not source_path.exists():
|
||||
if verbose:
|
||||
print(
|
||||
f"Warning: Source {source.path} does not exist. "
|
||||
f"Skipping."
|
||||
)
|
||||
print(f"Warning: Source {source.path} does not exist. Skipping.")
|
||||
continue
|
||||
|
||||
source_web_dir = gallery_root / source.name
|
||||
@@ -189,47 +171,37 @@ def generate(
|
||||
source_web_dir.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
print(
|
||||
f"Warning: Could not create directory "
|
||||
f"{source_web_dir}: {e}"
|
||||
)
|
||||
print(f"Warning: Could not create directory {source_web_dir}: {e}")
|
||||
continue
|
||||
|
||||
source_subdirs.append(source.name)
|
||||
|
||||
# Process source
|
||||
if source_path.is_file() and source_path.suffix == '.pdf':
|
||||
if source_path.is_file() and source_path.suffix == ".pdf":
|
||||
# Single PDF file
|
||||
from gallery.utils.processing import process_plot_files
|
||||
|
||||
item = process_plot_files(
|
||||
config=config,
|
||||
plot_file=source_path,
|
||||
web_dir=source_web_dir,
|
||||
)
|
||||
from gallery.utils.processing import render_gallery_page
|
||||
|
||||
render_gallery_page(
|
||||
config=config,
|
||||
template=template,
|
||||
web_dir=source_web_dir,
|
||||
items=[item],
|
||||
subdirs=[],
|
||||
relative_path=Path(source.name)
|
||||
relative_path=Path(source.name),
|
||||
)
|
||||
elif source_path.is_dir():
|
||||
# Directory of plots
|
||||
build_gallery(
|
||||
config,
|
||||
source_path,
|
||||
source_web_dir,
|
||||
template,
|
||||
Path(source.name)
|
||||
)
|
||||
build_gallery(config, source_path, source_web_dir, template, Path(source.name))
|
||||
else:
|
||||
if verbose:
|
||||
print(
|
||||
f"Warning: Source {source.path} is neither a "
|
||||
f"directory nor a PDF file. Skipping."
|
||||
)
|
||||
print(f"Warning: Source {source.path} is neither a directory nor a PDF file. Skipping.")
|
||||
continue
|
||||
|
||||
if verbose:
|
||||
@@ -237,15 +209,13 @@ def generate(
|
||||
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
print(
|
||||
f"Warning: Error processing source "
|
||||
f"{source.name}: {e}"
|
||||
)
|
||||
print(f"Warning: Error processing source {source.name}: {e}")
|
||||
continue
|
||||
|
||||
# Render gallery root index
|
||||
try:
|
||||
from gallery.utils.processing import render_gallery_page
|
||||
|
||||
render_gallery_page(
|
||||
config=config,
|
||||
template=template,
|
||||
@@ -253,7 +223,7 @@ def generate(
|
||||
items=[],
|
||||
subdirs=source_subdirs,
|
||||
relative_path=Path("."),
|
||||
title="Gallery Root"
|
||||
title="Gallery Root",
|
||||
)
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
@@ -277,8 +247,8 @@ def generate(
|
||||
def _count_gallery_plots(gallery_root: Path) -> int:
|
||||
"""Recursively count plot files (PDFs and HTMLs, excluding index.html) in the gallery output."""
|
||||
count = 0
|
||||
for f in gallery_root.rglob('*'):
|
||||
if f.is_file() and f.suffix.lower() in ('.pdf', '.html') and f.name != 'index.html':
|
||||
for f in gallery_root.rglob("*"):
|
||||
if f.is_file() and f.suffix.lower() in (".pdf", ".html") and f.name != "index.html":
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
+13
-17
@@ -2,7 +2,8 @@
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, Union
|
||||
from typing import Any, Dict, Optional, Union
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, Template
|
||||
|
||||
from gallery.config import GalleryConfig
|
||||
@@ -17,7 +18,6 @@ from gallery.utils.metadata import (
|
||||
)
|
||||
from gallery.utils.processing import (
|
||||
process_plot_files,
|
||||
needs_update,
|
||||
render_gallery_page,
|
||||
)
|
||||
|
||||
@@ -39,13 +39,14 @@ def get_template(template_dir: Optional[Union[Path, str]] = None):
|
||||
if template_dir is None:
|
||||
# Use package-included template
|
||||
import gallery
|
||||
|
||||
gallery_module_path = Path(gallery.__file__).parent
|
||||
template_dir = gallery_module_path / "templates"
|
||||
|
||||
env = Environment(loader=FileSystemLoader(str(template_dir)))
|
||||
|
||||
env.filters['datetime_from_timestamp'] = datetime_from_timestamp
|
||||
env.filters['strftime'] = strftime_filter
|
||||
env.filters["datetime_from_timestamp"] = datetime_from_timestamp
|
||||
env.filters["strftime"] = strftime_filter
|
||||
|
||||
return env.get_template("gallery.html")
|
||||
|
||||
@@ -54,8 +55,8 @@ def build_gallery(
|
||||
config: GalleryConfig,
|
||||
source_dir: Path,
|
||||
web_dir: Path,
|
||||
template: Template = None,
|
||||
relative_path: Path = None,
|
||||
template: Optional[Template] = None,
|
||||
relative_path: Optional[Path] = None,
|
||||
inherited_metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
@@ -119,7 +120,7 @@ def build_gallery(
|
||||
subdir_web,
|
||||
template,
|
||||
subdir_relative,
|
||||
current_metadata if config.inherit_from_parent else {}
|
||||
current_metadata if config.inherit_from_parent else {},
|
||||
)
|
||||
subdir_names.append(subdir.name)
|
||||
|
||||
@@ -130,15 +131,11 @@ def build_gallery(
|
||||
items=items,
|
||||
subdirs=subdir_names,
|
||||
relative_path=relative_path,
|
||||
metadata=current_metadata
|
||||
metadata=current_metadata,
|
||||
)
|
||||
|
||||
|
||||
def copy_assets(
|
||||
config: GalleryConfig,
|
||||
assets_src: Optional[Path] = None,
|
||||
verbose: bool = False
|
||||
) -> bool:
|
||||
def copy_assets(config: GalleryConfig, assets_src: Optional[Path] = None, verbose: bool = False) -> bool:
|
||||
"""
|
||||
Copy assets to the web directory.
|
||||
|
||||
@@ -154,14 +151,13 @@ def copy_assets(
|
||||
if assets_src is None:
|
||||
# Use package-included assets
|
||||
import gallery
|
||||
|
||||
gallery_module_path = Path(gallery.__file__).parent
|
||||
assets_src = gallery_module_path / "assets"
|
||||
|
||||
if not assets_src.exists():
|
||||
if verbose:
|
||||
print(
|
||||
f"Warning: Assets directory {assets_src} not found"
|
||||
)
|
||||
print(f"Warning: Assets directory {assets_src} not found")
|
||||
return False
|
||||
|
||||
gallery_root = Path(config.web_folder) / config.plot_root
|
||||
@@ -171,7 +167,7 @@ def copy_assets(
|
||||
# so any change to any JS/CSS file triggers a redeploy.
|
||||
sentinel_dst = assets_dst / "css" / "main.css"
|
||||
newest_src_mtime = max(
|
||||
(f.stat().st_mtime for f in assets_src.rglob('*') if f.is_file()),
|
||||
(f.stat().st_mtime for f in assets_src.rglob("*") if f.is_file()),
|
||||
default=0,
|
||||
)
|
||||
dst_mtime = sentinel_dst.stat().st_mtime if sentinel_dst.exists() else 0
|
||||
|
||||
+42
-23
@@ -10,13 +10,17 @@ 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,
|
||||
default_config_path, get_active_config_path, ensure_user_config,
|
||||
ConfigManager,
|
||||
GalleryConfig,
|
||||
GallerySource,
|
||||
ensure_user_config,
|
||||
get_active_config_path,
|
||||
)
|
||||
|
||||
_WELCOME = """\
|
||||
@@ -58,6 +62,11 @@ 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:
|
||||
@@ -104,13 +113,16 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
description="Scientific Plot Gallery Generator",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
type=str,
|
||||
default=None,
|
||||
metavar="FILE",
|
||||
help="Path to config file (default: package bundled config)",
|
||||
).completer = argcomplete.completers.FilesCompleter(["yaml", "yml"])
|
||||
_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")
|
||||
|
||||
@@ -121,13 +133,16 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
description="Generate scientific gallery from plot collections",
|
||||
)
|
||||
gen.add_argument("--clean", action="store_true", help="Clean gallery directory before generation")
|
||||
gen.add_argument(
|
||||
"--source",
|
||||
type=str,
|
||||
default=None,
|
||||
metavar="DIR",
|
||||
help="Only recompute a specific source directory (name defaults to dir name)",
|
||||
).completer = argcomplete.completers.DirectoriesCompleter()
|
||||
_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 -------------------------------------------------------------
|
||||
@@ -146,20 +161,21 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
cfg_sub.add_parser("sources", help="List all configured sources")
|
||||
|
||||
p_get = cfg_sub.add_parser("get", help="Get a config value")
|
||||
p_get.add_argument("key", help="Dot-separated key, e.g. gallery.png_dpi").completer = _config_keys
|
||||
_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")
|
||||
p_set.add_argument("key", help="Dot-separated key, e.g. gallery.png_dpi").completer = _config_keys
|
||||
_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)")
|
||||
p_add.add_argument("--path", required=True, metavar="DIR", help="Path to source directory").completer = (
|
||||
argcomplete.completers.DirectoriesCompleter()
|
||||
_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")
|
||||
p_rm.add_argument("name", help="Source name to remove").completer = _source_names
|
||||
_set_completer(p_rm.add_argument("name", help="Source name to remove"), _source_names)
|
||||
|
||||
return parser
|
||||
|
||||
@@ -174,10 +190,12 @@ def _run_generate(args: argparse.Namespace) -> int:
|
||||
try:
|
||||
config = GalleryConfig.from_yaml(config_path)
|
||||
|
||||
source_to_update = None
|
||||
source_to_update: Optional[GallerySource] = None
|
||||
if args.source:
|
||||
source_path = Path(args.source).resolve()
|
||||
matching = next((s for s in config.sources if Path(s.path).resolve() == source_path), None)
|
||||
# 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)
|
||||
@@ -312,6 +330,7 @@ def main():
|
||||
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)
|
||||
|
||||
@@ -8,7 +8,8 @@ defaults for gallery generation settings.
|
||||
import shutil
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from typing import Any, Dict, List, Optional, Union, cast
|
||||
|
||||
import yaml
|
||||
from platformdirs import user_config_dir
|
||||
|
||||
@@ -31,8 +32,8 @@ def user_config_path() -> Path:
|
||||
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
|
||||
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()
|
||||
@@ -133,6 +134,7 @@ class ConfigManager:
|
||||
@dataclass
|
||||
class GalleryDefaults:
|
||||
"""Default values for gallery generation."""
|
||||
|
||||
png_dpi: int = 400
|
||||
plot_root: str = "gallery"
|
||||
cache_enabled: bool = True
|
||||
@@ -142,6 +144,7 @@ class GalleryDefaults:
|
||||
@dataclass
|
||||
class GallerySource:
|
||||
"""Represents a single data source for the gallery."""
|
||||
|
||||
name: str
|
||||
path: Union[str, Path]
|
||||
|
||||
@@ -157,6 +160,7 @@ class GalleryConfig:
|
||||
|
||||
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
|
||||
@@ -169,10 +173,11 @@ class GalleryConfig:
|
||||
if isinstance(self.web_folder, str):
|
||||
self.web_folder = Path(self.web_folder)
|
||||
|
||||
normalized_sources = []
|
||||
normalized_sources: List[Union[GallerySource, Dict[str, Any]]] = []
|
||||
for source in self.sources:
|
||||
if isinstance(source, dict):
|
||||
source = GallerySource(**source)
|
||||
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)
|
||||
@@ -197,15 +202,19 @@ class GalleryConfig:
|
||||
|
||||
gallery_cfg = data.get("gallery", {})
|
||||
sources_data = data.get("sources", [])
|
||||
sources = [{"name": s["name"], "path": s["path"]} for s in sources_data]
|
||||
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=data.get("metadata", {}).get("cache_enabled", GalleryDefaults.cache_enabled),
|
||||
inherit_from_parent=data.get("metadata", {}).get("inherit_from_parent", GalleryDefaults.inherit_from_parent),
|
||||
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", ""),
|
||||
)
|
||||
|
||||
@@ -231,7 +240,8 @@ class GalleryConfig:
|
||||
"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],
|
||||
# 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:
|
||||
|
||||
+17
-16
@@ -27,6 +27,7 @@ from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Horizontal, ScrollableContainer, Vertical
|
||||
from textual.reactive import reactive
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Button, Collapsible, Footer, Header, Input, Label, RichLog, Static
|
||||
|
||||
from gallery.config import ConfigManager, ensure_user_config, get_active_config_path
|
||||
@@ -37,12 +38,12 @@ from gallery.config import ConfigManager, ensure_user_config, get_active_config_
|
||||
# widget_id is used as the HTML-style id (#web-folder) in CSS selectors
|
||||
# ---------------------------------------------------------------------------
|
||||
CONFIG_FIELDS = [
|
||||
("web-folder", "paths.web_folder", True),
|
||||
("plot-root", "gallery.plot_root", False),
|
||||
("png-dpi", "gallery.png_dpi", False),
|
||||
("backup-folder", "gallery.backup_folder", False),
|
||||
("cache-enabled", "metadata.cache_enabled", False),
|
||||
("inherit-meta", "metadata.inherit_from_parent",False),
|
||||
("web-folder", "paths.web_folder", True),
|
||||
("plot-root", "gallery.plot_root", False),
|
||||
("png-dpi", "gallery.png_dpi", False),
|
||||
("backup-folder", "gallery.backup_folder", False),
|
||||
("cache-enabled", "metadata.cache_enabled", False),
|
||||
("inherit-meta", "metadata.inherit_from_parent", False),
|
||||
]
|
||||
|
||||
REQUIRED_IDS = {fid for fid, _, req in CONFIG_FIELDS if req}
|
||||
@@ -269,9 +270,9 @@ class GalleryTUI(App):
|
||||
# Bottom bar — always visible outside the scroll area
|
||||
with Horizontal(id="footer-bar"):
|
||||
yield Static("", id="dirty-indicator")
|
||||
yield Button("Save Config", id="save-btn", variant="success")
|
||||
yield Button("Generate", id="generate-btn", variant="primary")
|
||||
yield Button("Quit", id="quit-btn", variant="error")
|
||||
yield Button("Save Config", id="save-btn", variant="success")
|
||||
yield Button("Generate", id="generate-btn", variant="primary")
|
||||
yield Button("Quit", id="quit-btn", variant="error")
|
||||
|
||||
yield Footer()
|
||||
|
||||
@@ -280,9 +281,7 @@ class GalleryTUI(App):
|
||||
# ------------------------------------------------------------------
|
||||
def on_mount(self) -> None:
|
||||
self._load_config_into_fields()
|
||||
self.query_one("#config-path-label", Static).update(
|
||||
f"Config: {self.config_path}"
|
||||
)
|
||||
self.query_one("#config-path-label", Static).update(f"Config: {self.config_path}")
|
||||
|
||||
def _make_source_row(self, name: str = "", path: str = "") -> Horizontal:
|
||||
"""Return a single editable source row widget."""
|
||||
@@ -368,7 +367,7 @@ class GalleryTUI(App):
|
||||
self._save_config()
|
||||
|
||||
@on(Button.Pressed, "#quit-btn")
|
||||
def action_quit(self) -> None:
|
||||
async def action_quit(self) -> None:
|
||||
self.exit()
|
||||
|
||||
@on(Button.Pressed, "#save-btn")
|
||||
@@ -421,7 +420,9 @@ class GalleryTUI(App):
|
||||
@on(Button.Pressed, ".source-remove-btn")
|
||||
def _remove_source_row(self, event: Button.Pressed) -> None:
|
||||
"""Remove the row whose − button was pressed."""
|
||||
event.button.parent.remove()
|
||||
parent = event.button.parent
|
||||
assert isinstance(parent, Widget), "source-remove button must be mounted inside a source row widget"
|
||||
parent.remove()
|
||||
self.dirty = True
|
||||
|
||||
# -- Generate --------------------------------------------------------
|
||||
@@ -461,8 +462,7 @@ class GalleryTUI(App):
|
||||
status.label = label
|
||||
|
||||
# --config is a top-level flag (before the subcommand) in the CLI parser
|
||||
cmd = [sys.executable, "-m", "gallery.cli",
|
||||
"--config", str(self.config_path), "generate", "--verbose"]
|
||||
cmd = [sys.executable, "-m", "gallery.cli", "--config", str(self.config_path), "generate", "--verbose"]
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
@@ -470,6 +470,7 @@ class GalleryTUI(App):
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
assert proc.stdout is not None, "Popen was called with stdout=PIPE"
|
||||
for line in proc.stdout:
|
||||
line = line.rstrip()
|
||||
if line:
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
"""Backup utilities for gallery."""
|
||||
|
||||
import zipfile
|
||||
import datetime
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def create_backup(
|
||||
web_folder: Path,
|
||||
backup_folder: Path
|
||||
) -> bool:
|
||||
def create_backup(web_folder: Path, backup_folder: Path) -> bool:
|
||||
"""
|
||||
Create a backup of the web folder.
|
||||
|
||||
|
||||
+15
-24
@@ -13,9 +13,10 @@ Features:
|
||||
"""
|
||||
|
||||
import json
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
from typing import Any, Dict
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def load_metadata_file(metadata_path: Path) -> Dict[str, Any]:
|
||||
@@ -33,15 +34,14 @@ def load_metadata_file(metadata_path: Path) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
try:
|
||||
with metadata_path.open('r', encoding='utf-8') as f:
|
||||
with metadata_path.open("r", encoding="utf-8") as f:
|
||||
suffix_lower = metadata_path.suffix.lower()
|
||||
if suffix_lower == '.yaml' or suffix_lower == '.yml':
|
||||
if suffix_lower == ".yaml" or suffix_lower == ".yml":
|
||||
return yaml.safe_load(f) or {}
|
||||
elif metadata_path.suffix.lower() == '.json':
|
||||
elif metadata_path.suffix.lower() == ".json":
|
||||
return json.load(f) or {}
|
||||
else:
|
||||
print(f"Warning: Unknown metadata file format: "
|
||||
f"{metadata_path}")
|
||||
print(f"Warning: Unknown metadata file format: {metadata_path}")
|
||||
return {}
|
||||
except (yaml.YAMLError, json.JSONDecodeError, IOError) as e:
|
||||
print(f"Warning: Could not parse metadata file {metadata_path}: {e}")
|
||||
@@ -59,7 +59,7 @@ def load_folder_metadata(folder_path: Path) -> Dict[str, Any]:
|
||||
Dictionary containing the folder metadata
|
||||
"""
|
||||
# Try YAML first, then JSON for backwards compatibility
|
||||
for filename in ['metadata.yaml', 'metadata.yml', 'metadata.json']:
|
||||
for filename in ["metadata.yaml", "metadata.yml", "metadata.json"]:
|
||||
metadata_path = folder_path / filename
|
||||
|
||||
if metadata_path.exists():
|
||||
@@ -81,7 +81,7 @@ def get_metadata_file_path(folder_path: Path) -> str:
|
||||
String path to the metadata file (existing or suggested)
|
||||
"""
|
||||
# Preferred order: YAML first, then JSON
|
||||
preferred_files = ['metadata.yaml', 'metadata.yml', 'metadata.json']
|
||||
preferred_files = ["metadata.yaml", "metadata.yml", "metadata.json"]
|
||||
|
||||
for filename in preferred_files:
|
||||
metadata_path = folder_path / filename
|
||||
@@ -89,13 +89,10 @@ def get_metadata_file_path(folder_path: Path) -> str:
|
||||
return str(metadata_path)
|
||||
|
||||
# If no file exists, suggest metadata.yaml (preferred format)
|
||||
return str(folder_path / 'metadata.yaml')
|
||||
return str(folder_path / "metadata.yaml")
|
||||
|
||||
|
||||
def merge_metadata(
|
||||
parent_metadata: Dict[str, Any],
|
||||
child_metadata: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
def merge_metadata(parent_metadata: Dict[str, Any], child_metadata: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Merge parent and child metadata, with child values overriding parent.
|
||||
|
||||
@@ -111,10 +108,7 @@ def merge_metadata(
|
||||
return merged
|
||||
|
||||
|
||||
def resolve_metadata_for_plot(
|
||||
plot_path: Path,
|
||||
inherited_metadata: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
def resolve_metadata_for_plot(plot_path: Path, inherited_metadata: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Resolve metadata for a specific plot.
|
||||
|
||||
@@ -131,7 +125,7 @@ def resolve_metadata_for_plot(
|
||||
plot_dir = plot_path.parent
|
||||
|
||||
# Check for plot-specific metadata files
|
||||
for suffix in ['.yaml', '.yml', '.json']:
|
||||
for suffix in [".yaml", ".yml", ".json"]:
|
||||
plot_metadata_path = plot_dir / f"{plot_stem}{suffix}"
|
||||
if plot_metadata_path.exists():
|
||||
plot_metadata = load_metadata_file(plot_metadata_path)
|
||||
@@ -141,10 +135,7 @@ def resolve_metadata_for_plot(
|
||||
return inherited_metadata.copy()
|
||||
|
||||
|
||||
def save_metadata_cache(
|
||||
web_dir: Path,
|
||||
plot_metadata_cache: Dict[str, Dict[str, Any]]
|
||||
) -> None:
|
||||
def save_metadata_cache(web_dir: Path, plot_metadata_cache: Dict[str, Dict[str, Any]]) -> None:
|
||||
"""
|
||||
Save plot metadata cache to meta_cache.json in the web directory.
|
||||
|
||||
@@ -154,7 +145,7 @@ def save_metadata_cache(
|
||||
"""
|
||||
cache_path = web_dir / "meta_cache.json"
|
||||
try:
|
||||
with cache_path.open('w', encoding='utf-8') as f:
|
||||
with cache_path.open("w", encoding="utf-8") as f:
|
||||
json.dump(plot_metadata_cache, f, indent=2, ensure_ascii=False)
|
||||
except IOError as e:
|
||||
print(f"Warning: Could not save metadata cache {cache_path}: {e}")
|
||||
|
||||
+39
-38
@@ -3,34 +3,31 @@
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from jinja2 import Template
|
||||
|
||||
from gallery.config import GalleryConfig
|
||||
from gallery.utils.metadata import (
|
||||
get_metadata_file_path,
|
||||
resolve_metadata_for_plot,
|
||||
)
|
||||
from gallery.utils.stats import (
|
||||
calculate_directory_stats,
|
||||
format_file_size,
|
||||
)
|
||||
|
||||
try:
|
||||
import fitz # PyMuPDF
|
||||
|
||||
_PYMUPDF_AVAILABLE = True
|
||||
except ImportError:
|
||||
_PYMUPDF_AVAILABLE = False
|
||||
|
||||
_IMAGEMAGICK_AVAILABLE = shutil.which("convert") is not None
|
||||
|
||||
from jinja2 import Template
|
||||
|
||||
from gallery.utils.metadata import (
|
||||
resolve_metadata_for_plot,
|
||||
get_metadata_file_path,
|
||||
)
|
||||
from gallery.utils.stats import (
|
||||
calculate_directory_stats,
|
||||
format_file_size,
|
||||
)
|
||||
from gallery.config import GalleryConfig
|
||||
|
||||
|
||||
def process_html_file(
|
||||
html_file: Path,
|
||||
web_dir: Path,
|
||||
current_metadata: Dict[str, Any] = None
|
||||
) -> dict:
|
||||
def process_html_file(html_file: Path, web_dir: Path, current_metadata: Optional[Dict[str, Any]] = None) -> dict:
|
||||
"""
|
||||
Process HTML plot file, copying it to web directory.
|
||||
|
||||
@@ -60,15 +57,15 @@ def process_html_file(
|
||||
"html_href": html_file.name,
|
||||
"is_html": True,
|
||||
"metadata": plot_metadata,
|
||||
"creation_time": source_creation_time
|
||||
"creation_time": source_creation_time,
|
||||
}
|
||||
|
||||
|
||||
def process_plot_files(
|
||||
config: GalleryConfig,
|
||||
plot_file: Path,
|
||||
web_dir: Path,
|
||||
current_metadata: Dict[str, Any] = None,
|
||||
config: GalleryConfig,
|
||||
plot_file: Path,
|
||||
web_dir: Path,
|
||||
current_metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Process plot files (PDF/PNG or HTML), handling conversion and copying.
|
||||
@@ -82,7 +79,7 @@ def process_plot_files(
|
||||
Returns:
|
||||
Dictionary containing plot information
|
||||
"""
|
||||
if plot_file.suffix.lower() == '.html':
|
||||
if plot_file.suffix.lower() == ".html":
|
||||
return process_html_file(plot_file, web_dir, current_metadata)
|
||||
|
||||
# Handle PDF files
|
||||
@@ -111,7 +108,7 @@ def process_plot_files(
|
||||
"png_href": png_file.name,
|
||||
"is_html": False,
|
||||
"metadata": plot_metadata,
|
||||
"creation_time": source_creation_time
|
||||
"creation_time": source_creation_time,
|
||||
}
|
||||
|
||||
|
||||
@@ -122,8 +119,8 @@ def render_gallery_page(
|
||||
items: list,
|
||||
subdirs: list,
|
||||
relative_path: Path,
|
||||
title: str = None,
|
||||
metadata: dict = None
|
||||
title: Optional[str] = None,
|
||||
metadata: Optional[dict] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Unified template rendering for all gallery pages.
|
||||
@@ -139,8 +136,7 @@ def render_gallery_page(
|
||||
metadata: Metadata dictionary (optional)
|
||||
"""
|
||||
if title is None:
|
||||
title = "Gallery" if relative_path == Path(
|
||||
".") else f"Gallery: {relative_path}"
|
||||
title = "Gallery" if relative_path == Path(".") else f"Gallery: {relative_path}"
|
||||
|
||||
if metadata is None:
|
||||
metadata = {}
|
||||
@@ -151,7 +147,7 @@ def render_gallery_page(
|
||||
"file_count": len(items),
|
||||
"folder_count": len(subdirs),
|
||||
"total_size": format_file_size(current_stats["total_size"]),
|
||||
"total_size_bytes": current_stats["total_size"]
|
||||
"total_size_bytes": current_stats["total_size"],
|
||||
}
|
||||
|
||||
# Calculate relative path to assets
|
||||
@@ -185,7 +181,7 @@ def render_gallery_page(
|
||||
folder_metadata=metadata,
|
||||
assets_path=assets_path,
|
||||
source_dir=str(web_dir),
|
||||
metadata_file_path=get_metadata_file_path(web_dir)
|
||||
metadata_file_path=get_metadata_file_path(web_dir),
|
||||
)
|
||||
f.write(rendered_html)
|
||||
|
||||
@@ -232,13 +228,18 @@ def _convert_pdf_pymupdf(pdf_path: Path, png_path: Path, dpi: int) -> None:
|
||||
|
||||
|
||||
def _convert_pdf_imagemagick(pdf_path: Path, png_path: Path, dpi: int) -> None:
|
||||
subprocess.run([
|
||||
"convert",
|
||||
"-density", str(dpi),
|
||||
str(pdf_path),
|
||||
"-quality", "95",
|
||||
str(png_path),
|
||||
], check=True)
|
||||
subprocess.run(
|
||||
[
|
||||
"convert",
|
||||
"-density",
|
||||
str(dpi),
|
||||
str(pdf_path),
|
||||
"-quality",
|
||||
"95",
|
||||
str(png_path),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
def needs_update(source_file: Path, target_file: Path) -> bool:
|
||||
|
||||
@@ -30,9 +30,9 @@ def calculate_directory_stats(directory: Path) -> dict:
|
||||
size = item.stat().st_size
|
||||
stats["total_size"] += size
|
||||
|
||||
if item.suffix.lower() == '.pdf':
|
||||
if item.suffix.lower() == ".pdf":
|
||||
stats["pdf_size"] += size
|
||||
elif item.suffix.lower() == '.png':
|
||||
elif item.suffix.lower() == ".png":
|
||||
stats["png_size"] += size
|
||||
elif item.is_dir():
|
||||
stats["folder_count"] += 1
|
||||
|
||||
Reference in New Issue
Block a user