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:
2026-07-22 15:29:55 +02:00
parent 4ba321b327
commit 3b9d1ef1a8
27 changed files with 752 additions and 915 deletions
-1
View File
@@ -1,4 +1,3 @@
import sys
sys.path.append("..")
+76 -153
View File
@@ -1,214 +1,137 @@
import zipfile
import datetime
import zipfile
from unittest.mock import patch
from utils import backup
from gallery.utils.backup import create_backup
def test_backup_creates_zip(tmp_path, monkeypatch):
# Setup fake web folder
web_folder = tmp_path / 'plots'
def test_backup_creates_zip(tmp_path):
web_folder = tmp_path / "plots"
web_folder.mkdir()
(web_folder / 'file1.txt').write_text('abc')
(web_folder / 'file2.txt').write_text('def')
backup_folder = tmp_path / 'backups'
(web_folder / "file1.txt").write_text("abc")
(web_folder / "file2.txt").write_text("def")
backup_folder = tmp_path / "backups"
backup_folder.mkdir()
# Patch the module variables
monkeypatch.setattr(backup, 'WEB_FOLDER', web_folder)
monkeypatch.setattr(backup, 'BACKUP_FOLDER', backup_folder)
assert create_backup(web_folder, backup_folder) is True
# Call the backup function
backup.create_backup()
# Check that backup was created
today = datetime.date.today().strftime('%Y%m%d')
backup_name = f'backup-{today}.zip'
backup_path = backup_folder / backup_name
today = datetime.date.today().strftime("%Y%m%d")
backup_path = backup_folder / f"backup-{today}.zip"
assert backup_path.exists()
with zipfile.ZipFile(backup_path, 'r') as z:
with zipfile.ZipFile(backup_path, "r") as z:
names = z.namelist()
assert any('file1.txt' in n for n in names)
assert any('file2.txt' in n for n in names)
# Cleanup: remove the backup file after test
backup_path.unlink()
assert any("file1.txt" in n for n in names)
assert any("file2.txt" in n for n in names)
def test_backup_with_subdirectories(tmp_path, monkeypatch):
# Setup fake web folder with subdirectories
web_folder = tmp_path / 'plots'
def test_backup_with_subdirectories(tmp_path):
web_folder = tmp_path / "plots"
web_folder.mkdir()
(web_folder / 'file1.txt').write_text('content1')
subdir = web_folder / 'subdir'
(web_folder / "file1.txt").write_text("content1")
subdir = web_folder / "subdir"
subdir.mkdir()
(subdir / 'file2.txt').write_text('content2')
nested_subdir = subdir / 'nested'
(subdir / "file2.txt").write_text("content2")
nested_subdir = subdir / "nested"
nested_subdir.mkdir()
(nested_subdir / 'file3.txt').write_text('content3')
backup_folder = tmp_path / 'backups'
(nested_subdir / "file3.txt").write_text("content3")
backup_folder = tmp_path / "backups"
backup_folder.mkdir()
# Patch the module variables
monkeypatch.setattr(backup, 'WEB_FOLDER', web_folder)
monkeypatch.setattr(backup, 'BACKUP_FOLDER', backup_folder)
assert create_backup(web_folder, backup_folder) is True
# Call the backup function
backup.create_backup()
# Check that backup was created with all files
today = datetime.date.today().strftime('%Y%m%d')
backup_name = f'backup-{today}.zip'
backup_path = backup_folder / backup_name
today = datetime.date.today().strftime("%Y%m%d")
backup_path = backup_folder / f"backup-{today}.zip"
assert backup_path.exists()
with zipfile.ZipFile(backup_path, 'r') as z:
with zipfile.ZipFile(backup_path, "r") as z:
names = z.namelist()
assert any('file1.txt' in n for n in names)
assert any('file2.txt' in n for n in names)
assert any('file3.txt' in n for n in names)
# Cleanup
backup_path.unlink()
assert any("file1.txt" in n for n in names)
assert any("file2.txt" in n for n in names)
assert any("file3.txt" in n for n in names)
def test_backup_existing_file(tmp_path, monkeypatch, capsys):
# Setup fake web folder
web_folder = tmp_path / 'plots'
def test_backup_existing_file_is_not_overwritten(tmp_path):
web_folder = tmp_path / "plots"
web_folder.mkdir()
(web_folder / 'file1.txt').write_text('abc')
backup_folder = tmp_path / 'backups'
(web_folder / "file1.txt").write_text("abc")
backup_folder = tmp_path / "backups"
backup_folder.mkdir()
# Create existing backup file
today = datetime.date.today().strftime('%Y%m%d')
backup_name = f'backup-{today}.zip'
backup_path = backup_folder / backup_name
backup_path.write_text('existing backup')
today = datetime.date.today().strftime("%Y%m%d")
backup_path = backup_folder / f"backup-{today}.zip"
backup_path.write_text("existing backup")
# Patch the module variables
monkeypatch.setattr(backup, 'WEB_FOLDER', web_folder)
monkeypatch.setattr(backup, 'BACKUP_FOLDER', backup_folder)
# Call the backup function
backup.create_backup()
# Check that message about existing backup was printed
captured = capsys.readouterr()
assert f"Backup already exists: {backup_path}" in captured.out
# Cleanup
backup_path.unlink()
assert create_backup(web_folder, backup_folder) is True
assert backup_path.read_text() == "existing backup"
def test_backup_empty_folder(tmp_path, monkeypatch):
# Setup empty web folder
web_folder = tmp_path / 'plots'
def test_backup_empty_folder(tmp_path):
web_folder = tmp_path / "plots"
web_folder.mkdir()
backup_folder = tmp_path / 'backups'
backup_folder = tmp_path / "backups"
backup_folder.mkdir()
# Patch the module variables
monkeypatch.setattr(backup, 'WEB_FOLDER', web_folder)
monkeypatch.setattr(backup, 'BACKUP_FOLDER', backup_folder)
assert create_backup(web_folder, backup_folder) is True
# Call the backup function
backup.create_backup()
# Check that backup was created (empty)
today = datetime.date.today().strftime('%Y%m%d')
backup_name = f'backup-{today}.zip'
backup_path = backup_folder / backup_name
today = datetime.date.today().strftime("%Y%m%d")
backup_path = backup_folder / f"backup-{today}.zip"
assert backup_path.exists()
with zipfile.ZipFile(backup_path, 'r') as z:
with zipfile.ZipFile(backup_path, "r") as z:
assert len(z.namelist()) == 0
# Cleanup
backup_path.unlink()
def test_backup_nonexistent_web_folder(tmp_path, monkeypatch):
# Setup nonexistent web folder
web_folder = tmp_path / 'nonexistent_plots'
backup_folder = tmp_path / 'backups'
def test_backup_nonexistent_web_folder(tmp_path):
web_folder = tmp_path / "nonexistent_plots"
backup_folder = tmp_path / "backups"
backup_folder.mkdir()
# Patch the module variables
monkeypatch.setattr(backup, 'WEB_FOLDER', web_folder)
monkeypatch.setattr(backup, 'BACKUP_FOLDER', backup_folder)
assert create_backup(web_folder, backup_folder) is True
# Call the backup function
backup.create_backup()
# Check that backup was created (empty since source doesn't exist)
today = datetime.date.today().strftime('%Y%m%d')
backup_name = f'backup-{today}.zip'
backup_path = backup_folder / backup_name
today = datetime.date.today().strftime("%Y%m%d")
backup_path = backup_folder / f"backup-{today}.zip"
assert backup_path.exists()
with zipfile.ZipFile(backup_path, 'r') as z:
with zipfile.ZipFile(backup_path, "r") as z:
assert len(z.namelist()) == 0
# Cleanup
backup_path.unlink()
@patch('datetime.date')
def test_backup_with_custom_date(mock_date, tmp_path, monkeypatch):
# Mock date to return a specific date
@patch("datetime.date")
def test_backup_with_custom_date(mock_date, tmp_path):
mock_date.today.return_value.strftime.return_value = "20230908"
# Setup fake web folder
web_folder = tmp_path / 'plots'
web_folder = tmp_path / "plots"
web_folder.mkdir()
(web_folder / 'file1.txt').write_text('test')
backup_folder = tmp_path / 'backups'
(web_folder / "file1.txt").write_text("test")
backup_folder = tmp_path / "backups"
backup_folder.mkdir()
# Patch the module variables
monkeypatch.setattr(backup, 'WEB_FOLDER', web_folder)
monkeypatch.setattr(backup, 'BACKUP_FOLDER', backup_folder)
assert create_backup(web_folder, backup_folder) is True
# Call the backup function
backup.create_backup()
# Check that backup was created with custom date
backup_path = backup_folder / "backup-20230908.zip"
assert backup_path.exists()
# Cleanup
backup_path.unlink()
def test_backup_folder_creation(tmp_path, monkeypatch):
# Setup fake web folder
web_folder = tmp_path / 'plots'
def test_backup_folder_creation(tmp_path):
web_folder = tmp_path / "plots"
web_folder.mkdir()
(web_folder / 'file1.txt').write_text('test')
# Don't create backup folder - let function create it
backup_folder = tmp_path / 'new_backups'
(web_folder / "file1.txt").write_text("test")
# Patch the module variables
monkeypatch.setattr(backup, 'WEB_FOLDER', web_folder)
monkeypatch.setattr(backup, 'BACKUP_FOLDER', backup_folder)
# Don't create backup folder - let create_backup() create it
backup_folder = tmp_path / "new_backups"
assert create_backup(web_folder, backup_folder) is True
# Call the backup function
backup.create_backup()
# Check that backup folder was created
assert backup_folder.exists()
assert backup_folder.is_dir()
# Check that backup file was created
today = datetime.date.today().strftime('%Y%m%d')
backup_name = f'backup-{today}.zip'
backup_path = backup_folder / backup_name
assert backup_path.exists()
today = datetime.date.today().strftime("%Y%m%d")
backup_path = backup_folder / f"backup-{today}.zip"
assert backup_path.exists()
+155 -163
View File
@@ -1,195 +1,187 @@
from pathlib import Path
import tempfile
import pytest
import yaml
from utils import config
from gallery.config import ConfigManager, GalleryConfig, GalleryDefaults, GallerySource
def test_path_config():
pc = config.PathConfig(work_dir='/tmp', web_folder='/web')
assert pc.work_dir == '/tmp'
assert pc.web_folder == '/web'
def test_gallery_source_path_conversion():
source = GallerySource(name="test", path="/test/path")
assert source.name == "test"
assert source.path == Path("/test/path")
def test_gallery_config():
gc = config.GalleryConfig(
plot_root='plots', png_dpi=150, backup_folder='backups')
assert gc.plot_root == 'plots'
assert gc.png_dpi == 150
assert gc.backup_folder == 'backups'
def test_gallery_defaults():
defaults = GalleryDefaults()
assert defaults.png_dpi == 400
assert defaults.plot_root == "gallery"
assert defaults.cache_enabled is True
assert defaults.inherit_from_parent is True
def test_ui_config():
ui = config.UIConfig(max_recent_plots=10, search_debounce_ms=200)
assert ui.max_recent_plots == 10
assert ui.search_debounce_ms == 200
def test_gallery_config_defaults():
cfg = GalleryConfig(web_folder="/web")
assert cfg.web_folder == Path("/web")
assert cfg.sources == []
assert cfg.png_dpi == GalleryDefaults.png_dpi
assert cfg.plot_root == GalleryDefaults.plot_root
assert cfg.cache_enabled == GalleryDefaults.cache_enabled
assert cfg.inherit_from_parent == GalleryDefaults.inherit_from_parent
assert cfg.backup_folder == ""
def test_metadata_config_defaults():
mc = config.MetadataConfig()
assert mc.cache_enabled is True
assert mc.inherit_from_parent is True
assert mc.supported_formats == ['.yaml', '.yml', '.json']
def test_gallery_config_sources_from_dicts():
cfg = GalleryConfig(web_folder="/web", sources=[{"name": "s1", "path": "/p1"}])
assert len(cfg.sources) == 1
assert isinstance(cfg.sources[0], GallerySource)
assert cfg.sources[0].name == "s1"
assert cfg.sources[0].path == Path("/p1")
def test_metadata_config_custom():
mc = config.MetadataConfig(
cache_enabled=False,
inherit_from_parent=False,
supported_formats=['.yaml']
)
assert mc.cache_enabled is False
assert mc.inherit_from_parent is False
assert mc.supported_formats == ['.yaml']
def test_gallery_config_sources_invalid_type():
with pytest.raises(TypeError):
GalleryConfig(web_folder="/web", sources=[123])
def test_gallery_item():
item = config.GalleryItem(name="test", path=Path("/test/path"))
assert item.name == "test"
assert item.path == Path("/test/path")
def test_gallery_config_from_yaml(tmp_path):
yaml_content = {
"paths": {"web_folder": "/test/web"},
"gallery": {"plot_root": "test_plots", "png_dpi": 200, "backup_folder": "test_backups"},
"metadata": {"cache_enabled": False, "inherit_from_parent": False},
"sources": [
{"name": "source1", "path": "/path1"},
{"name": "source2", "path": "/path2"},
],
}
yaml_file = tmp_path / "test_config.yaml"
with yaml_file.open("w") as f:
yaml.dump(yaml_content, f)
cfg = GalleryConfig.from_yaml(yaml_file)
assert cfg.web_folder == Path("/test/web")
assert cfg.plot_root == "test_plots"
assert cfg.png_dpi == 200
assert cfg.backup_folder == "test_backups"
assert cfg.cache_enabled is False
assert cfg.inherit_from_parent is False
assert len(cfg.sources) == 2
assert cfg.sources[0].name == "source1"
assert cfg.sources[0].path == Path("/path1")
def test_config_creation():
paths = config.PathConfig(work_dir="/work", web_folder="/web")
gallery = config.GalleryConfig(
plot_root="plots", png_dpi=300, backup_folder="backups")
ui = config.UIConfig(max_recent_plots=5, search_debounce_ms=100)
metadata = config.MetadataConfig()
def test_gallery_config_from_yaml_missing_file():
with pytest.raises(FileNotFoundError):
GalleryConfig.from_yaml("/nonexistent/file.yaml")
cfg = config.Config(
paths=paths,
gallery=gallery,
ui=ui,
metadata=metadata
)
assert cfg.paths == paths
assert cfg.gallery == gallery
assert cfg.ui == ui
assert cfg.metadata == metadata
def test_gallery_config_from_yaml_missing_web_folder(tmp_path):
yaml_file = tmp_path / "no_web_folder.yaml"
yaml_file.write_text(yaml.dump({"gallery": {"plot_root": "plots"}}))
with pytest.raises(ValueError):
GalleryConfig.from_yaml(yaml_file)
def test_gallery_config_from_yaml_partial_data(tmp_path):
yaml_content = {"paths": {"web_folder": "/min_web"}}
yaml_file = tmp_path / "minimal_config.yaml"
yaml_file.write_text(yaml.dump(yaml_content))
cfg = GalleryConfig.from_yaml(yaml_file)
assert cfg.web_folder == Path("/min_web")
assert cfg.png_dpi == GalleryDefaults.png_dpi
assert cfg.plot_root == GalleryDefaults.plot_root
assert cfg.cache_enabled is True
assert cfg.sources == []
def test_config_backward_compatibility_properties():
paths = config.PathConfig(work_dir="/work", web_folder="/web")
gallery = config.GalleryConfig(
plot_root="plots", png_dpi=300, backup_folder="backups")
ui = config.UIConfig(max_recent_plots=5, search_debounce_ms=100)
metadata = config.MetadataConfig()
cfg = config.Config(
paths=paths,
gallery=gallery,
ui=ui,
metadata=metadata
def test_gallery_config_to_yaml_round_trip(tmp_path):
cfg = GalleryConfig(
web_folder="/web",
sources=[{"name": "test", "path": "/test"}],
plot_root="plots",
png_dpi=300,
backup_folder="backups",
)
assert cfg.web_folder == "/web"
assert cfg.png_dpi == 300
assert cfg.plot_root == "plots"
assert cfg.backup_folder == "backups"
def test_config_from_yaml(tmp_path):
yaml_content = {
'paths': {
'work_dir': '/test/work',
'web_folder': '/test/web'
},
'gallery': {
'plot_root': 'test_plots',
'png_dpi': 200,
'backup_folder': 'test_backups'
},
'ui': {
'max_recent_plots': 15,
'search_debounce_ms': 300
},
'metadata': {
'cache_enabled': False,
'inherit_from_parent': False
},
'sources': [
{'name': 'source1', 'path': '/path1'},
{'name': 'source2', 'path': '/path2'}
]
}
yaml_file = tmp_path / 'test_config.yaml'
with yaml_file.open('w') as f:
yaml.dump(yaml_content, f)
cfg = config.Config.from_yaml(str(yaml_file))
assert cfg.paths.work_dir == '/test/work'
assert cfg.paths.web_folder == '/test/web'
assert cfg.gallery.plot_root == 'test_plots'
assert cfg.gallery.png_dpi == 200
assert cfg.ui.max_recent_plots == 15
assert cfg.metadata.cache_enabled is False
assert len(cfg.sources) == 2
assert cfg.sources[0].name == 'source1'
assert cfg.sources[0].path == Path('/path1')
def test_config_from_yaml_missing_file():
with pytest.raises(FileNotFoundError):
config.Config.from_yaml('/nonexistent/file.yaml')
def test_config_from_yaml_malformed():
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml',
delete=False) as f:
f.write('invalid: yaml: content: [')
f.flush()
with pytest.raises(yaml.YAMLError):
config.Config.from_yaml(f.name)
def test_config_to_yaml(tmp_path):
paths = config.PathConfig(work_dir="/work", web_folder="/web")
gallery = config.GalleryConfig(
plot_root="plots", png_dpi=300, backup_folder="backups")
ui = config.UIConfig(max_recent_plots=5, search_debounce_ms=100)
metadata = config.MetadataConfig()
sources = [config.GalleryItem(name="test", path=Path("/test"))]
cfg = config.Config(
paths=paths,
gallery=gallery,
ui=ui,
metadata=metadata,
sources=sources
)
yaml_file = tmp_path / 'output_config.yaml'
cfg.to_yaml(str(yaml_file))
yaml_file = tmp_path / "output_config.yaml"
cfg.to_yaml(yaml_file)
assert yaml_file.exists()
# Just verify the file contains expected content (no Path parsing)
content = yaml_file.read_text()
assert 'work_dir: /work' in content
assert 'png_dpi: 300' in content
assert 'name: test' in content
reloaded = GalleryConfig.from_yaml(yaml_file)
assert reloaded.web_folder == cfg.web_folder
assert reloaded.plot_root == cfg.plot_root
assert reloaded.png_dpi == cfg.png_dpi
assert reloaded.backup_folder == cfg.backup_folder
assert reloaded.sources[0].name == "test"
def test_config_from_yaml_partial_data(tmp_path):
# Test with minimal YAML data
yaml_content = {
'paths': {'work_dir': '/min', 'web_folder': '/min_web'},
'gallery': {'plot_root': 'min_plots', 'png_dpi': 100,
'backup_folder': 'min_backup'},
'ui': {'max_recent_plots': 3, 'search_debounce_ms': 50}
}
# ---------------------------------------------------------------------------
# ConfigManager
# ---------------------------------------------------------------------------
yaml_file = tmp_path / 'minimal_config.yaml'
with yaml_file.open('w') as f:
yaml.dump(yaml_content, f)
cfg = config.Config.from_yaml(str(yaml_file))
@pytest.fixture
def config_manager(tmp_path):
path = tmp_path / "config.yaml"
path.write_text(
yaml.dump(
{
"paths": {"web_folder": "/web"},
"gallery": {"plot_root": "gallery", "png_dpi": 400},
"sources": [{"name": "existing", "path": "/existing"}],
}
)
)
return ConfigManager(path)
# Should use defaults for metadata and empty sources
assert cfg.metadata.cache_enabled is True # default
assert cfg.sources == [] # default empty list
def test_config_manager_get(config_manager):
assert config_manager.get("gallery.png_dpi") == 400
def test_config_manager_get_missing_key(config_manager):
with pytest.raises(KeyError):
config_manager.get("gallery.nonexistent")
def test_config_manager_set(config_manager):
config_manager.set("gallery.png_dpi", "600")
assert config_manager.get("gallery.png_dpi") == 600
def test_config_manager_list_all(config_manager):
data = config_manager.list_all()
assert data["paths"]["web_folder"] == "/web"
def test_config_manager_add_source(config_manager):
config_manager.add_source("new_source", "/new/path")
sources = config_manager.list_sources()
assert {"name": "new_source", "path": "/new/path"} in sources
def test_config_manager_add_source_duplicate(config_manager):
with pytest.raises(ValueError):
config_manager.add_source("existing", "/other/path")
def test_config_manager_remove_source(config_manager):
config_manager.remove_source("existing")
assert config_manager.list_sources() == []
def test_config_manager_remove_source_not_found(config_manager):
with pytest.raises(KeyError):
config_manager.remove_source("nonexistent")
def test_config_manager_list_sources(config_manager):
sources = config_manager.list_sources()
assert sources == [{"name": "existing", "path": "/existing"}]
-138
View File
@@ -1,138 +0,0 @@
import pytest
import tempfile
import time
import os
from pathlib import Path
import subprocess
import sys
def test_python_version():
"""Test that Python 3.9+ is available."""
version = sys.version_info
assert version.major >= 3
assert version.minor >= 9
def test_required_modules():
"""Test that required Python modules are installed."""
try:
import jinja2 # noqa: F401
import yaml # noqa: F401
except ImportError as e:
pytest.fail(f"Required module not found: {e}")
def test_imagemagick_available():
"""Test that ImageMagick is installed and accessible."""
try:
result = subprocess.run(['convert', '-version'], capture_output=True, text=True, timeout=10)
assert result.returncode == 0
assert 'ImageMagick' in result.stdout
except (subprocess.TimeoutExpired, FileNotFoundError):
pytest.fail("ImageMagick not available or not working")
def test_format_file_size():
# Add current directory to path instead of /src
sys.path.insert(0, '.')
from gallery import format_file_size
assert format_file_size(0) == "0 B"
assert format_file_size(1024) == "1.0 KB"
assert format_file_size(1048576) == "1.0 MB"
assert format_file_size(1073741824) == "1.0 GB"
def test_needs_update():
sys.path.insert(0, '.')
from gallery import needs_update
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
source = temp_path / "source.txt"
target = temp_path / "target.txt"
source.write_text("test")
assert needs_update(source, target)
target.write_text("test")
time.sleep(0.1)
os.utime(target, (time.time(), time.time()))
assert not needs_update(source, target)
def test_metadata_loading():
sys.path.insert(0, '.')
from utils.metadata import load_metadata_file
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
yaml_file = temp_path / "test.yaml"
yaml_content = "title: Test\nauthor: Container Test\n"
yaml_file.write_text(yaml_content)
metadata = load_metadata_file(yaml_file)
assert metadata['title'] == 'Test'
assert metadata['author'] == 'Container Test'
def test_metadata_inheritance():
sys.path.insert(0, '.')
from utils.metadata import merge_metadata
parent = {'project': 'Test', 'version': '1.0'}
child = {'experiment': 'A', 'version': '1.1'}
merged = merge_metadata(parent, child)
assert merged['project'] == 'Test'
assert merged['experiment'] == 'A'
assert merged['version'] == '1.1'
def create_mock_pdf(path: Path):
path.write_text("%PDF-1.4\nMock PDF for testing")
def test_pdf_conversion(tmpdir):
sys.path.insert(0, '/src')
from gallery import convert_pdf_to_png, GalleryConfig
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
pdf_path = temp_path / "test.pdf"
create_mock_pdf(pdf_path)
try:
convert_pdf_to_png(pdf_path, GalleryConfig(tmpdir))
png_path = pdf_path.with_suffix('.png')
assert png_path.exists()
except subprocess.CalledProcessError:
pytest.skip("Mock PDF not processable by ImageMagick")
def create_test_structure(source_dir):
pdf_path = source_dir / "test_plot.pdf"
pdf_path.write_text("%PDF-1.4\nTest plot content")
metadata_path = source_dir / "metadata.yaml"
metadata_path.write_text("title: Container Test\nauthor: CI Pipeline\n")
def test_build_gallery(tmpdir):
sys.path.insert(0, '.')
from gallery import build_gallery, GalleryConfig
from unittest.mock import patch
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
source_dir = temp_path / "source"
web_dir = temp_path / "web"
source_dir.mkdir()
web_dir.mkdir()
create_test_structure(source_dir)
# Mock the PDF conversion and create the expected PNG file
def mock_convert_pdf_to_png(pdf_path):
png_path = pdf_path.with_suffix('.png')
png_path.write_text("Mock PNG content")
with patch('gallery.convert_pdf_to_png', side_effect=mock_convert_pdf_to_png):
try:
build_gallery(GalleryConfig(tmpdir), source_dir=source_dir, web_dir=web_dir)
html_file = web_dir / "index.html"
assert html_file.exists()
pdf_file = web_dir / "test_plot.pdf"
assert pdf_file.exists()
png_file = web_dir / "test_plot.png"
assert png_file.exists()
except Exception as e:
pytest.skip(f"Gallery generation failed: {e}")
+49 -60
View File
@@ -1,17 +1,17 @@
import os
from datetime import datetime
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
from unittest.mock import patch
from gallery import (
convert_pdf_to_png,
needs_update,
build_gallery,
calculate_directory_stats,
format_file_size,
convert_pdf_to_png,
datetime_from_timestamp,
strftime_filter
format_file_size,
needs_update,
strftime_filter,
)
from datetime import datetime
def test_format_file_size():
@@ -46,6 +46,7 @@ def test_needs_update_target_newer(tmp_path):
# Make target newer by modifying its timestamp
import time
time.sleep(0.1)
target.touch()
@@ -58,9 +59,10 @@ def test_needs_update_source_newer(tmp_path):
target.write_text("test")
import time
time.sleep(0.1)
source.write_text("test")
# Force different modification times with 31+ second buffer
target_time = target.stat().st_mtime
source_time = target_time + 40 # 40 seconds newer (> 30 second buffer)
@@ -69,28 +71,24 @@ def test_needs_update_source_newer(tmp_path):
assert needs_update(source, target) is True
@patch('subprocess.run')
def test_convert_pdf_to_png_success(mock_run, tmp_path):
@patch("gallery.utils.processing._convert_pdf_pymupdf")
def test_convert_pdf_to_png_success(mock_convert, tmp_path):
from gallery import GalleryConfig
pdf_path = tmp_path / "test.pdf"
png_path = tmp_path / "test.png"
pdf_path.write_text("fake pdf")
mock_run.return_value = MagicMock(returncode=0)
config = GalleryConfig(tmp_path)
convert_pdf_to_png(pdf_path, config)
convert_pdf_to_png(pdf_path, GalleryConfig(tmp_path))
mock_run.assert_called_once()
call_args = mock_run.call_args[0][0]
assert call_args[0] == "convert"
assert str(pdf_path) in call_args
assert str(png_path) in call_args
mock_convert.assert_called_once_with(pdf_path, png_path, config.png_dpi)
@patch('subprocess.run')
def test_convert_pdf_to_png_already_exists_newer(mock_run, tmp_path):
@patch("gallery.utils.processing._convert_pdf_pymupdf")
def test_convert_pdf_to_png_already_exists_newer(mock_convert, tmp_path):
from gallery import GalleryConfig
pdf_path = tmp_path / "test.pdf"
png_path = tmp_path / "test.png"
@@ -104,27 +102,27 @@ def test_convert_pdf_to_png_already_exists_newer(mock_run, tmp_path):
convert_pdf_to_png(pdf_path, GalleryConfig(tmp_path))
# Should not call subprocess since PNG is newer
mock_run.assert_not_called()
# Should not convert since PNG is newer
mock_convert.assert_not_called()
@patch('subprocess.run')
def test_convert_pdf_to_png_pdf_newer(mock_run, tmp_path):
@patch("gallery.utils.processing._convert_pdf_pymupdf")
def test_convert_pdf_to_png_pdf_newer(mock_convert, tmp_path):
from gallery import GalleryConfig
pdf_path = tmp_path / "test.pdf"
png_path = tmp_path / "test.png"
png_path.write_text("fake png")
import time
time.sleep(0.1)
pdf_path.write_text("fake pdf")
mock_run.return_value = MagicMock(returncode=0)
convert_pdf_to_png(pdf_path, GalleryConfig(tmp_path))
# Should call subprocess since PDF is newer
mock_run.assert_called_once()
# Should convert since PDF is newer
mock_convert.assert_called_once()
def test_calculate_directory_stats_empty(tmp_path):
@@ -181,24 +179,17 @@ def test_strftime_filter():
assert formatted == "2023-09-08 14:30"
@patch('gallery.builder.render_gallery_page')
@patch('gallery.utils.metadata.save_metadata_cache')
@patch('gallery.utils.metadata.resolve_metadata_for_plot')
@patch('gallery.utils.metadata.merge_metadata')
@patch('gallery.utils.metadata.load_folder_metadata')
@patch('gallery.utils.processing.convert_pdf_to_png')
@patch('shutil.copy2')
@patch("gallery.builder.save_metadata_cache")
@patch("gallery.utils.processing.resolve_metadata_for_plot")
@patch("gallery.builder.merge_metadata")
@patch("gallery.builder.load_folder_metadata")
@patch("gallery.utils.processing.convert_pdf_to_png")
@patch("shutil.copy2")
def test_build_gallery_basic(
mock_copy,
mock_convert,
mock_load_folder,
mock_merge,
mock_resolve,
mock_save_cache,
mock_template,
tmp_path
mock_copy, mock_convert, mock_load_folder, mock_merge, mock_resolve, mock_save_cache, tmp_path
):
from gallery import GalleryConfig
from gallery import GalleryConfig, get_template
source_dir = tmp_path / "source"
web_dir = tmp_path / "web"
source_dir.mkdir()
@@ -214,29 +205,24 @@ def test_build_gallery_basic(
mock_load_folder.return_value = {"folder": "metadata"}
mock_merge.return_value = {"merged": "metadata"}
mock_resolve.return_value = {"plot": "metadata"}
mock_template.render.return_value = "<html>test</html>"
build_gallery(GalleryConfig(tmp_path), source_dir, web_dir)
config = GalleryConfig(tmp_path)
build_gallery(config, source_dir, web_dir, template=get_template())
# Verify mocks were called
mock_convert.assert_called_once_with(pdf_file)
mock_convert.assert_called_once_with(pdf_file, config=config)
mock_copy.assert_called() # Should be called for PDF
mock_save_cache.assert_called_once()
# Check HTML file was created
# Check HTML file was created by the real render_gallery_page
html_file = web_dir / "index.html"
assert html_file.exists()
@patch('gallery.builder.render_gallery_page')
@patch('gallery.utils.metadata.save_metadata_cache')
@patch('gallery.utils.metadata.load_folder_metadata')
def test_build_gallery_with_subdirs(
mock_load_folder,
mock_save_cache,
mock_template,
tmp_path
):
@patch("gallery.builder.render_gallery_page")
@patch("gallery.builder.save_metadata_cache")
@patch("gallery.builder.load_folder_metadata")
def test_build_gallery_with_subdirs(mock_load_folder, mock_save_cache, mock_template, tmp_path):
source_dir = tmp_path / "source"
web_dir = tmp_path / "web"
source_dir.mkdir()
@@ -248,8 +234,9 @@ def test_build_gallery_with_subdirs(
mock_load_folder.return_value = {}
mock_template.render.return_value = "<html>test</html>"
from gallery import GalleryConfig
build_gallery(config=GalleryConfig(tmp_path), source_dir=source_dir, web_dir=web_dir)
# Check subdirectory was created in web
@@ -258,8 +245,8 @@ def test_build_gallery_with_subdirs(
assert web_subdir.is_dir()
@patch('gallery.utils.processing.needs_update')
@patch('shutil.copy2')
@patch("gallery.utils.processing.needs_update")
@patch("shutil.copy2")
def test_build_gallery_skip_up_to_date(mock_copy, mock_needs_update, tmp_path):
source_dir = tmp_path / "source"
web_dir = tmp_path / "web"
@@ -282,8 +269,10 @@ def test_build_gallery_skip_up_to_date(mock_copy, mock_needs_update, tmp_path):
mock_needs_update.return_value = False
from gallery import get_template
get_template()
from gallery import GalleryConfig
build_gallery(GalleryConfig(tmp_path), source_dir, web_dir)
# copy2 should not be called since files are up to date
+49 -52
View File
@@ -1,79 +1,81 @@
import json
import pytest
from utils import metadata
import yaml
from gallery.utils import metadata
def test_load_metadata_file_yaml(tmp_path):
data = {'a': 1, 'b': 'test'}
yaml_path = tmp_path / 'meta.yaml'
data = {"a": 1, "b": "test"}
yaml_path = tmp_path / "meta.yaml"
yaml_path.write_text(yaml.dump(data))
result = metadata.load_metadata_file(yaml_path)
assert result == data
def test_load_metadata_file_json(tmp_path):
data = {'x': 42, 'y': 'hello'}
json_path = tmp_path / 'meta.json'
data = {"x": 42, "y": "hello"}
json_path = tmp_path / "meta.json"
json_path.write_text(json.dumps(data))
result = metadata.load_metadata_file(json_path)
assert result == data
def test_load_metadata_file_missing(tmp_path):
missing_path = tmp_path / 'nope.yaml'
missing_path = tmp_path / "nope.yaml"
result = metadata.load_metadata_file(missing_path)
assert result == {}
def test_load_metadata_file_yml_extension(tmp_path):
data = {'test': 'yml_format'}
yml_path = tmp_path / 'meta.yml'
data = {"test": "yml_format"}
yml_path = tmp_path / "meta.yml"
yml_path.write_text(yaml.dump(data))
result = metadata.load_metadata_file(yml_path)
assert result == data
def test_load_metadata_file_unknown_format(tmp_path):
txt_path = tmp_path / 'meta.txt'
txt_path.write_text('some text')
txt_path = tmp_path / "meta.txt"
txt_path.write_text("some text")
result = metadata.load_metadata_file(txt_path)
assert result == {}
def test_load_metadata_file_malformed_yaml(tmp_path):
yaml_path = tmp_path / 'bad.yaml'
yaml_path.write_text('invalid: yaml: content: [')
yaml_path = tmp_path / "bad.yaml"
yaml_path.write_text("invalid: yaml: content: [")
with pytest.raises(yaml.YAMLError):
metadata.load_metadata_file(yaml_path)
def test_load_metadata_file_malformed_json(tmp_path):
json_path = tmp_path / 'bad.json'
json_path = tmp_path / "bad.json"
json_path.write_text('{"invalid": json}')
with pytest.raises(json.JSONDecodeError):
metadata.load_metadata_file(json_path)
def test_load_folder_metadata_yaml(tmp_path):
data = {'folder': 'metadata'}
metadata_path = tmp_path / 'metadata.yaml'
data = {"folder": "metadata"}
metadata_path = tmp_path / "metadata.yaml"
metadata_path.write_text(yaml.dump(data))
result = metadata.load_folder_metadata(tmp_path)
assert result == data
def test_load_folder_metadata_yml(tmp_path):
data = {'folder': 'metadata_yml'}
metadata_path = tmp_path / 'metadata.yml'
data = {"folder": "metadata_yml"}
metadata_path = tmp_path / "metadata.yml"
metadata_path.write_text(yaml.dump(data))
result = metadata.load_folder_metadata(tmp_path)
assert result == data
def test_load_folder_metadata_json(tmp_path):
data = {'folder': 'metadata_json'}
metadata_path = tmp_path / 'metadata.json'
data = {"folder": "metadata_json"}
metadata_path = tmp_path / "metadata.json"
metadata_path.write_text(json.dumps(data))
result = metadata.load_folder_metadata(tmp_path)
assert result == data
@@ -85,21 +87,21 @@ def test_load_folder_metadata_missing(tmp_path):
def test_get_metadata_file_path_existing_yaml(tmp_path):
metadata_path = tmp_path / 'metadata.yaml'
metadata_path.write_text('test: data')
metadata_path = tmp_path / "metadata.yaml"
metadata_path.write_text("test: data")
result = metadata.get_metadata_file_path(tmp_path)
assert result == str(metadata_path)
def test_get_metadata_file_path_existing_yml(tmp_path):
metadata_path = tmp_path / 'metadata.yml'
metadata_path.write_text('test: data')
metadata_path = tmp_path / "metadata.yml"
metadata_path.write_text("test: data")
result = metadata.get_metadata_file_path(tmp_path)
assert result == str(metadata_path)
def test_get_metadata_file_path_existing_json(tmp_path):
metadata_path = tmp_path / 'metadata.json'
metadata_path = tmp_path / "metadata.json"
metadata_path.write_text('{"test": "data"}')
result = metadata.get_metadata_file_path(tmp_path)
assert result == str(metadata_path)
@@ -107,28 +109,27 @@ def test_get_metadata_file_path_existing_json(tmp_path):
def test_get_metadata_file_path_none_existing(tmp_path):
result = metadata.get_metadata_file_path(tmp_path)
expected = str(tmp_path / 'metadata.yaml')
expected = str(tmp_path / "metadata.yaml")
assert result == expected
def test_merge_metadata():
parent = {'project': 'Test', 'version': '1.0', 'author': 'Parent'}
child = {'experiment': 'A', 'version': '1.1'}
parent = {"project": "Test", "version": "1.0", "author": "Parent"}
child = {"experiment": "A", "version": "1.1"}
merged = metadata.merge_metadata(parent, child)
expected = {'project': 'Test', 'version': '1.1',
'author': 'Parent', 'experiment': 'A'}
expected = {"project": "Test", "version": "1.1", "author": "Parent", "experiment": "A"}
assert merged == expected
def test_merge_metadata_empty_parent():
parent = {}
child = {'experiment': 'A', 'version': '1.1'}
child = {"experiment": "A", "version": "1.1"}
merged = metadata.merge_metadata(parent, child)
assert merged == child
def test_merge_metadata_empty_child():
parent = {'project': 'Test', 'version': '1.0'}
parent = {"project": "Test", "version": "1.0"}
child = {}
merged = metadata.merge_metadata(parent, child)
assert merged == parent
@@ -136,35 +137,34 @@ def test_merge_metadata_empty_child():
def test_resolve_metadata_for_plot_with_specific_yaml(tmp_path):
# Create plot-specific metadata file
plot_path = tmp_path / 'test_plot.pdf'
plot_metadata_path = tmp_path / 'test_plot.yaml'
plot_metadata = {'specific': 'plot_data', 'override': 'plot_value'}
plot_path = tmp_path / "test_plot.pdf"
plot_metadata_path = tmp_path / "test_plot.yaml"
plot_metadata = {"specific": "plot_data", "override": "plot_value"}
plot_metadata_path.write_text(yaml.dump(plot_metadata))
inherited = {'general': 'data', 'override': 'inherited_value'}
inherited = {"general": "data", "override": "inherited_value"}
result = metadata.resolve_metadata_for_plot(plot_path, inherited)
expected = {'general': 'data',
'override': 'plot_value', 'specific': 'plot_data'}
expected = {"general": "data", "override": "plot_value", "specific": "plot_data"}
assert result == expected
def test_resolve_metadata_for_plot_with_specific_json(tmp_path):
plot_path = tmp_path / 'test_plot.pdf'
plot_metadata_path = tmp_path / 'test_plot.json'
plot_metadata = {'specific': 'plot_data_json'}
plot_path = tmp_path / "test_plot.pdf"
plot_metadata_path = tmp_path / "test_plot.json"
plot_metadata = {"specific": "plot_data_json"}
plot_metadata_path.write_text(json.dumps(plot_metadata))
inherited = {'general': 'data'}
inherited = {"general": "data"}
result = metadata.resolve_metadata_for_plot(plot_path, inherited)
expected = {'general': 'data', 'specific': 'plot_data_json'}
expected = {"general": "data", "specific": "plot_data_json"}
assert result == expected
def test_resolve_metadata_for_plot_no_specific(tmp_path):
plot_path = tmp_path / 'test_plot.pdf'
inherited = {'general': 'data', 'inherited': 'value'}
plot_path = tmp_path / "test_plot.pdf"
inherited = {"general": "data", "inherited": "value"}
result = metadata.resolve_metadata_for_plot(plot_path, inherited)
# Should return copy of inherited metadata
@@ -173,17 +173,14 @@ def test_resolve_metadata_for_plot_no_specific(tmp_path):
def test_save_metadata_cache(tmp_path):
cache_data = {
'plot1': {'title': 'Plot 1', 'author': 'Test'},
'plot2': {'title': 'Plot 2', 'experiment': 'B'}
}
cache_data = {"plot1": {"title": "Plot 1", "author": "Test"}, "plot2": {"title": "Plot 2", "experiment": "B"}}
metadata.save_metadata_cache(tmp_path, cache_data)
cache_file = tmp_path / 'meta_cache.json'
cache_file = tmp_path / "meta_cache.json"
assert cache_file.exists()
with cache_file.open('r') as f:
with cache_file.open("r") as f:
loaded_data = json.load(f)
assert loaded_data == cache_data
@@ -193,10 +190,10 @@ def test_save_metadata_cache_empty(tmp_path):
cache_data = {}
metadata.save_metadata_cache(tmp_path, cache_data)
cache_file = tmp_path / 'meta_cache.json'
cache_file = tmp_path / "meta_cache.json"
assert cache_file.exists()
with cache_file.open('r') as f:
with cache_file.open("r") as f:
loaded_data = json.load(f)
assert loaded_data == {}
+38 -37
View File
@@ -6,86 +6,87 @@ This script validates metadata files in the gallery source directories,
checking for proper YAML/JSON syntax and common field validation.
"""
import sys
import json
import yaml
import sys
from pathlib import Path
from typing import Dict, Any, List
from typing import List
import yaml
def validate_metadata_file(file_path: Path) -> tuple[bool, List[str]]:
"""
Validate a single metadata file.
Args:
file_path: Path to the metadata file
Returns:
Tuple of (is_valid, error_messages)
"""
errors = []
if not file_path.exists():
errors.append(f"File does not exist: {file_path}")
return False, errors
try:
with file_path.open('r', encoding='utf-8') as f:
with file_path.open("r", encoding="utf-8") as f:
suffix_lower = file_path.suffix.lower()
if suffix_lower in ['.yaml', '.yml']:
if suffix_lower in [".yaml", ".yml"]:
data = yaml.safe_load(f)
elif suffix_lower == '.json':
elif suffix_lower == ".json":
data = json.load(f)
else:
errors.append(f"Unsupported file format: {file_path}")
return False, errors
if data is None:
errors.append(f"Empty metadata file: {file_path}")
return False, errors
# Basic validation
if not isinstance(data, dict):
errors.append(f"Metadata must be a dictionary: {file_path}")
return False, errors
# Check for common issues
if 'title' in data and not isinstance(data['title'], str):
if "title" in data and not isinstance(data["title"], str):
errors.append(f"Title must be a string: {file_path}")
if 'tags' in data and not isinstance(data['tags'], list):
if "tags" in data and not isinstance(data["tags"], list):
errors.append(f"Tags must be a list: {file_path}")
if 'author' in data and not isinstance(data['author'], dict):
if "author" in data and not isinstance(data["author"], dict):
errors.append(f"Author must be a dictionary: {file_path}")
except (yaml.YAMLError, json.JSONDecodeError) as e:
errors.append(f"Parse error in {file_path}: {e}")
return False, errors
except Exception as e:
errors.append(f"Unexpected error reading {file_path}: {e}")
return False, errors
return len(errors) == 0, errors
def find_metadata_files(root_dir: Path) -> List[Path]:
"""
Find all metadata files in a directory tree.
Args:
root_dir: Root directory to search
Returns:
List of metadata file paths
"""
metadata_files = []
for pattern in ['**/*.yaml', '**/*.yml', '**/*.json']:
for pattern in ["**/*.yaml", "**/*.yml", "**/*.json"]:
for file_path in root_dir.glob(pattern):
if file_path.name.startswith('meta.') or file_path.stem != file_path.name:
if file_path.name.startswith("meta.") or file_path.stem != file_path.name:
metadata_files.append(file_path)
return metadata_files
@@ -94,32 +95,32 @@ def main():
if len(sys.argv) != 2:
print("Usage: python validate_metadata.py <directory>")
sys.exit(1)
root_dir = Path(sys.argv[1])
if not root_dir.exists():
print(f"Error: Directory does not exist: {root_dir}")
sys.exit(1)
if not root_dir.is_dir():
print(f"Error: Not a directory: {root_dir}")
sys.exit(1)
print(f"Validating metadata files in: {root_dir}")
print("-" * 50)
metadata_files = find_metadata_files(root_dir)
if not metadata_files:
print("No metadata files found.")
return
total_files = len(metadata_files)
valid_files = 0
for file_path in metadata_files:
is_valid, errors = validate_metadata_file(file_path)
if is_valid:
print(f"{file_path.relative_to(root_dir)}")
valid_files += 1
@@ -127,10 +128,10 @@ def main():
print(f"{file_path.relative_to(root_dir)}")
for error in errors:
print(f" - {error}")
print("-" * 50)
print(f"Summary: {valid_files}/{total_files} files valid")
if valid_files != total_files:
sys.exit(1)