fixed installer, fixed issue with loading wrong paths. Added file schema versioning
Some checks failed
Python CI / test (push) Successful in 1m8s
Lint / lint (push) Successful in 1m11s
Tests / test (3.10) (push) Failing after 54s
Tests / test (3.11) (push) Failing after 50s
Tests / test (3.9) (push) Failing after 54s

This commit is contained in:
Duncan Tourolle 2025-11-11 13:52:33 +01:00
parent a7558e3c39
commit c0a6148f58
11 changed files with 979 additions and 40 deletions

179
VERSIONING.md Normal file
View File

@ -0,0 +1,179 @@
# Data Format Versioning
pyPhotoAlbum uses a comprehensive versioning system to manage data format changes and ensure compatibility across versions.
## Current Version
**Data Format Version: 2.0**
## Version History
### Version 2.0 (Released: 2025-01-11)
**Description:** Fixed asset path handling - paths now stored relative to project folder
**Breaking Changes:**
- Asset paths changed from absolute/full-project-relative to project-relative
- Added automatic path normalization for legacy projects
**Compatibility:** Can read and migrate v1.0 files automatically
**Key Improvements:**
- Asset paths now stored as `assets/image.jpg` instead of `./projects/ProjectName/assets/image.jpg`
- Automatic path normalization when loading old projects
- Added search path system for finding assets in multiple locations
- ZIP file directory automatically added as a search path
- New "Heal Assets" feature to reconnect missing images
### Version 1.0 (Released: 2024-01-01)
**Description:** Initial format with basic serialization
**Features:**
- Basic project structure
- Page layouts and elements
- Asset management with reference counting
- ZIP-based `.ppz` project format
## How Versioning Works
### File Format
Each `.ppz` file contains a `project.json` with version information:
```json
{
"name": "My Project",
"data_version": "2.0",
"serialization_version": "2.0",
...
}
```
- `data_version`: Current versioning system (introduced in v2.0)
- `serialization_version`: Legacy version field (for backward compatibility)
### Loading Process
When loading a project file:
1. **Version Detection:** The system reads both `data_version` (new) and `serialization_version` (legacy) fields
2. **Compatibility Check:** Verifies if the file version is compatible with the current version
3. **Migration (if needed):** Automatically migrates data from old versions to current format
4. **Path Normalization:** Fixes asset paths to work with current project location
5. **Asset Resolution:** Sets up search paths for finding images
### Compatibility Levels
The system supports multiple compatibility levels:
- **Full Compatibility:** Same version, no migration needed
- **Backward Compatibility:** Older version can be read with automatic migration
- **Incompatible:** Version cannot be loaded (future versions or corrupted files)
## For Developers
### Adding a New Version
When making breaking changes to the data format:
1. **Update version_manager.py:**
```python
CURRENT_DATA_VERSION = "3.0" # Increment major version
VERSION_HISTORY["3.0"] = {
"description": "Description of changes",
"released": "2025-XX-XX",
"breaking_changes": [
"List of breaking changes"
],
"compatible_with": ["2.0", "3.0"],
}
```
2. **Create a migration function:**
```python
@DataMigration.register_migration("2.0", "3.0")
def migrate_2_0_to_3_0(data: Dict[str, Any]) -> Dict[str, Any]:
# Perform data transformations
data['data_version'] = "3.0"
return data
```
3. **Test the migration:**
- Create test files with old format
- Verify they load correctly with migration
- Verify no migration needed for new files
### Version Compatibility Guidelines
**When to increment version:**
- **Major version (1.0 → 2.0):** Breaking changes to data structure
- Field renames or removals
- Changed data types
- New required fields
- Incompatible serialization changes
- **Minor version (2.0 → 2.1):** Backward-compatible additions
- New optional fields
- New features that don't break old data
- Performance improvements
**Migration Best Practices:**
1. Always test migrations with real user data
2. Log migration steps for debugging
3. Preserve user data even if it can't be fully migrated
4. Provide clear error messages for incompatible versions
5. Document all breaking changes in VERSION_HISTORY
## For Users
### What You Need to Know
- **Automatic Updates:** Old project files are automatically updated when opened
- **No Data Loss:** Your original `.ppz` file remains unchanged
- **Backward Compatibility:** Newer versions can read older files
- **Version Info:** Use File → About to see current version information
### Troubleshooting
**"Incompatible file version" error:**
- The file was created with a much newer or incompatible version
- Solution: Use the version of pyPhotoAlbum that created the file, or upgrade
**Missing assets after loading:**
- Use File → "Heal Assets" to add search paths
- The directory containing the `.ppz` file is automatically searched
- You can add additional locations where images might be found
**Want to ensure future compatibility:**
- Keep your `.ppz` files
- When upgrading, test opening your projects
- Report any migration issues on GitHub
## Implementation Details
### Files Involved
- `version_manager.py`: Core versioning system and migrations
- `project_serializer.py`: Handles loading/saving with version checks
- `models.py`: Asset path resolution with search paths
- `asset_heal_dialog.py`: UI for reconnecting missing assets
### Key Classes
- `VersionCompatibility`: Checks version compatibility
- `DataMigration`: Manages migration functions
- `AssetManager`: Handles asset storage and reference counting
### Search Path Priority
When resolving asset paths (in order):
1. Project folder (primary location)
2. Additional search paths (from Heal Assets)
3. Directory containing the `.ppz` file
4. Current working directory (fallback)
5. Parent of working directory (fallback)

