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:
@@ -1,4 +1,3 @@
|
||||
import sys
|
||||
|
||||
|
||||
sys.path.append("..")
|
||||
|
||||
+76
-153
@@ -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
@@ -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"}]
|
||||
|
||||
@@ -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}")
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user