Async loading and use of temp file
Some checks failed
Python CI / test (push) Successful in 1m21s
Lint / lint (push) Successful in 1m48s
Tests / test (3.10) (push) Failing after 2m16s
Tests / test (3.11) (push) Failing after 1m30s
Tests / test (3.9) (push) Failing after 2m13s

This commit is contained in:
Duncan Tourolle 2025-11-21 21:13:37 +01:00
parent 1b0e6f9ed1
commit 950aa6bee9
6 changed files with 632 additions and 43 deletions

View File

@ -0,0 +1,251 @@
"""
Async project loader for pyPhotoAlbum
Loads projects asynchronously with progress updates to prevent UI freezing.
"""
import os
import json
import zipfile
import tempfile
from typing import Optional, Tuple
from pathlib import Path
from PyQt6.QtCore import QThread, pyqtSignal
from pyPhotoAlbum.project import Project
from pyPhotoAlbum.models import ImageData, set_asset_resolution_context
from pyPhotoAlbum.version_manager import (
CURRENT_DATA_VERSION,
check_version_compatibility,
VersionCompatibility,
DataMigration
)
class AsyncProjectLoader(QThread):
"""
Async worker thread for loading projects from ZIP files.
Signals:
progress_updated(int, int, str): Emitted with (current, total, message)
load_complete(Project): Emitted when loading succeeds
load_failed(str): Emitted when loading fails with error message
"""
progress_updated = pyqtSignal(int, int, str) # current, total, message
load_complete = pyqtSignal(object) # Project instance
load_failed = pyqtSignal(str) # error message
def __init__(self, zip_path: str, extract_to: Optional[str] = None):
super().__init__()
self.zip_path = zip_path
self.extract_to = extract_to
self._cancelled = False
def cancel(self):
"""Cancel the loading operation"""
self._cancelled = True
def run(self):
"""Run the async loading operation"""
try:
if not os.path.exists(self.zip_path):
self.load_failed.emit(f"ZIP file not found: {self.zip_path}")
return
if self._cancelled:
return
# Progress: Starting
self.progress_updated.emit(0, 100, "Preparing to load...")
# Track if we created a temp directory
temp_dir_obj = None
# Determine extraction directory
if self.extract_to is None:
zip_basename = os.path.splitext(os.path.basename(self.zip_path))[0]
temp_dir_obj = tempfile.TemporaryDirectory(prefix=f"pyPhotoAlbum_{zip_basename}_")
extract_to = temp_dir_obj.name
else:
os.makedirs(self.extract_to, exist_ok=True)
extract_to = self.extract_to
if self._cancelled:
return
# Progress: Extracting ZIP
self.progress_updated.emit(10, 100, "Extracting project files...")
# Extract ZIP contents with progress
with zipfile.ZipFile(self.zip_path, 'r') as zipf:
file_list = zipf.namelist()
total_files = len(file_list)
for i, filename in enumerate(file_list):
if self._cancelled:
return
zipf.extract(filename, extract_to)
# Update progress every 10 files or on last file
if i % 10 == 0 or i == total_files - 1:
progress = 10 + int((i / total_files) * 30) # 10-40%
self.progress_updated.emit(
progress, 100,
f"Extracting files... ({i + 1}/{total_files})"
)
if self._cancelled:
return
# Progress: Loading project data
self.progress_updated.emit(45, 100, "Loading project data...")
# Load project.json
project_json_path = os.path.join(extract_to, 'project.json')
if not os.path.exists(project_json_path):
self.load_failed.emit("Invalid project file: project.json not found")
return
with open(project_json_path, 'r') as f:
project_data = json.load(f)
if self._cancelled:
return
# Progress: Checking version
self.progress_updated.emit(55, 100, "Checking version compatibility...")
# Check version compatibility
file_version = project_data.get('data_version', project_data.get('serialization_version', '1.0'))
is_compatible, error_msg = check_version_compatibility(file_version, self.zip_path)
if not is_compatible:
self.load_failed.emit(error_msg)
return
# Apply migrations if needed
if VersionCompatibility.needs_migration(file_version):
self.progress_updated.emit(60, 100, f"Migrating from version {file_version}...")
try:
project_data = DataMigration.migrate(project_data, file_version, CURRENT_DATA_VERSION)
except Exception as e:
self.load_failed.emit(f"Migration failed: {str(e)}")
return
if self._cancelled:
return
# Progress: Creating project
self.progress_updated.emit(70, 100, "Creating project...")
# Create new project
project_name = project_data.get('name', 'Untitled Project')
project = Project(name=project_name, folder_path=extract_to)
# Deserialize project data
project.deserialize(project_data)
# Update folder path to extraction location
project.folder_path = extract_to
project.asset_manager.project_folder = extract_to
project.asset_manager.assets_folder = os.path.join(extract_to, "assets")
# Attach temporary directory to project (if we created one)
if temp_dir_obj is not None:
project._temp_dir = temp_dir_obj
if self._cancelled:
return
# Progress: Normalizing paths
self.progress_updated.emit(85, 100, "Normalizing asset paths...")
# Normalize asset paths
self._normalize_asset_paths(project, extract_to)
# Progress: Setting up asset resolution
self.progress_updated.emit(95, 100, "Setting up asset resolution...")
# Set asset resolution context
zip_directory = os.path.dirname(os.path.abspath(self.zip_path))
set_asset_resolution_context(extract_to, additional_search_paths=[zip_directory])
if self._cancelled:
return
# Progress: Complete
self.progress_updated.emit(100, 100, "Loading complete!")
# Emit success
self.load_complete.emit(project)
except Exception as e:
error_msg = f"Error loading project: {str(e)}"
self.load_failed.emit(error_msg)
def _normalize_asset_paths(self, project: Project, project_folder: str):
"""
Normalize asset paths in a loaded project to be relative to the project folder.
"""
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
if not os.path.isabs(original_path) and not original_path.startswith('./projects/'):
continue
# 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
continue
# Pattern 2: Absolute path - try to make it relative
if os.path.isabs(original_path):
try:
new_path = os.path.relpath(original_path, project_folder)
element.image_path = new_path
normalized_count += 1
except ValueError:
pass
if normalized_count > 0:
print(f"Normalized {normalized_count} asset paths")
def load_from_zip_async(zip_path: str, extract_to: Optional[str] = None,
progress_callback=None, complete_callback=None,
error_callback=None) -> AsyncProjectLoader:
"""
Load a project from a ZIP file asynchronously.
Args:
zip_path: Path to the ZIP file to load
extract_to: Optional directory to extract to. If None, uses a temporary directory.
progress_callback: Optional callback(current, total, message) for progress updates
complete_callback: Optional callback(project) when loading completes
error_callback: Optional callback(error_msg) when loading fails
Returns:
AsyncProjectLoader instance (already started)
"""
loader = AsyncProjectLoader(zip_path, extract_to)
if progress_callback:
loader.progress_updated.connect(progress_callback)
if complete_callback:
loader.load_complete.connect(complete_callback)
if error_callback:
loader.load_failed.connect(error_callback)
loader.start()
return loader

