From 802fee1337e0cdc720b7ada6763794410350747b Mon Sep 17 00:00:00 2001 From: Kylian Schmidt Date: Mon, 8 Sep 2025 14:47:38 +0200 Subject: [PATCH] Add pytest and gitlab-ci for container --- .gitignore | 3 +- .gitlab-ci.yml | 223 ++-------------------------------- tests/__init__.py | 4 + tests/test_backup.py | 42 +++++++ tests/test_build_container.py | 0 tests/test_config.py | 27 ++++ tests/test_container.py | 150 +++++++++++++++++++++++ tests/test_metadata.py | 25 ++++ utils/config.py | 2 +- utils/metadata.py | 4 +- 10 files changed, 263 insertions(+), 217 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/test_backup.py create mode 100644 tests/test_build_container.py create mode 100644 tests/test_config.py create mode 100644 tests/test_container.py create mode 100644 tests/test_metadata.py diff --git a/.gitignore b/.gitignore index e6d7423..ad429e4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ **__pycache__** .vscode *.sif -*.ipynb \ No newline at end of file +*.ipynb +backups \ No newline at end of file diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 58b44cd..bf14635 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,82 +1,49 @@ # GitLab CI/CD Pipeline for Gallery Generator -# Tests container building and runs unit tests inside Apptainer +# Builds Apptainer container and runs tests inside it stages: - build - test - - deploy variables: CONTAINER_IMAGE: "gallery-generator.sif" APPTAINER_CACHE_DIR: "$CI_PROJECT_DIR/.apptainer-cache" - PIP_CACHE_DIR: "$CI_PROJECT_DIR/.pip-cache" # Cache to speed up builds cache: key: "$CI_COMMIT_REF_SLUG" paths: - .apptainer-cache/ - - .pip-cache/ # Build the Apptainer container build:container: stage: build image: ubuntu:22.04 before_script: - # Install Apptainer - apt-get update -qq - apt-get install -y wget - wget -O- http://neuro.debian.net/lists/jammy.us-ca.full | tee /etc/apt/sources.list.d/neurodebian.sources.list - apt-key adv --recv-keys --keyserver hkps://keyserver.ubuntu.com 0xA5D32F012649A5A9 - apt-get update -qq - apt-get install -y apptainer - # Create cache directory - mkdir -p $APPTAINER_CACHE_DIR script: - echo "Building Apptainer container..." - apptainer --version - apptainer build $CONTAINER_IMAGE Singularity.def - ls -lh $CONTAINER_IMAGE - - echo "Container built successfully" artifacts: paths: - $CONTAINER_IMAGE expire_in: 1 hour - only: - - main - - merge_requests - - develop -# Test container build process -test:container-build: +# Run tests inside the container +test:pytest: stage: test image: ubuntu:22.04 dependencies: - build:container before_script: - # Install Apptainer - - apt-get update -qq - - apt-get install -y wget - - wget -O- http://neuro.debian.net/lists/jammy.us-ca.full | tee /etc/apt/sources.list.d/neurodebian.sources.list - - apt-key adv --recv-keys --keyserver hkps://keyserver.ubuntu.com 0xA5D32F012649A5A9 - - apt-get update -qq - - apt-get install -y apptainer python3 - script: - - echo "Testing container build process..." - - python3 tests/test_build_container.py - only: - - main - - merge_requests - - develop - -# Run tests inside the container with coverage -test:unit-tests: - stage: test - image: ubuntu:22.04 - dependencies: - - build:container - before_script: - # Install Apptainer - apt-get update -qq - apt-get install -y wget - wget -O- http://neuro.debian.net/lists/jammy.us-ca.full | tee /etc/apt/sources.list.d/neurodebian.sources.list @@ -84,31 +51,18 @@ test:unit-tests: - apt-get update -qq - apt-get install -y apptainer script: - - echo "Running unit tests inside container..." - - apptainer --version - - ls -la $CONTAINER_IMAGE - # Test that container works - - apptainer exec $CONTAINER_IMAGE python3 --version - - apptainer exec $CONTAINER_IMAGE pip list - # Run the container test suite - - apptainer exec $CONTAINER_IMAGE python3 /src/tests/test_container.py + - echo "Running pytest inside container..." + - apptainer exec $CONTAINER_IMAGE pytest /src/tests/ -v artifacts: - reports: - junit: test-results.xml when: always - only: - - main - - merge_requests - - develop -# Run automated coverage testing +# Run tests with coverage test:coverage: stage: test image: ubuntu:22.04 dependencies: - build:container before_script: - # Install Apptainer - apt-get update -qq - apt-get install -y wget - wget -O- http://neuro.debian.net/lists/jammy.us-ca.full | tee /etc/apt/sources.list.d/neurodebian.sources.list @@ -116,15 +70,10 @@ test:coverage: - apt-get update -qq - apt-get install -y apptainer script: - - echo "Running automated coverage analysis..." - - apptainer --version - - ls -la $CONTAINER_IMAGE - # Run automated coverage testing - - apptainer exec $CONTAINER_IMAGE python3 /src/tests/run_coverage.py - # Extract coverage report from container - - apptainer exec $CONTAINER_IMAGE cat /src/coverage.xml > coverage.xml || true - - apptainer exec $CONTAINER_IMAGE ls -la /src/coverage_html_report/ || true - coverage: '/TOTAL.+?(\d+\.\d+)%/' + - echo "Running coverage analysis inside container..." + - apptainer exec $CONTAINER_IMAGE pytest /src/tests/ --cov=/src --cov-report=xml --cov-report=term + - apptainer exec $CONTAINER_IMAGE cat /src/coverage.xml > coverage.xml || echo "No coverage.xml found" + coverage: '/TOTAL.*\s+(\d+%)$/' artifacts: reports: coverage_report: @@ -133,155 +82,3 @@ test:coverage: paths: - coverage.xml expire_in: 30 days - only: - - main - - merge_requests - - develop - -# Test gallery generation functionality -test:gallery-generation: - stage: test - image: ubuntu:22.04 - dependencies: - - build:container - before_script: - # Install Apptainer - - apt-get update -qq - - apt-get install -y wget - - wget -O- http://neuro.debian.net/lists/jammy.us-ca.full | tee /etc/apt/sources.list.d/neurodebian.sources.list - - apt-key adv --recv-keys --keyserver hkps://keyserver.ubuntu.com 0xA5D32F012649A5A9 - - apt-get update -qq - - apt-get install -y apptainer - script: - - echo "Testing gallery generation..." - # Create test data - - mkdir -p test_input - - echo "%PDF-1.4" > test_input/test.pdf - - echo "title: Test Gallery" > test_input/metadata.yaml - # Test the main script - - apptainer exec $CONTAINER_IMAGE python3 /src/generate_gallery.py --help || true - - echo "Gallery generation test completed" - only: - - main - - merge_requests - - develop - -# Performance and integration tests -test:performance: - stage: test - image: ubuntu:22.04 - dependencies: - - build:container - before_script: - - apt-get update -qq - - apt-get install -y wget time - - wget -O- http://neuro.debian.net/lists/jammy.us-ca.full | tee /etc/apt/sources.list.d/neurodebian.sources.list - - apt-key adv --recv-keys --keyserver hkps://keyserver.ubuntu.com 0xA5D32F012649A5A9 - - apt-get update -qq - - apt-get install -y apptainer - script: - - echo "Running performance tests..." - # Test container startup time - - time apptainer exec $CONTAINER_IMAGE python3 -c "print('Container startup test')" - # Test memory usage - - apptainer exec $CONTAINER_IMAGE python3 -c " - import psutil; - print(f'Memory usage: {psutil.virtual_memory().percent}%')" - - echo "Performance tests completed" - only: - - main - - merge_requests - allow_failure: true - -# Security scan (optional) -test:security: - stage: test - image: ubuntu:22.04 - dependencies: - - build:container - before_script: - - apt-get update -qq - - apt-get install -y wget - - wget -O- http://neuro.debian.net/lists/jammy.us-ca.full | tee /etc/apt/sources.list.d/neurodebian.sources.list - - apt-key adv --recv-keys --keyserver hkps://keyserver.ubuntu.com 0xA5D32F012649A5A9 - - apt-get update -qq - - apt-get install -y apptainer - script: - - echo "Running basic security checks..." - # Check for known vulnerabilities in base image - - apptainer exec $CONTAINER_IMAGE python3 -c " - import sys; - print(f'Python version: {sys.version}'); - import subprocess; - result = subprocess.run(['pip', 'list'], capture_output=True, text=True); - print('Installed packages:'); - print(result.stdout)" - - echo "Security scan completed" - only: - - main - - merge_requests - allow_failure: true - -# Documentation and examples -test:documentation: - stage: test - image: python:3.11-slim - script: - - echo "Validating documentation..." - - python3 -c " - import pathlib; - docs = ['README.md', 'tests/README.md', 'Singularity.def']; - for doc in docs: - if pathlib.Path(doc).exists(): - print(f'✅ {doc} exists'); - else: - print(f'❌ {doc} missing')" - - echo "Documentation validation completed" - only: - - main - - merge_requests - -# Deploy (if needed) -deploy:registry: - stage: deploy - image: ubuntu:22.04 - dependencies: - - build:container - before_script: - - apt-get update -qq - - apt-get install -y wget - - wget -O- http://neuro.debian.net/lists/jammy.us-ca.full | tee /etc/apt/sources.list.d/neurodebian.sources.list - - apt-key adv --recv-keys --keyserver hkps://keyserver.ubuntu.com 0xA5D32F012649A5A9 - - apt-get update -qq - - apt-get install -y apptainer - script: - - echo "Deploying container to registry (placeholder)..." - - echo "Container size:" - - ls -lh $CONTAINER_IMAGE - - echo "Container would be pushed to registry here" - # Actual deployment would push to a container registry - # - apptainer push $CONTAINER_IMAGE oras://registry.example.com/gallery-generator:latest - only: - - main - when: manual - -# Job for creating releases -create:release: - stage: deploy - image: ubuntu:22.04 - dependencies: - - build:container - script: - - echo "Creating release artifacts..." - - mkdir -p release/ - - cp $CONTAINER_IMAGE release/ - - cp Singularity.def release/ - - cp tests/test_container.py release/ - - echo "Release artifacts created" - artifacts: - paths: - - release/ - expire_in: 1 week - only: - - tags - when: manual diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..2889ef3 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,4 @@ +import sys + + +sys.path.append("..") diff --git a/tests/test_backup.py b/tests/test_backup.py new file mode 100644 index 0000000..7044a01 --- /dev/null +++ b/tests/test_backup.py @@ -0,0 +1,42 @@ +import zipfile +import datetime +from utils import backup + + +def test_backup_creates_zip(tmp_path, monkeypatch): + # Setup fake web folder + 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' + backup_folder.mkdir() + + # Patch the module variables + monkeypatch.setattr(backup, 'WEB_FOLDER', web_folder) + monkeypatch.setattr(backup, 'BACKUP_FOLDER', backup_folder) + + # Manually execute the backup logic + today = datetime.date.today().strftime('%Y%m%d') + backup_name = f'backup-{today}.zip' + backup_path = backup_folder / backup_name + + # Remove if exists + if backup_path.exists(): + backup_path.unlink() + + # Create backup manually using the backup module's logic + with zipfile.ZipFile(backup_path, "w", zipfile.ZIP_DEFLATED) as zipf: + for path in web_folder.rglob("*"): + if path.is_file(): + arcname = path.relative_to(web_folder.parent) + zipf.write(path, arcname) + + assert backup_path.exists() + 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() diff --git a/tests/test_build_container.py b/tests/test_build_container.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..2343c20 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,27 @@ +from utils import config + + +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_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_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_metadata_config_defaults(): + mc = config.MetadataConfig() + assert mc.cache_enabled is True + assert mc.inherit_from_parent is True diff --git a/tests/test_container.py b/tests/test_container.py new file mode 100644 index 0000000..8aacee4 --- /dev/null +++ b/tests/test_container.py @@ -0,0 +1,150 @@ +import pytest +import tempfile +import time +import os +from pathlib import Path +import subprocess +import sys + + +def test_python_version(): + """Test that Python 3.11+ is available.""" + version = sys.version_info + assert version.major >= 3 + assert version.minor >= 11 + + +def test_required_modules(): + """Test that required Python modules are installed.""" + try: + import jinja2 # type: ignore + import yaml # type: ignore + 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_working_directory(): + """Test that the source code is available.""" + expected_files = ['generate_gallery.py', + 'config.yaml', 'utils/'] + for file_path in expected_files: + # Check current directory instead of /src + path = Path('.') / file_path + assert path.exists(), f"Missing: {file_path}" + + +def test_format_file_size(): + # Add current directory to path instead of /src + sys.path.insert(0, '.') + from generate_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 generate_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(): + sys.path.insert(0, '/src') + from generate_gallery import convert_pdf_to_png + 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) + 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(): + sys.path.insert(0, '.') + from generate_gallery import build_gallery + 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('generate_gallery.convert_pdf_to_png', + side_effect=mock_convert_pdf_to_png): + try: + build_gallery(source_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.fail(f"Gallery generation failed: {e}") diff --git a/tests/test_metadata.py b/tests/test_metadata.py new file mode 100644 index 0000000..7a768d6 --- /dev/null +++ b/tests/test_metadata.py @@ -0,0 +1,25 @@ +import json +from utils import metadata +import yaml + + +def test_load_metadata_file_yaml(tmp_path): + 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' + 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' + result = metadata.load_metadata_file(missing_path) + assert result == {} \ No newline at end of file diff --git a/utils/config.py b/utils/config.py index fc010cc..f2a906a 100644 --- a/utils/config.py +++ b/utils/config.py @@ -54,7 +54,7 @@ class GalleryItem: class Config: """ Main configuration class that aggregates all gallery settings. - + Provides backward compatibility properties and methods for loading configuration from YAML files. """ diff --git a/utils/metadata.py b/utils/metadata.py index 00bf445..0fc56b2 100644 --- a/utils/metadata.py +++ b/utils/metadata.py @@ -80,12 +80,12 @@ def get_metadata_file_path(folder_path: Path) -> str: """ # Preferred order: YAML first, then JSON preferred_files = ['metadata.yaml', 'metadata.yml', 'metadata.json'] - + for filename in preferred_files: metadata_path = folder_path / filename if metadata_path.exists(): return str(metadata_path) - + # If no file exists, suggest metadata.yaml (preferred format) return str(folder_path / 'metadata.yaml')