Coverage for pyPhotoAlbum/mixins/async_loading.py: 98%
112 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-20 12:55 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-20 12:55 +0000
1"""
2Async loading mixin for non-blocking image loading and PDF generation.
3"""
5import os
6from pathlib import Path
7from typing import TYPE_CHECKING, Optional, cast
8import logging
10from PyQt6.QtCore import QObject
11from PyQt6.QtWidgets import QProgressDialog
13from pyPhotoAlbum.async_backend import AsyncImageLoader, AsyncPDFGenerator, ImageCache, LoadPriority
15if TYPE_CHECKING:
16 from PyQt6.QtWidgets import QMainWindow
18logger = logging.getLogger(__name__)
21class AsyncLoadingMixin:
22 # Type hints for expected attributes from mixing class
23 _pdf_progress_dialog: Optional[QProgressDialog]
25 def update(self) -> None: # type: ignore[empty-body]
26 """Expected from QWidget"""
27 ...
29 def window(self) -> "QMainWindow": # type: ignore[empty-body]
30 """Expected from QWidget"""
31 ...
33 """
34 Mixin to add async loading capabilities to GLWidget.
36 Provides non-blocking image loading and PDF generation with
37 progressive updates and shared caching.
38 """
40 def _init_async_loading(self):
41 """Initialize async loading components."""
42 logger.info("Initializing async loading system...")
44 # Create shared image cache (512MB)
45 self.image_cache = ImageCache(max_memory_mb=512)
47 # Create async image loader
48 self.async_image_loader = AsyncImageLoader(cache=self.image_cache, max_workers=4)
49 self.async_image_loader.image_loaded.connect(self._on_image_loaded)
50 self.async_image_loader.load_failed.connect(self._on_image_load_failed)
51 self.async_image_loader.start()
53 # Create async PDF generator
54 self.async_pdf_generator = AsyncPDFGenerator(image_cache=self.image_cache, max_workers=2)
55 self.async_pdf_generator.progress_updated.connect(self._on_pdf_progress)
56 self.async_pdf_generator.export_complete.connect(self._on_pdf_complete)
57 self.async_pdf_generator.export_failed.connect(self._on_pdf_failed)
58 self.async_pdf_generator.start()
60 logger.info("Async loading system initialized")
62 def _cleanup_async_loading(self):
63 """Cleanup async loading components."""
64 logger.info("Cleaning up async loading system...")
66 if hasattr(self, "async_image_loader"):
67 self.async_image_loader.stop()
69 if hasattr(self, "async_pdf_generator"):
70 self.async_pdf_generator.stop()
72 if hasattr(self, "image_cache"):
73 self.image_cache.clear()
75 logger.info("Async loading system cleaned up")
77 def _on_image_loaded(self, path: Path, image, user_data):
78 """
79 Handle image loaded callback.
81 Args:
82 path: Path to loaded image
83 image: Loaded PIL Image
84 user_data: User data (ImageData element)
85 """
86 logger.debug(f"Image loaded callback: {path}")
88 if user_data and hasattr(user_data, "_on_async_image_loaded"):
89 user_data._on_async_image_loaded(image)
91 # Trigger re-render to show newly loaded image
92 self.update()
94 def _on_image_load_failed(self, path: Path, error_msg: str, user_data):
95 """
96 Handle image load failure.
98 Args:
99 path: Path that failed to load
100 error_msg: Error message
101 user_data: User data (ImageData element)
102 """
103 logger.warning(f"Image load failed: {path} - {error_msg}")
105 if user_data and hasattr(user_data, "_on_async_image_load_failed"):
106 user_data._on_async_image_load_failed(error_msg)
108 def _on_pdf_progress(self, current: int, total: int, message: str):
109 """
110 Handle PDF export progress updates.
112 Args:
113 current: Current progress (pages completed)
114 total: Total pages
115 message: Progress message
116 """
117 logger.debug(f"PDF progress: {current}/{total} - {message}")
119 # Update progress dialog if it exists
120 # Use local reference to avoid race condition
121 dialog = getattr(self, "_pdf_progress_dialog", None)
122 if dialog is not None:
123 dialog.setValue(current)
124 dialog.setLabelText(message)
126 def _on_pdf_complete(self, success: bool, warnings: list):
127 """
128 Handle PDF export completion.
130 Args:
131 success: Whether export succeeded
132 warnings: List of warning messages
133 """
134 logger.info(f"PDF export complete: success={success}, warnings={len(warnings)}")
136 # Close progress dialog
137 if hasattr(self, "_pdf_progress_dialog") and self._pdf_progress_dialog:
138 self._pdf_progress_dialog.close()
139 self._pdf_progress_dialog = None
141 # Show completion message
142 main_window = self.window()
143 if hasattr(main_window, "show_status"):
144 if success:
145 if warnings:
146 main_window.show_status(f"PDF exported successfully with {len(warnings)} warnings", 5000)
147 else:
148 main_window.show_status("PDF exported successfully", 3000)
149 else:
150 main_window.show_status("PDF export failed", 5000)
152 def _on_pdf_failed(self, error_msg: str):
153 """
154 Handle PDF export failure.
156 Args:
157 error_msg: Error message
158 """
159 logger.error(f"PDF export failed: {error_msg}")
161 # Close progress dialog
162 if hasattr(self, "_pdf_progress_dialog") and self._pdf_progress_dialog:
163 self._pdf_progress_dialog.close()
164 self._pdf_progress_dialog = None
166 # Show error message
167 main_window = self.window()
168 if hasattr(main_window, "show_status"):
169 main_window.show_status(f"PDF export failed: {error_msg}", 5000)
171 def request_image_load(self, image_data, priority: LoadPriority = LoadPriority.NORMAL):
172 """
173 Request async load for an ImageData element.
175 Args:
176 image_data: ImageData element to load
177 priority: Load priority level
178 """
179 if not hasattr(self, "async_image_loader"):
180 logger.warning("Async image loader not initialized")
181 return
183 if not image_data.image_path:
184 return
186 # Security: only load images from the assets folder
187 if not image_data.image_path.startswith("assets/"):
188 logger.warning(f"Skipping path not in assets folder (needs healing): {image_data.image_path}")
189 return
191 # Use ImageData's path resolution (delegates to project layer)
192 image_full_path = image_data.resolve_image_path()
193 if not image_full_path:
194 logger.warning(f"Image not found (needs healing): {image_data.image_path}")
195 return
197 # Calculate target size (max 2048px like original)
198 target_size = (2048, 2048) # Will be downsampled if larger
200 # Request load
201 self.async_image_loader.request_load(
202 Path(image_full_path),
203 priority=priority,
204 target_size=target_size,
205 user_data=image_data, # Pass element for callback
206 )
208 def export_pdf_async(self, project, output_path: str, export_dpi: int = 300):
209 """
210 Export PDF asynchronously without blocking UI.
212 Args:
213 project: Project to export
214 output_path: Output PDF file path
215 export_dpi: Export DPI (default 300)
216 """
217 if not hasattr(self, "async_pdf_generator"):
218 logger.warning("Async PDF generator not initialized")
219 return False
221 # Create progress dialog
222 from PyQt6.QtWidgets import QProgressDialog
223 from PyQt6.QtCore import Qt
225 total_pages = sum(1 if page.is_cover else (2 if page.is_double_spread else 1) for page in project.pages)
227 from PyQt6.QtWidgets import QWidget
229 self._pdf_progress_dialog = QProgressDialog("Exporting to PDF...", "Cancel", 0, total_pages, cast(QWidget, self))
230 self._pdf_progress_dialog.setWindowModality(Qt.WindowModality.WindowModal)
231 self._pdf_progress_dialog.setWindowTitle("PDF Export")
232 self._pdf_progress_dialog.canceled.connect(self._on_pdf_cancel)
233 self._pdf_progress_dialog.show()
235 # Start async export
236 return self.async_pdf_generator.export_pdf(project, output_path, export_dpi)
238 def _on_pdf_cancel(self):
239 """Handle PDF export cancellation."""
240 logger.info("User requested PDF export cancellation")
242 if hasattr(self, "async_pdf_generator"):
243 self.async_pdf_generator.cancel_export()
245 def get_async_stats(self) -> dict:
246 """Get async loading system statistics."""
247 stats = {}
249 if hasattr(self, "async_image_loader"):
250 stats["image_loader"] = self.async_image_loader.get_stats()
252 if hasattr(self, "async_pdf_generator"):
253 stats["pdf_generator"] = self.async_pdf_generator.get_stats()
255 return stats