View File

@ -0,0 +1,184 @@
"""
Loading progress widget for pyPhotoAlbum
Displays loading progress in the lower-right corner of the window.
"""
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QProgressBar
from PyQt6.QtCore import Qt, QPropertyAnimation, QEasingCurve, pyqtProperty
from PyQt6.QtGui import QPalette, QColor
class LoadingWidget(QWidget):
"""
A widget that displays loading progress in the lower-right corner.
Features:
- Fade in/out animations
- Progress bar with percentage
- Status message display
- Compact, non-intrusive design
"""
def __init__(self, parent=None):
super().__init__(parent)
# Widget configuration
self.setWindowFlags(Qt.WindowType.ToolTip | Qt.WindowType.FramelessWindowHint)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, False)
self.setFixedSize(280, 80)
# Styling
self.setStyleSheet("""
QWidget {
background-color: rgba(50, 50, 50, 230);
border-radius: 8px;
border: 1px solid rgba(100, 100, 100, 180);
}
QLabel {
color: white;
background-color: transparent;
font-size: 11pt;
}
QProgressBar {
border: 1px solid rgba(80, 80, 80, 180);
border-radius: 4px;
background-color: rgba(30, 30, 30, 200);
text-align: center;
color: white;
font-size: 10pt;
}
QProgressBar::chunk {
background-color: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 rgba(70, 130, 180, 220),
stop:1 rgba(100, 160, 210, 220));
border-radius: 3px;
}
""")
# Layout
layout = QVBoxLayout()
layout.setContentsMargins(12, 10, 12, 10)
layout.setSpacing(8)
# Status label
self._status_label = QLabel("Loading...")
self._status_label.setAlignment(Qt.AlignmentFlag.AlignLeft)
layout.addWidget(self._status_label)
# Progress bar with percentage label
progress_layout = QHBoxLayout()
progress_layout.setSpacing(8)
self._progress_bar = QProgressBar()
self._progress_bar.setMinimum(0)
self._progress_bar.setMaximum(100)
self._progress_bar.setValue(0)
self._progress_bar.setTextVisible(True)
self._progress_bar.setFormat("%p%")
progress_layout.addWidget(self._progress_bar, 1)
layout.addLayout(progress_layout)
self.setLayout(layout)
# Animation for fade in/out
self._opacity = 1.0
self._fade_animation = QPropertyAnimation(self, b"opacity")
self._fade_animation.setDuration(300)
self._fade_animation.setEasingCurve(QEasingCurve.Type.InOutQuad)
# Initially hidden
self.hide()
@pyqtProperty(float)
def opacity(self):
"""Get opacity for animation"""
return self._opacity
@opacity.setter
def opacity(self, value):
"""Set opacity for animation"""
self._opacity = value
self.setWindowOpacity(value)
def show_loading(self, message: str = "Loading..."):
"""
Show the loading widget with a fade-in animation.
Args:
message: Initial status message
"""
self.set_status(message)
self.set_progress(0)
# Position in lower-right corner of parent
self._reposition()
# Fade in
self.show()
self._fade_animation.stop()
self._fade_animation.setStartValue(0.0)
self._fade_animation.setEndValue(1.0)
self._fade_animation.start()
def hide_loading(self):
"""Hide the loading widget with a fade-out animation."""
self._fade_animation.stop()
self._fade_animation.setStartValue(1.0)
self._fade_animation.setEndValue(0.0)
self._fade_animation.finished.connect(self.hide)
self._fade_animation.start()
def set_status(self, message: str):
"""
Update the status message.
Args:
message: Status message to display
"""
self._status_label.setText(message)
def set_progress(self, value: int, maximum: int = 100):
"""
Update the progress bar.
Args:
value: Current progress value
maximum: Maximum progress value (default: 100)
"""
self._progress_bar.setMaximum(maximum)
self._progress_bar.setValue(value)
def set_indeterminate(self, indeterminate: bool = True):
"""
Set the progress bar to indeterminate mode (busy indicator).
Args:
indeterminate: True for indeterminate, False for normal progress
"""
if indeterminate:
self._progress_bar.setMinimum(0)
self._progress_bar.setMaximum(0)
else:
self._progress_bar.setMinimum(0)
self._progress_bar.setMaximum(100)
def _reposition(self):
"""Position the widget in the lower-right corner of the parent."""
if self.parent():
parent_rect = self.parent().rect()
margin = 20
x = parent_rect.width() - self.width() - margin
y = parent_rect.height() - self.height() - margin
self.move(x, y)
def showEvent(self, event):
"""Handle show event to reposition."""
super().showEvent(event)
self._reposition()
def resizeParent(self):
"""Call this when parent is resized to reposition the widget."""
if self.isVisible():
self._reposition()

