Convert to proper python package

This commit is contained in:
Kylian Schmidt
2026-04-22 13:48:23 +02:00
parent 7e36688d1d
commit 27ea17246c
52 changed files with 6888 additions and 246 deletions
+1
View File
@@ -0,0 +1 @@
"""Utility modules for gallery generation."""
+40
View File
@@ -0,0 +1,40 @@
"""Backup utilities for gallery."""
import zipfile
import datetime
from pathlib import Path
def create_backup(
web_folder: Path,
backup_folder: Path
) -> bool:
"""
Create a backup of the web folder.
Args:
web_folder: Path to the web folder to backup
backup_folder: Path to the backup directory
Returns:
True if backup was created successfully, False otherwise
"""
try:
today = datetime.date.today().strftime("%Y%m%d")
backup_name = f"backup-{today}.zip"
backup_path = backup_folder / backup_name
backup_folder.mkdir(parents=True, exist_ok=True)
if backup_path.exists():
return True
with zipfile.ZipFile(backup_path, "w", zipfile.ZIP_DEFLATED) as zipf:
for path in web_folder.rglob("*"):
if path.is_file():
arcname = path.relative_to(web_folder.parent)
zipf.write(path, arcname)
return True
except Exception as e:
print(f"Warning: Could not create backup: {e}")
return False
+159
View File
@@ -0,0 +1,159 @@
"""
Metadata Management for Scientific Gallery Generator
This module handles loading, parsing, and caching of metadata for plots
and folders in the gallery system. Supports YAML and JSON formats with
hierarchical inheritance.
Features:
- Load metadata from YAML/JSON files
- Hierarchical metadata inheritance from parent folders
- Plot-specific metadata overrides
- Metadata caching for performance
"""
import json
import yaml
from pathlib import Path
from typing import Dict, Any
def load_metadata_file(metadata_path: Path) -> Dict[str, Any]:
"""
Load metadata from a YAML or JSON file.
Args:
metadata_path: Path to the metadata file
Returns:
Dictionary containing the metadata, empty dict if file doesn't exist
or can't be parsed
"""
if not metadata_path.exists():
return {}
try:
with metadata_path.open('r', encoding='utf-8') as f:
suffix_lower = metadata_path.suffix.lower()
if suffix_lower == '.yaml' or suffix_lower == '.yml':
return yaml.safe_load(f) or {}
elif metadata_path.suffix.lower() == '.json':
return json.load(f) or {}
else:
print(f"Warning: Unknown metadata file format: "
f"{metadata_path}")
return {}
except (yaml.YAMLError, json.JSONDecodeError, IOError) as e:
print(f"Warning: Could not parse metadata file {metadata_path}: {e}")
raise e
def load_folder_metadata(folder_path: Path) -> Dict[str, Any]:
"""
Load folder-level metadata from metadata.yaml, metadata.yml, or metadata.json.
Args:
folder_path: Path to the folder to check for metadata
Returns:
Dictionary containing the folder metadata
"""
# Try YAML first, then JSON for backwards compatibility
for filename in ['metadata.yaml', 'metadata.yml', 'metadata.json']:
metadata_path = folder_path / filename
if metadata_path.exists():
return load_metadata_file(metadata_path)
return {}
def get_metadata_file_path(folder_path: Path) -> str:
"""
Get the metadata file path for a folder.
Returns existing file if found, otherwise suggests metadata.yaml.
Args:
folder_path: Path to the folder to check for metadata
Returns:
String path to the metadata file (existing or suggested)
"""
# Preferred order: YAML first, then JSON
preferred_files = ['metadata.yaml', 'metadata.yml', 'metadata.json']
for filename in preferred_files:
metadata_path = folder_path / filename
if metadata_path.exists():
return str(metadata_path)
# If no file exists, suggest metadata.yaml (preferred format)
return str(folder_path / 'metadata.yaml')
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.
Args:
parent_metadata: Metadata from parent folder
child_metadata: Metadata from current folder
Returns:
Merged metadata dictionary
"""
merged = parent_metadata.copy()
merged.update(child_metadata)
return merged
def resolve_metadata_for_plot(
plot_path: Path,
inherited_metadata: Dict[str, Any]
) -> Dict[str, Any]:
"""
Resolve metadata for a specific plot.
Merges inherited metadata with plot-specific metadata.
Args:
plot_path: Path to the plot file (PDF)
inherited_metadata: Metadata inherited from folder hierarchy
Returns:
Final merged metadata for the plot
"""
plot_stem = plot_path.stem
plot_dir = plot_path.parent
# Check for plot-specific metadata files
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)
return merge_metadata(inherited_metadata, plot_metadata)
# No plot-specific metadata found, return inherited metadata
return inherited_metadata.copy()
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.
Args:
web_dir: Web directory where the cache file should be saved
plot_metadata_cache: Dictionary mapping plot names to their metadata
"""
cache_path = web_dir / "meta_cache.json"
try:
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}")
+234
View File
@@ -0,0 +1,234 @@
"""Plot file processing and HTML rendering."""
import shutil
import subprocess
from pathlib import Path
from typing import Any, Dict
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:
"""
Process HTML plot file, copying it to web directory.
Args:
html_file: Path to the source HTML file
web_dir: Target web directory
current_metadata: Current metadata dictionary for the plot
Returns:
Dictionary containing plot information
"""
web_html = web_dir / html_file.name
if needs_update(html_file, web_html):
shutil.copy2(html_file, web_html)
# Get source file creation time
source_creation_time = int(html_file.stat().st_ctime)
# Resolve metadata if provided
plot_metadata = {}
if current_metadata is not None:
plot_metadata = resolve_metadata_for_plot(html_file, current_metadata)
return {
"name": html_file.stem,
"html_href": html_file.name,
"is_html": True,
"metadata": plot_metadata,
"creation_time": source_creation_time
}
def process_plot_files(
config: GalleryConfig,
plot_file: Path,
web_dir: Path,
current_metadata: Dict[str, Any] = None,
) -> dict:
"""
Process plot files (PDF/PNG or HTML), handling conversion and copying.
Args:
config: Gallery configuration object
plot_file: Path to the source plot file (PDF or HTML)
web_dir: Target web directory
current_metadata: Current metadata dictionary for the plot
Returns:
Dictionary containing plot information
"""
if plot_file.suffix.lower() == '.html':
return process_html_file(plot_file, web_dir, current_metadata)
# Handle PDF files
png_file = plot_file.with_suffix(".png")
web_pdf = web_dir / plot_file.name
web_png = web_dir / png_file.name
if needs_update(plot_file, web_pdf):
shutil.copy2(plot_file, web_pdf)
if not png_file.exists():
convert_pdf_to_png(plot_file, config=config)
if needs_update(png_file, web_png):
shutil.copy2(png_file, web_png)
source_creation_time = int(plot_file.stat().st_ctime)
plot_metadata = {}
if current_metadata is not None:
plot_metadata = resolve_metadata_for_plot(plot_file, current_metadata)
return {
"name": plot_file.stem,
"pdf_href": plot_file.name,
"png_href": png_file.name,
"is_html": False,
"metadata": plot_metadata,
"creation_time": source_creation_time
}
def render_gallery_page(
config: GalleryConfig,
template: Template,
web_dir: Path,
items: list,
subdirs: list,
relative_path: Path,
title: str = None,
metadata: dict = None
) -> None:
"""
Unified template rendering for all gallery pages.
Args:
config: Gallery configuration object
template: Jinja2 template object
web_dir: Target web directory
items: List of plot items
subdirs: List of subdirectory names
relative_path: Relative path from gallery root
title: Page title (optional)
metadata: Metadata dictionary (optional)
"""
if title is None:
title = "Gallery" if relative_path == Path(
".") else f"Gallery: {relative_path}"
if metadata is None:
metadata = {}
# Calculate statistics
current_stats = calculate_directory_stats(web_dir)
stats = {
"file_count": len(items),
"folder_count": len(subdirs),
"total_size": format_file_size(current_stats["total_size"]),
"total_size_bytes": current_stats["total_size"]
}
# Calculate relative path to assets
if relative_path == Path("."):
assets_path = "../assets"
else:
depth = len(relative_path.parts)
assets_path = "../" * (depth + 1) + "assets"
# For root level, show only directory structure
if relative_path == Path("."):
items = []
output_html = web_dir / "index.html"
with output_html.open("w") as f:
paths_dict = {
"work_dir": str(Path.cwd()),
"web_folder": str(config.web_folder),
}
ui_dict = {
"max_recent_plots": 20,
"search_debounce_ms": 300,
}
rendered_html = template.render(
title=title,
items=items,
subdirs=subdirs,
relpath=str(relative_path),
paths=paths_dict,
ui=ui_dict,
stats=stats,
folder_metadata=metadata,
assets_path=assets_path,
source_dir=str(web_dir),
metadata_file_path=get_metadata_file_path(web_dir)
)
f.write(rendered_html)
def convert_pdf_to_png(pdf_path: Path, config: GalleryConfig) -> None:
"""
Convert a PDF file to PNG format using ImageMagick.
Only converts if the PNG doesn't exist or if the PDF is newer than
the PNG (with a 30-second buffer to handle filesystem timing issues).
Args:
pdf_path: Path to the source PDF file
config: Gallery configuration object
Raises:
subprocess.CalledProcessError: If ImageMagick conversion fails
"""
png_path = pdf_path.with_suffix(".png")
if png_path.exists():
pdf_mtime = pdf_path.stat().st_mtime
png_mtime = png_path.stat().st_mtime
if png_mtime >= (pdf_mtime + 30):
return
subprocess.run([
"convert",
"-density", str(config.png_dpi),
str(pdf_path),
"-quality", "95",
str(png_path)
], check=True)
def needs_update(source_file: Path, target_file: Path) -> bool:
"""
Check if target file needs updating based on source modification time.
Args:
source_file: Path to the source file
target_file: Path to the target file
Returns:
True if target needs update, False otherwise
"""
if not target_file.exists():
return True
source_mtime = source_file.stat().st_mtime
target_mtime = target_file.stat().st_mtime
return source_mtime > (target_mtime + 30)
+63
View File
@@ -0,0 +1,63 @@
"""Statistics calculation for gallery directories."""
from pathlib import Path
def calculate_directory_stats(directory: Path) -> dict:
"""
Calculate statistics for a directory.
Args:
directory: Path to the directory to analyze
Returns:
Dictionary containing file count, folder count, and total size
"""
stats = {
"file_count": 0,
"folder_count": 0,
"total_size": 0,
"pdf_size": 0,
"png_size": 0,
}
if not directory.exists():
return stats
for item in directory.rglob("*"):
if item.is_file():
stats["file_count"] += 1
size = item.stat().st_size
stats["total_size"] += size
if item.suffix.lower() == '.pdf':
stats["pdf_size"] += size
elif item.suffix.lower() == '.png':
stats["png_size"] += size
elif item.is_dir():
stats["folder_count"] += 1
return stats
def format_file_size(size_bytes: int) -> str:
"""
Format file size in human readable format.
Args:
size_bytes: Size in bytes
Returns:
Formatted size string
"""
if size_bytes == 0:
return "0 B"
size_names = ["B", "KB", "MB", "GB", "TB"]
size = float(size_bytes)
i = 0
while size >= 1024 and i < len(size_names) - 1:
size /= 1024
i += 1
return f"{size:.1f} {size_names[i]}"