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>
263 lines
7.3 KiB
Python
263 lines
7.3 KiB
Python
"""Plot file processing and HTML rendering."""
|
|
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
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
|
|
|
|
|
|
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.
|
|
|
|
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: Optional[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: Optional[str] = None,
|
|
metadata: Optional[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 = {
|
|
"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.
|
|
|
|
Uses PyMuPDF (fitz) when available; falls back to ImageMagick otherwise.
|
|
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).
|
|
|
|
Raises:
|
|
RuntimeError: If neither PyMuPDF nor ImageMagick is available.
|
|
subprocess.CalledProcessError: If the ImageMagick fallback 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
|
|
|
|
if _PYMUPDF_AVAILABLE:
|
|
_convert_pdf_pymupdf(pdf_path, png_path, config.png_dpi)
|
|
elif _IMAGEMAGICK_AVAILABLE:
|
|
_convert_pdf_imagemagick(pdf_path, png_path, config.png_dpi)
|
|
else:
|
|
raise RuntimeError(
|
|
"No PDF renderer found. Install PyMuPDF (`pip install pymupdf`) "
|
|
"or ImageMagick (`apt-get install imagemagick`)."
|
|
)
|
|
|
|
|
|
def _convert_pdf_pymupdf(pdf_path: Path, png_path: Path, dpi: int) -> None:
|
|
zoom = dpi / 72 # PDF coordinate space is 72 pt/inch
|
|
doc = fitz.open(str(pdf_path))
|
|
try:
|
|
pix = doc[0].get_pixmap(matrix=fitz.Matrix(zoom, zoom))
|
|
pix.save(str(png_path))
|
|
finally:
|
|
doc.close()
|
|
|
|
|
|
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,
|
|
)
|
|
|
|
|
|
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)
|