View File

@ -131,21 +131,27 @@ class MainWindow(
"""Register keyboard shortcuts from decorated methods"""
from PyQt6.QtGui import QShortcut, QKeySequence
from pyPhotoAlbum.ribbon_builder import get_keyboard_shortcuts
shortcuts = get_keyboard_shortcuts(self.__class__)
for shortcut_str, method_name in shortcuts.items():
if hasattr(self, method_name):
shortcut = QShortcut(QKeySequence(shortcut_str), self)
method = getattr(self, method_name)
shortcut.activated.connect(method)
print(f"Registered shortcut: {shortcut_str} -> {method_name}")
# Register additional Ctrl+Shift+Z shortcut for redo
if hasattr(self, 'redo'):
redo_shortcut = QShortcut(QKeySequence("Ctrl+Shift+Z"), self)
redo_shortcut.activated.connect(self.redo)
print("Registered shortcut: Ctrl+Shift+Z -> redo")
def resizeEvent(self, event):
"""Handle window resize to reposition loading widget"""
super().resizeEvent(event)
if hasattr(self, '_loading_widget'):
self._loading_widget.resizeParent()
def _add_sample_page(self):
"""Add a sample page with some elements for demonstration"""

View File

@ -2,9 +2,19 @@
File operations mixin for pyPhotoAlbum
"""
from PyQt6.QtWidgets import QFileDialog, QDialog, QVBoxLayout, QHBoxLayout, QLabel, QDoubleSpinBox, QSpinBox, QPushButton, QGroupBox, QRadioButton, QButtonGroup
from PyQt6.QtWidgets import (
QFileDialog, QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QDoubleSpinBox, QSpinBox, QPushButton, QGroupBox, QRadioButton,
QButtonGroup, QLineEdit, QTextEdit
)
from pyPhotoAlbum.decorators import ribbon_action, numerical_input
from pyPhotoAlbum.project import Project
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.async_project_loader import AsyncProjectLoader
from pyPhotoAlbum.loading_widget import LoadingWidget
from pyPhotoAlbum.project_serializer import save_to_zip
from pyPhotoAlbum.models import set_asset_resolution_context
from pyPhotoAlbum.version_manager import format_version_info, CURRENT_DATA_VERSION
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
class FileOperationsMixin:
@ -19,8 +29,6 @@ class FileOperationsMixin:
)
def new_project(self):
"""Create a new project with initial setup dialog"""
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QDoubleSpinBox, QSpinBox, QPushButton, QGroupBox, QLineEdit
# Create new project setup dialog
dialog = QDialog(self)
dialog.setWindowTitle("New Project Setup")
@ -153,7 +161,6 @@ class FileOperationsMixin:
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
@ -173,36 +180,71 @@ class FileOperationsMixin:
shortcut="Ctrl+O"
)
def open_project(self):
"""Open an existing project"""
from pyPhotoAlbum.project_serializer import load_from_zip
"""Open an existing project with async loading and progress bar"""
file_path, _ = QFileDialog.getOpenFileName(
self,
"Open Project",
"",
"pyPhotoAlbum Projects (*.ppz);;All Files (*)"
)
if file_path:
print(f"Opening project: {file_path}")
# Cleanup old project if it exists
if hasattr(self, 'project') and self.project:
self.project.cleanup()
# Load project from ZIP
project, error = load_from_zip(file_path)
# Create loading widget if not exists
if not hasattr(self, '_loading_widget'):
self._loading_widget = LoadingWidget(self)
if project:
self.project = project
self.current_page_index = 0 # Reset to first page
self.update_view()
self.show_status(f"Project opened: {file_path}")
print(f"Successfully loaded project: {project.name}")
else:
error_msg = f"Failed to open project: {error}"
self.show_status(error_msg)
print(error_msg)
# Show loading widget
self._loading_widget.show_loading("Opening project...")
# Create and configure async loader
self._project_loader = AsyncProjectLoader(file_path)
# Connect signals
self._project_loader.progress_updated.connect(self._on_load_progress)
self._project_loader.load_complete.connect(self._on_load_complete)
self._project_loader.load_failed.connect(self._on_load_failed)
# Start async loading
self._project_loader.start()
def _on_load_progress(self, current: int, total: int, message: str):
"""Handle loading progress updates"""
if hasattr(self, '_loading_widget'):
self._loading_widget.set_progress(current, total)
self._loading_widget.set_status(message)
def _on_load_complete(self, project):
"""Handle successful project load"""
# Cleanup old project if it exists
if hasattr(self, 'project') and self.project:
self.project.cleanup()
# Set new project
self.project = project
self.gl_widget.current_page_index = 0 # Reset to first page
# Hide loading widget
if hasattr(self, '_loading_widget'):
self._loading_widget.hide_loading()
# Update view (this will trigger progressive image loading)
self.update_view()
self.show_status(f"Project opened: {project.name}")
print(f"Successfully loaded project: {project.name}")
def _on_load_failed(self, error_msg: str):
"""Handle project load failure"""
# Hide loading widget
if hasattr(self, '_loading_widget'):
self._loading_widget.hide_loading()
error_msg = f"Failed to open project: {error_msg}"
self.show_status(error_msg)
self.show_error("Load Failed", error_msg)
print(error_msg)
@ribbon_action(
label="Save",
@ -213,8 +255,6 @@ class FileOperationsMixin:
)
def save_project(self):
"""Save the current project"""
from pyPhotoAlbum.project_serializer import save_to_zip
file_path, _ = QFileDialog.getSaveFileName(
self,
"Save Project",
@ -244,8 +284,6 @@ class FileOperationsMixin:
)
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()
@ -529,9 +567,6 @@ class FileOperationsMixin:
)
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)

View File

@ -491,14 +491,11 @@ class PDFExporter:
current_width, current_height = cropped_img.size
if current_width > target_width_px or current_height > target_height_px:
# Use LANCZOS resampling for high quality downsampling
cropped_img = cropped_img.resize((target_width_px, target_height_px),
cropped_img = cropped_img.resize((target_width_px, target_height_px),
Image.Resampling.LANCZOS)
# Apply rotation if needed
if image_element.rotation != 0:
# Rotate around center
cropped_img = cropped_img.rotate(-image_element.rotation, expand=True,
fillcolor=(255, 255, 255, 0))
# Note: Rotation is applied at the canvas level (below), not here
# to avoid double-rotation issues
# Save state for transformations
c.saveState()
@ -509,7 +506,7 @@ class PDFExporter:
center_x = x_pt + width_pt / 2
center_y = y_pt + height_pt / 2
c.translate(center_x, center_y)
c.rotate(image_element.rotation)
c.rotate(-image_element.rotation)
c.translate(-width_pt / 2, -height_pt / 2)
# Draw at origin after transformation
c.drawImage(ImageReader(cropped_img), 0, 0, width_pt, height_pt,

116
test_loading_widget.py Normal file
View File

@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""
Test script for the loading widget functionality
"""
import sys
import time
from pathlib import Path
from PyQt6.QtWidgets import QApplication, QMainWindow, QPushButton, QVBoxLayout, QWidget
from PyQt6.QtCore import QTimer
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent))
from pyPhotoAlbum.loading_widget import LoadingWidget
class TestWindow(QMainWindow):
"""Test window for loading widget"""
def __init__(self):
super().__init__()
self.setWindowTitle("Loading Widget Test")
self.resize(800, 600)
# Central widget
central = QWidget()
layout = QVBoxLayout()
# Test buttons
btn1 = QPushButton("Show Loading (Determinate)")
btn1.clicked.connect(self.test_determinate)
layout.addWidget(btn1)
btn2 = QPushButton("Show Loading (Indeterminate)")
btn2.clicked.connect(self.test_indeterminate)
layout.addWidget(btn2)
btn3 = QPushButton("Simulate File Loading")
btn3.clicked.connect(self.simulate_file_loading)
layout.addWidget(btn3)
central.setLayout(layout)
self.setCentralWidget(central)
# Create loading widget
self.loading_widget = LoadingWidget(self)
# Timer for progress simulation
self.timer = QTimer()
self.timer.timeout.connect(self.update_progress)
self.progress = 0
def test_determinate(self):
"""Test determinate progress"""
self.loading_widget.show_loading("Loading...")
self.loading_widget.set_indeterminate(False)
self.loading_widget.set_progress(50, 100)
# Auto hide after 3 seconds
QTimer.singleShot(3000, self.loading_widget.hide_loading)
def test_indeterminate(self):
"""Test indeterminate progress"""
self.loading_widget.show_loading("Processing...")
self.loading_widget.set_indeterminate(True)
# Auto hide after 3 seconds
QTimer.singleShot(3000, self.loading_widget.hide_loading)
def simulate_file_loading(self):
"""Simulate file loading with progress"""
self.progress = 0
self.loading_widget.show_loading("Extracting files...")
self.loading_widget.set_indeterminate(False)
self.timer.start(100) # Update every 100ms
def update_progress(self):
"""Update progress during simulation"""
self.progress += 5
if self.progress <= 40:
self.loading_widget.set_status(f"Extracting files... ({self.progress}%)")
elif self.progress <= 70:
self.loading_widget.set_status("Loading project data...")
elif self.progress <= 95:
self.loading_widget.set_status("Normalizing asset paths...")
else:
self.loading_widget.set_status("Loading complete!")
self.loading_widget.set_progress(self.progress, 100)
if self.progress >= 100:
self.timer.stop()
QTimer.singleShot(500, self.loading_widget.hide_loading)
def resizeEvent(self, event):
"""Handle resize"""
super().resizeEvent(event)
self.loading_widget.resizeParent()
def main():
"""Run test"""
app = QApplication(sys.argv)
window = TestWindow()
window.show()
print("Loading widget test window opened")
print("Click buttons to test different loading states")
sys.exit(app.exec())
if __name__ == "__main__":
main()