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

1""" 

2Async loading mixin for non-blocking image loading and PDF generation. 

3""" 

4 

5import os 

6from pathlib import Path 

7from typing import TYPE_CHECKING, Optional, cast 

8import logging 

9 

10from PyQt6.QtCore import QObject 

11from PyQt6.QtWidgets import QProgressDialog 

12 

13from pyPhotoAlbum.async_backend import AsyncImageLoader, AsyncPDFGenerator, ImageCache, LoadPriority 

14 

15if TYPE_CHECKING: 

16 from PyQt6.QtWidgets import QMainWindow 

17 

18logger = logging.getLogger(__name__) 

19 

20 

21class AsyncLoadingMixin: 

22 # Type hints for expected attributes from mixing class 

23 _pdf_progress_dialog: Optional[QProgressDialog] 

24 

25 def update(self) -> None: # type: ignore[empty-body] 

26 """Expected from QWidget""" 

27 ... 

28 

29 def window(self) -> "QMainWindow": # type: ignore[empty-body] 

30 """Expected from QWidget""" 

31 ... 

32 

33 """ 

34 Mixin to add async loading capabilities to GLWidget. 

35 

36 Provides non-blocking image loading and PDF generation with 

37 progressive updates and shared caching. 

38 """ 

39 

40 def _init_async_loading(self): 

41 """Initialize async loading components.""" 

42 logger.info("Initializing async loading system...") 

43 

44 # Create shared image cache (512MB) 

45 self.image_cache = ImageCache(max_memory_mb=512) 

46 

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() 

52 

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() 

59 

60 logger.info("Async loading system initialized") 

61 

62 def _cleanup_async_loading(self): 

63 """Cleanup async loading components.""" 

64 logger.info("Cleaning up async loading system...") 

65 

66 if hasattr(self, "async_image_loader"): 

67 self.async_image_loader.stop() 

68 

69 if hasattr(self, "async_pdf_generator"): 

70 self.async_pdf_generator.stop() 

71 

72 if hasattr(self, "image_cache"): 

73 self.image_cache.clear() 

74 

75 logger.info("Async loading system cleaned up") 

76 

77 def _on_image_loaded(self, path: Path, image, user_data): 

78 """ 

79 Handle image loaded callback. 

80 

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}") 

87 

88 if user_data and hasattr(user_data, "_on_async_image_loaded"): 

89 user_data._on_async_image_loaded(image) 

90 

91 # Trigger re-render to show newly loaded image 

92 self.update() 

93 

94 def _on_image_load_failed(self, path: Path, error_msg: str, user_data): 

95 """ 

96 Handle image load failure. 

97 

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}") 

104 

105 if user_data and hasattr(user_data, "_on_async_image_load_failed"): 

106 user_data._on_async_image_load_failed(error_msg) 

107 

108 def _on_pdf_progress(self, current: int, total: int, message: str): 

109 """ 

110 Handle PDF export progress updates. 

111 

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}") 

118 

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) 

125 

126 def _on_pdf_complete(self, success: bool, warnings: list): 

127 """ 

128 Handle PDF export completion. 

129 

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)}") 

135 

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 

140 

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) 

151 

152 def _on_pdf_failed(self, error_msg: str): 

153 """ 

154 Handle PDF export failure. 

155 

156 Args: 

157 error_msg: Error message 

158 """ 

159 logger.error(f"PDF export failed: {error_msg}") 

160 

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 

165 

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) 

170 

171 def request_image_load(self, image_data, priority: LoadPriority = LoadPriority.NORMAL): 

172 """ 

173 Request async load for an ImageData element. 

174 

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 

182 

183 if not image_data.image_path: 

184 return 

185 

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 

190 

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 

196 

197 # Calculate target size (max 2048px like original) 

198 target_size = (2048, 2048) # Will be downsampled if larger 

199 

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 ) 

207 

208 def export_pdf_async(self, project, output_path: str, export_dpi: int = 300): 

209 """ 

210 Export PDF asynchronously without blocking UI. 

211 

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 

220 

221 # Create progress dialog 

222 from PyQt6.QtWidgets import QProgressDialog 

223 from PyQt6.QtCore import Qt 

224 

225 total_pages = sum(1 if page.is_cover else (2 if page.is_double_spread else 1) for page in project.pages) 

226 

227 from PyQt6.QtWidgets import QWidget 

228 

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() 

234 

235 # Start async export 

236 return self.async_pdf_generator.export_pdf(project, output_path, export_dpi) 

237 

238 def _on_pdf_cancel(self): 

239 """Handle PDF export cancellation.""" 

240 logger.info("User requested PDF export cancellation") 

241 

242 if hasattr(self, "async_pdf_generator"): 

243 self.async_pdf_generator.cancel_export() 

244 

245 def get_async_stats(self) -> dict: 

246 """Get async loading system statistics.""" 

247 stats = {} 

248 

249 if hasattr(self, "async_image_loader"): 

250 stats["image_loader"] = self.async_image_loader.get_stats() 

251 

252 if hasattr(self, "async_pdf_generator"): 

253 stats["pdf_generator"] = self.async_pdf_generator.get_stats() 

254 

255 return stats