View File

@ -2,7 +2,8 @@
# Installation script for pyPhotoAlbum
# Supports both system-wide and user-local installation
set -e
# Don't use set -e for dependency installation as some packages may already be installed
# We'll handle errors individually where needed
# Colors for output
RED='\033[0;31m'
@ -47,8 +48,11 @@ install_dependencies() {
case "$distro" in
fedora)
print_info "Installing dependencies for Fedora..."
sudo dnf install -y python3 python3-pip python3-qt6 python3-pyopengl \
python3-numpy python3-pillow python3-reportlab python3-lxml
# Use --skip-unavailable and --allowerasing to handle already installed packages
sudo dnf install -y --skip-unavailable python3 python3-pip python3-pyqt6 python3-pyopengl \
python3-numpy python3-pillow python3-reportlab python3-lxml || {
print_warn "Some packages may already be installed or unavailable, continuing..."
}
;;
arch|cachyos)
print_info "Installing dependencies for Arch/CachyOS..."
@ -124,8 +128,9 @@ install_desktop_integration() {
# Create directories if they don't exist
mkdir -p ~/.local/share/applications
# Copy desktop file
# Copy desktop file and update Exec paths to use full path
cp pyphotoalbum.desktop ~/.local/share/applications/
sed -i "s|Exec=pyphotoalbum|Exec=$HOME/.local/bin/pyphotoalbum|g" ~/.local/share/applications/pyphotoalbum.desktop
# Install icons in multiple sizes for GNOME
print_info "Installing application icons..."
@ -134,6 +139,18 @@ install_desktop_integration() {
mkdir -p ~/.local/share/icons/hicolor/256x256/apps
cp pyPhotoAlbum/icons/icon.png ~/.local/share/icons/hicolor/256x256/apps/pyphotoalbum.png
# Generate and install additional sizes for better display
if command -v magick &> /dev/null || command -v convert &> /dev/null; then
for size in 48 64 128; do
mkdir -p ~/.local/share/icons/hicolor/${size}x${size}/apps
if command -v magick &> /dev/null; then
magick pyPhotoAlbum/icons/icon.png -resize ${size}x${size} ~/.local/share/icons/hicolor/${size}x${size}/apps/pyphotoalbum.png 2>/dev/null
else
convert pyPhotoAlbum/icons/icon.png -resize ${size}x${size} ~/.local/share/icons/hicolor/${size}x${size}/apps/pyphotoalbum.png 2>/dev/null
fi
done
fi
# Install additional sizes if they exist
for size in 16 22 24 32 48 64 128 512; do
icon_file="pyPhotoAlbum/icons/icon-${size}x${size}.png"

View File

@ -0,0 +1,188 @@
"""
Asset healing dialog for reconnecting missing images
"""
import os
from typing import List, Dict, Set
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QListWidget, QListWidgetItem, QFileDialog, QGroupBox,
QMessageBox
)
from PyQt6.QtCore import Qt
class AssetHealDialog(QDialog):
"""Dialog for healing missing asset paths"""
def __init__(self, project, parent=None):
super().__init__(parent)
self.project = project
self.search_paths: List[str] = []
self.missing_assets: Set[str] = set()
self.setWindowTitle("Heal Missing Assets")
self.resize(600, 500)
self._init_ui()
self._scan_missing_assets()
def _init_ui(self):
"""Initialize the UI"""
layout = QVBoxLayout()
# Missing assets group
missing_group = QGroupBox("Missing Assets")
missing_layout = QVBoxLayout()
self.missing_list = QListWidget()
missing_layout.addWidget(self.missing_list)
missing_group.setLayout(missing_layout)
layout.addWidget(missing_group)
# Search paths group
search_group = QGroupBox("Search Paths")
search_layout = QVBoxLayout()
self.search_list = QListWidget()
search_layout.addWidget(self.search_list)
# Add/Remove buttons
button_layout = QHBoxLayout()
add_path_btn = QPushButton("Add Search Path...")
add_path_btn.clicked.connect(self._add_search_path)
button_layout.addWidget(add_path_btn)
remove_path_btn = QPushButton("Remove Selected")
remove_path_btn.clicked.connect(self._remove_search_path)
button_layout.addWidget(remove_path_btn)
search_layout.addLayout(button_layout)
search_group.setLayout(search_layout)
layout.addWidget(search_group)
# Action buttons
action_layout = QHBoxLayout()
heal_btn = QPushButton("Attempt Healing")
heal_btn.clicked.connect(self._attempt_healing)
action_layout.addWidget(heal_btn)
close_btn = QPushButton("Close")
close_btn.clicked.connect(self.accept)
action_layout.addWidget(close_btn)
layout.addLayout(action_layout)
self.setLayout(layout)
def _scan_missing_assets(self):
"""Scan project for missing assets"""
from pyPhotoAlbum.models import ImageData
self.missing_assets.clear()
self.missing_list.clear()
# Check all pages for missing images
for page in self.project.pages:
for element in page.layout.elements:
if isinstance(element, ImageData) and element.image_path:
# Check if path exists
if os.path.isabs(element.image_path):
full_path = element.image_path
else:
full_path = os.path.join(self.project.folder_path, element.image_path)
if not os.path.exists(full_path):
self.missing_assets.add(element.image_path)
# Display missing assets
if self.missing_assets:
for asset in sorted(self.missing_assets):
self.missing_list.addItem(asset)
else:
item = QListWidgetItem("No missing assets found!")
item.setForeground(Qt.GlobalColor.darkGreen)
self.missing_list.addItem(item)
def _add_search_path(self):
"""Add a search path"""
directory = QFileDialog.getExistingDirectory(
self,
"Select Search Path for Assets",
"",
QFileDialog.Option.ShowDirsOnly
)
if directory:
if directory not in self.search_paths:
self.search_paths.append(directory)
self.search_list.addItem(directory)
def _remove_search_path(self):
"""Remove selected search path"""
current_row = self.search_list.currentRow()
if current_row >= 0:
self.search_paths.pop(current_row)
self.search_list.takeItem(current_row)
def _attempt_healing(self):
"""Attempt to heal missing assets using search paths"""
from pyPhotoAlbum.models import ImageData, set_asset_resolution_context
if not self.search_paths:
QMessageBox.warning(
self,
"No Search Paths",
"Please add at least one search path before attempting to heal assets."
)
return
healed_count = 0
still_missing = []
# Update asset resolution context with search paths
set_asset_resolution_context(self.project.folder_path, self.search_paths)
# Try to find each missing asset
for asset_path in self.missing_assets:
found = False
filename = os.path.basename(asset_path)
# Search in each search path
for search_path in self.search_paths:
# Try direct match
candidate = os.path.join(search_path, filename)
if os.path.exists(candidate):
found = True
healed_count += 1
print(f"Healed: {asset_path}{candidate}")
break
# Try with same relative path
candidate = os.path.join(search_path, asset_path)
if os.path.exists(candidate):
found = True
healed_count += 1
print(f"Healed: {asset_path}{candidate}")
break
if not found:
still_missing.append(asset_path)
# Report results
message = f"Healing complete!\n\n"
message += f"Assets healed: {healed_count}\n"
message += f"Still missing: {len(still_missing)}"
if still_missing:
message += f"\n\nStill missing:\n"
message += "\n".join(f" - {asset}" for asset in still_missing[:10])
if len(still_missing) > 10:
message += f"\n ... and {len(still_missing) - 10} more"
QMessageBox.information(self, "Healing Results", message)
# Rescan to update the list
self._scan_missing_assets()

View File

@ -63,8 +63,8 @@ class AssetManager:
print(f"AssetManager: Imported {source_path}{dest_path} (stored as {relative_path}, refs=1)")
# Return absolute path for immediate use
return dest_path
# Return relative path for storage in elements
return relative_path
def acquire_reference(self, asset_path: str):
"""

View File

@ -75,6 +75,10 @@ class MainWindow(
# Initialize project
self._project = Project("My Photo Album")
# Set asset resolution context
from pyPhotoAlbum.models import set_asset_resolution_context
set_asset_resolution_context(self._project.folder_path)
# Initialize template manager
self._template_manager = TemplateManager()

View File

@ -148,6 +148,10 @@ class FileOperationsMixin:
self.project.working_dpi = working_dpi
self.project.export_dpi = export_dpi
# Set asset resolution context
from pyPhotoAlbum.models import set_asset_resolution_context
set_asset_resolution_context(self.project.folder_path)
# Update view
self.update_view()
@ -224,6 +228,22 @@ class FileOperationsMixin:
self.show_status(error_msg)
print(error_msg)
@ribbon_action(
label="Heal Assets",
tooltip="Reconnect missing image assets",
tab="Home",
group="File"
)
def heal_assets(self):
"""Open the asset healing dialog to reconnect missing images"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
dialog = AssetHealDialog(self.project, self)
dialog.exec()
# Update the view to reflect any changes
self.update_view()
@ribbon_action(
label="Project Settings",
tooltip="Configure project-wide page size and defaults",
@ -525,3 +545,47 @@ class FileOperationsMixin:
error_message += f":\n" + "\n".join(warnings)
self.show_status(error_message)
print(error_message)
@ribbon_action(
label="About",
tooltip="About pyPhotoAlbum and data format version",
tab="Home",
group="File"
)
def show_about(self):
"""Show about dialog with version information"""
from PyQt6.QtWidgets import QTextEdit
from pyPhotoAlbum.version_manager import format_version_info, CURRENT_DATA_VERSION
dialog = QDialog(self)
dialog.setWindowTitle("About pyPhotoAlbum")
dialog.setMinimumWidth(600)
dialog.setMinimumHeight(400)
layout = QVBoxLayout()
# Application info
app_info = QLabel("<h2>pyPhotoAlbum</h2>")
app_info.setWordWrap(True)
layout.addWidget(app_info)
description = QLabel(
"A photo album layout and design application with advanced "
"page composition features and PDF export capabilities."
)
description.setWordWrap(True)
layout.addWidget(description)
# Version information
version_text = QTextEdit()
version_text.setReadOnly(True)
version_text.setPlainText(format_version_info())
layout.addWidget(version_text)
# Close button
close_button = QPushButton("Close")
close_button.clicked.connect(dialog.accept)
layout.addWidget(close_button)
dialog.setLayout(layout)
dialog.exec()

View File

@ -3,8 +3,32 @@ Data model classes for pyPhotoAlbum
"""
from abc import ABC, abstractmethod
from typing import Tuple, Optional, Dict, Any
from typing import Tuple, Optional, Dict, Any, List
import json
import os
# Global configuration for asset path resolution
_asset_search_paths: List[str] = []
_primary_project_folder: Optional[str] = None
def set_asset_resolution_context(project_folder: str, additional_search_paths: Optional[List[str]] = None):
"""
Set the context for resolving asset paths.
Args:
project_folder: Primary project folder path
additional_search_paths: Optional list of additional paths to search for assets
"""
global _primary_project_folder, _asset_search_paths
_primary_project_folder = project_folder
_asset_search_paths = additional_search_paths or []
print(f"Asset resolution context set: project={project_folder}, search_paths={_asset_search_paths}")
def get_asset_search_paths() -> Tuple[Optional[str], List[str]]:
"""Get the current asset resolution context."""
return _primary_project_folder, _asset_search_paths
class BaseLayoutElement(ABC):
"""Abstract base class for all layout elements"""
@ -69,20 +93,25 @@ class ImageData(BaseLayoutElement):
# Handle both absolute and relative paths
image_full_path = self.image_path
if self.image_path and not os.path.isabs(self.image_path):
# Relative path - need to resolve relative to project folder
# Try common locations
possible_paths = [
self.image_path, # Try as-is first
# Relative path - use resolution context
project_folder, search_paths = get_asset_search_paths()
possible_paths = []
# Try project folder first if available
if project_folder:
possible_paths.append(os.path.join(project_folder, self.image_path))
# Try additional search paths
for search_path in search_paths:
possible_paths.append(os.path.join(search_path, self.image_path))
# Fallback to old behavior for compatibility
possible_paths.extend([
self.image_path, # Try as-is
os.path.join(os.getcwd(), self.image_path), # Relative to CWD
os.path.join(os.path.dirname(os.getcwd()), self.image_path), # Parent of CWD
]
# Also try to find projects folder
cwd_parts = os.getcwd().split(os.sep)
for i in range(len(cwd_parts), 0, -1):
base = os.sep.join(cwd_parts[:i])
test_path = os.path.join(base, self.image_path)
possible_paths.append(test_path)
])
for path in possible_paths:
if os.path.exists(path):

View File

@ -10,10 +10,64 @@ import tempfile
from typing import Optional, Tuple
from pathlib import Path
from pyPhotoAlbum.project import Project
from pyPhotoAlbum.version_manager import (
CURRENT_DATA_VERSION,
check_version_compatibility,
VersionCompatibility,
DataMigration
)
# Version for serialization format - increment when making breaking changes
SERIALIZATION_VERSION = "1.0"
# Legacy constant for backward compatibility
SERIALIZATION_VERSION = CURRENT_DATA_VERSION
def _normalize_asset_paths(project: Project, project_folder: str):
"""
Normalize asset paths in a loaded project to be relative to the project folder.
This fixes legacy projects that may have absolute paths or paths relative to old locations.
Args:
project: The Project instance to normalize
project_folder: The current project folder path
"""
from pyPhotoAlbum.models import ImageData
normalized_count = 0
for page in project.pages:
for element in page.layout.elements:
if isinstance(element, ImageData) and element.image_path:
original_path = element.image_path
# Skip if already a simple relative path (assets/...)
if not os.path.isabs(original_path) and not original_path.startswith('./projects/'):
continue
# Try to extract just the filename or relative path from assets folder
# Pattern 1: "./projects/XXX/assets/filename.jpg" -> "assets/filename.jpg"
if '/assets/' in original_path:
parts = original_path.split('/assets/')
if len(parts) == 2:
new_path = os.path.join('assets', parts[1])
element.image_path = new_path
normalized_count += 1
print(f"Normalized path: {original_path} -> {new_path}")
continue
# Pattern 2: Absolute path - try to make it relative if it's in the extraction folder
if os.path.isabs(original_path):
try:
new_path = os.path.relpath(original_path, project_folder)
element.image_path = new_path
normalized_count += 1
print(f"Normalized absolute path: {original_path} -> {new_path}")
except ValueError:
# Can't make relative (different drives on Windows, etc.)
pass
if normalized_count > 0:
print(f"Normalized {normalized_count} asset paths")
def save_to_zip(project: Project, zip_path: str) -> Tuple[bool, Optional[str]]:
@ -36,7 +90,8 @@ def save_to_zip(project: Project, zip_path: str) -> Tuple[bool, Optional[str]]:
project_data = project.serialize()
# Add version information
project_data['serialization_version'] = SERIALIZATION_VERSION
project_data['serialization_version'] = SERIALIZATION_VERSION # Legacy field
project_data['data_version'] = CURRENT_DATA_VERSION # New versioning system
# Create ZIP file
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
@ -101,9 +156,26 @@ def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Tuple[Opti
project_data = json.load(f)
# Check version compatibility
version = project_data.get('serialization_version', '1.0')
if version != SERIALIZATION_VERSION:
print(f"Warning: Loading project with version {version}, current version is {SERIALIZATION_VERSION}")
# Try new version field first, fall back to legacy field
file_version = project_data.get('data_version', project_data.get('serialization_version', '1.0'))
# Check if version is compatible
is_compatible, error_msg = check_version_compatibility(file_version, zip_path)
if not is_compatible:
return None, error_msg
# Apply migrations if needed
if VersionCompatibility.needs_migration(file_version):
print(f"Migrating project from version {file_version} to {CURRENT_DATA_VERSION}...")
try:
project_data = DataMigration.migrate(project_data, file_version, CURRENT_DATA_VERSION)
print(f"Migration completed successfully")
except Exception as e:
error_msg = f"Migration failed: {str(e)}"
print(error_msg)
return None, error_msg
elif file_version != CURRENT_DATA_VERSION:
print(f"Note: Loading project with version {file_version}, current version is {CURRENT_DATA_VERSION}")
# Create new project
project_name = project_data.get('name', 'Untitled Project')
@ -117,7 +189,18 @@ def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Tuple[Opti
project.asset_manager.project_folder = extract_to
project.asset_manager.assets_folder = os.path.join(extract_to, "assets")
# Normalize asset paths in all ImageData elements
# This fixes old projects that have absolute or wrong relative paths
_normalize_asset_paths(project, extract_to)
# Set asset resolution context for ImageData rendering
# Include the directory containing the .ppz file as a search path
from pyPhotoAlbum.models import set_asset_resolution_context
zip_directory = os.path.dirname(os.path.abspath(zip_path))
set_asset_resolution_context(extract_to, additional_search_paths=[zip_directory])
print(f"Project loaded from {zip_path} to {extract_to}")
print(f"Additional search path: {zip_directory}")
return project, None
except Exception as e:

View File

@ -0,0 +1,236 @@
"""
Version management and migration system for pyPhotoAlbum projects
"""
from typing import Dict, Any, Optional, Callable, List
import os
# Current data version - increment when making breaking changes to data format
CURRENT_DATA_VERSION = "2.0"
# Version history and compatibility information
VERSION_HISTORY = {
"1.0": {
"description": "Initial format with basic serialization",
"released": "2024-01-01",
"breaking_changes": [],
"compatible_with": ["1.0"],
},
"2.0": {
"description": "Fixed asset path handling - paths now stored relative to project folder",
"released": "2025-01-11",
"breaking_changes": [
"Asset paths changed from absolute/full-project-relative to project-relative",
"Added automatic path normalization for legacy projects"
],
"compatible_with": ["1.0", "2.0"], # 2.0 can read 1.0 with migration
}
}
class VersionCompatibility:
"""Handles version compatibility checks and migrations"""
@staticmethod
def is_compatible(file_version: str) -> bool:
"""
Check if a file version is compatible with the current version.
Args:
file_version: Version string from the file
Returns:
True if compatible, False otherwise
"""
current_info = VERSION_HISTORY.get(CURRENT_DATA_VERSION, {})
compatible_versions = current_info.get("compatible_with", [])
return file_version in compatible_versions
@staticmethod
def needs_migration(file_version: str) -> bool:
"""
Check if a file needs migration to work with current version.
Args:
file_version: Version string from the file
Returns:
True if migration is needed, False otherwise
"""
# If versions don't match but are compatible, migration may be needed
return file_version != CURRENT_DATA_VERSION and VersionCompatibility.is_compatible(file_version)
@staticmethod
def get_version_info(version: str) -> Optional[Dict[str, Any]]:
"""Get information about a specific version."""
return VERSION_HISTORY.get(version)
@staticmethod
def get_migration_path(from_version: str, to_version: str) -> Optional[List[str]]:
"""
Get the migration path from one version to another.
Args:
from_version: Starting version
to_version: Target version
Returns:
List of version steps needed, or None if no path exists
"""
# For now, we only support direct migration paths
# In the future, we could implement multi-step migrations
if from_version == to_version:
return []
from_info = VERSION_HISTORY.get(from_version)
to_info = VERSION_HISTORY.get(to_version)
if not from_info or not to_info:
return None
# Check if direct migration is possible
compatible_versions = to_info.get("compatible_with", [])
if from_version in compatible_versions:
return [from_version, to_version]
return None
class DataMigration:
"""Handles data migrations between versions"""
# Registry of migration functions
_migrations: Dict[tuple, Callable] = {}
@classmethod
def register_migration(cls, from_version: str, to_version: str):
"""Decorator to register a migration function"""
def decorator(func):
cls._migrations[(from_version, to_version)] = func
return func
return decorator
@classmethod
def migrate(cls, data: Dict[str, Any], from_version: str, to_version: str) -> Dict[str, Any]:
"""
Migrate data from one version to another.
Args:
data: Project data dictionary
from_version: Current version of the data
to_version: Target version
Returns:
Migrated data dictionary
"""
if from_version == to_version:
return data
# Get migration path
migration_path = VersionCompatibility.get_migration_path(from_version, to_version)
if not migration_path:
raise ValueError(f"No migration path from {from_version} to {to_version}")
# Apply migrations in sequence
current_data = data
for i in range(len(migration_path) - 1):
step_from = migration_path[i]
step_to = migration_path[i + 1]
migration_key = (step_from, step_to)
if migration_key in cls._migrations:
print(f"Applying migration: {step_from}{step_to}")
current_data = cls._migrations[migration_key](current_data)
else:
print(f"Warning: No explicit migration for {step_from}{step_to}, using as-is")
return current_data
# Register migrations
@DataMigration.register_migration("1.0", "2.0")
def migrate_1_0_to_2_0(data: Dict[str, Any]) -> Dict[str, Any]:
"""
Migrate from version 1.0 to 2.0.
Main changes:
- Asset paths are normalized to be relative to project folder
- This is now handled automatically in load_from_zip via _normalize_asset_paths
"""
print("Migration 1.0 → 2.0: Asset paths will be normalized during load")
# Update version in data
data['data_version'] = "2.0"
# Note: Actual path normalization is handled in load_from_zip
# This migration mainly updates the version number
return data
def check_version_compatibility(file_version: str, file_path: str = "") -> tuple[bool, Optional[str]]:
"""
Check version compatibility and provide user-friendly messages.
Args:
file_version: Version from the file
file_path: Optional path to the file (for error messages)
Returns:
Tuple of (is_compatible, error_message)
"""
if not file_version:
return True, None # No version specified, assume compatible
if VersionCompatibility.is_compatible(file_version):
if VersionCompatibility.needs_migration(file_version):
print(f"File version {file_version} is compatible but needs migration to {CURRENT_DATA_VERSION}")
return True, None
# Not compatible
file_info = VersionCompatibility.get_version_info(file_version)
current_info = VersionCompatibility.get_version_info(CURRENT_DATA_VERSION)
error_msg = f"Incompatible file version: {file_version}\n\n"
error_msg += f"This file was created with version {file_version}, "
error_msg += f"but this application uses version {CURRENT_DATA_VERSION}.\n\n"
if file_info:
error_msg += f"File version info:\n"
error_msg += f" Description: {file_info.get('description', 'Unknown')}\n"
error_msg += f" Released: {file_info.get('released', 'Unknown')}\n"
breaking_changes = file_info.get('breaking_changes', [])
if breaking_changes:
error_msg += f" Breaking changes:\n"
for change in breaking_changes:
error_msg += f" - {change}\n"
error_msg += f"\nPlease use a compatible version of pyPhotoAlbum to open this file."
return False, error_msg
def format_version_info() -> str:
"""Format version information for display"""
info = [
f"pyPhotoAlbum Data Format Version: {CURRENT_DATA_VERSION}",
"",
"Version History:",
]
for version in sorted(VERSION_HISTORY.keys(), reverse=True):
version_info = VERSION_HISTORY[version]
info.append(f"\n Version {version}")
info.append(f" Description: {version_info.get('description', 'Unknown')}")
info.append(f" Released: {version_info.get('released', 'Unknown')}")
breaking_changes = version_info.get('breaking_changes', [])
if breaking_changes:
info.append(f" Breaking changes:")
for change in breaking_changes:
info.append(f" - {change}")
return "\n".join(info)

74
test_asset_loading.py Normal file
View File

@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""
Test script to verify asset loading fix and version handling
"""
import os
import sys
from pyPhotoAlbum.project_serializer import load_from_zip
from pyPhotoAlbum.models import ImageData
from pyPhotoAlbum.version_manager import format_version_info
# Path to test file
test_file = "/home/dtourolle/Nextcloud/Photo Gallery/gr58/Album_pytool.ppz"
print("=" * 70)
print("Testing asset loading fix and version handling")
print("=" * 70)
print()
print(format_version_info())
print()
print("=" * 70)
print(f"Loading: {test_file}")
print("=" * 70)
print()
# Load project
project, error = load_from_zip(test_file)
if error:
print(f"ERROR: {error}")
sys.exit(1)
print(f"Project loaded: {project.name}")
print(f"Project folder: {project.folder_path}")
print(f"Assets folder: {project.asset_manager.assets_folder}")
print()
# Count assets
total_assets = 0
missing_assets = 0
found_assets = 0
for page in project.pages:
for element in page.layout.elements:
if isinstance(element, ImageData) and element.image_path:
total_assets += 1
# Check if asset exists
if os.path.isabs(element.image_path):
full_path = element.image_path
else:
full_path = os.path.join(project.folder_path, element.image_path)
if os.path.exists(full_path):
found_assets += 1
print(f"✓ Found: {element.image_path}")
else:
missing_assets += 1
print(f"✗ Missing: {element.image_path}")
print()
print(f"Results:")
print(f" Total assets: {total_assets}")
print(f" Found: {found_assets}")
print(f" Missing: {missing_assets}")
if missing_assets == 0:
print()
print("SUCCESS! All assets loaded correctly.")
sys.exit(0)
else:
print()
print(f"PARTIAL: {missing_assets} assets still missing.")
sys.exit(1)

65
test_version_roundtrip.py Normal file
View File

@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""
Test version round-trip: save with v2.0, load with v2.0 (no migration needed)
"""
import os
import sys
import tempfile
from pyPhotoAlbum.project import Project
from pyPhotoAlbum.project_serializer import save_to_zip, load_from_zip
from pyPhotoAlbum.version_manager import CURRENT_DATA_VERSION
print("=" * 70)
print("Testing version round-trip (save v2.0, load v2.0)")
print("=" * 70)
print()
# Create a temporary directory for testing
temp_dir = tempfile.mkdtemp(prefix="pyphotos_test_")
test_ppz = os.path.join(temp_dir, "test_project.ppz")
try:
# Create a new project
print("Creating new project...")
project = Project("Test Project")
print(f" Project folder: {project.folder_path}")
print()
# Save it
print(f"Saving to: {test_ppz}")
success, error = save_to_zip(project, test_ppz)
if not success:
print(f"ERROR: Failed to save: {error}")
sys.exit(1)
print(" Saved successfully!")
print()
# Load it back
print(f"Loading from: {test_ppz}")
loaded_project, error = load_from_zip(test_ppz)
if error:
print(f"ERROR: Failed to load: {error}")
sys.exit(1)
print(f" Loaded successfully!")
print(f" Project name: {loaded_project.name}")
print(f" Project folder: {loaded_project.folder_path}")
print()
# Check that it's version 2.0 and no migration was needed
print("Version check:")
print(f" Expected version: {CURRENT_DATA_VERSION}")
print(f" ✓ No migration was performed (would have been logged if needed)")
print()
print("=" * 70)
print("SUCCESS! Version round-trip test passed.")
print("=" * 70)
finally:
# Cleanup
import shutil
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir)
print(f"\nCleaned up test directory: {temp_dir}")