Coverage for pyPhotoAlbum/async_project_loader.py: 15%

131 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-20 12:55 +0000

1""" 

2Async project loader for pyPhotoAlbum 

3 

4Loads projects asynchronously with progress updates to prevent UI freezing. 

5""" 

6 

7import os 

8import json 

9import zipfile 

10import tempfile 

11from typing import Optional, Tuple 

12from pathlib import Path 

13from PyQt6.QtCore import QThread, pyqtSignal 

14 

15from pyPhotoAlbum.project import Project 

16from pyPhotoAlbum.models import ImageData, set_asset_resolution_context 

17from pyPhotoAlbum.version_manager import ( 

18 CURRENT_DATA_VERSION, 

19 check_version_compatibility, 

20 VersionCompatibility, 

21 DataMigration, 

22) 

23 

24 

25class AsyncProjectLoader(QThread): 

26 """ 

27 Async worker thread for loading projects from ZIP files. 

28 

29 Signals: 

30 progress_updated(int, int, str): Emitted with (current, total, message) 

31 load_complete(Project): Emitted when loading succeeds 

32 load_failed(str): Emitted when loading fails with error message 

33 """ 

34 

35 progress_updated = pyqtSignal(int, int, str) # current, total, message 

36 load_complete = pyqtSignal(object) # Project instance 

37 load_failed = pyqtSignal(str) # error message 

38 

39 def __init__(self, zip_path: str, extract_to: Optional[str] = None): 

40 super().__init__() 

41 self.zip_path = zip_path 

42 self.extract_to = extract_to 

43 self._cancelled = False 

44 

45 def cancel(self): 

46 """Cancel the loading operation""" 

47 self._cancelled = True 

48 

49 def run(self): 

50 """Run the async loading operation""" 

51 try: 

52 if not os.path.exists(self.zip_path): 

53 self.load_failed.emit(f"ZIP file not found: {self.zip_path}") 

54 return 

55 

56 if self._cancelled: 

57 return 

58 

59 # Progress: Starting 

60 self.progress_updated.emit(0, 100, "Preparing to load...") 

61 

62 # Track if we created a temp directory 

63 temp_dir_obj = None 

64 

65 # Determine extraction directory 

66 if self.extract_to is None: 

67 zip_basename = os.path.splitext(os.path.basename(self.zip_path))[0] 

68 temp_dir_obj = tempfile.TemporaryDirectory(prefix=f"pyPhotoAlbum_{zip_basename}_") 

69 extract_to = temp_dir_obj.name 

70 else: 

71 os.makedirs(self.extract_to, exist_ok=True) 

72 extract_to = self.extract_to 

73 

74 if self._cancelled: 

75 return 

76 

77 # Progress: Extracting ZIP 

78 self.progress_updated.emit(10, 100, "Extracting project files...") 

79 

80 # Extract ZIP contents with progress 

81 with zipfile.ZipFile(self.zip_path, "r") as zipf: 

82 file_list = zipf.namelist() 

83 total_files = len(file_list) 

84 

85 for i, filename in enumerate(file_list): 

86 if self._cancelled: 

87 return 

88 

89 zipf.extract(filename, extract_to) 

90 

91 # Update progress every 10 files or on last file 

92 if i % 10 == 0 or i == total_files - 1: 

93 progress = 10 + int((i / total_files) * 30) # 10-40% 

94 self.progress_updated.emit(progress, 100, f"Extracting files... ({i + 1}/{total_files})") 

95 

96 if self._cancelled: 

97 return 

98 

99 # Progress: Loading project data 

100 self.progress_updated.emit(45, 100, "Loading project data...") 

101 

102 # Load project.json 

103 project_json_path = os.path.join(extract_to, "project.json") 

104 if not os.path.exists(project_json_path): 

105 self.load_failed.emit("Invalid project file: project.json not found") 

106 return 

107 

108 with open(project_json_path, "r") as f: 

109 project_data = json.load(f) 

110 

111 if self._cancelled: 

112 return 

113 

114 # Progress: Checking version 

115 self.progress_updated.emit(55, 100, "Checking version compatibility...") 

116 

117 # Check version compatibility 

118 file_version = project_data.get("data_version", project_data.get("serialization_version", "1.0")) 

119 

120 is_compatible, error_msg = check_version_compatibility(file_version, self.zip_path) 

121 if not is_compatible: 

122 self.load_failed.emit(error_msg) 

123 return 

124 

125 # Apply migrations if needed 

126 if VersionCompatibility.needs_migration(file_version): 

127 self.progress_updated.emit(60, 100, f"Migrating from version {file_version}...") 

128 try: 

129 project_data = DataMigration.migrate(project_data, file_version, CURRENT_DATA_VERSION) 

130 except Exception as e: 

131 self.load_failed.emit(f"Migration failed: {str(e)}") 

132 return 

133 

134 if self._cancelled: 

135 return 

136 

137 # Progress: Creating project 

138 self.progress_updated.emit(70, 100, "Creating project...") 

139 

140 # Create new project 

141 project_name = project_data.get("name", "Untitled Project") 

142 project = Project(name=project_name, folder_path=extract_to) 

143 

144 # Deserialize project data 

145 project.deserialize(project_data) 

146 

147 # Update folder path to extraction location 

148 project.folder_path = extract_to 

149 project.asset_manager.project_folder = extract_to 

150 project.asset_manager.assets_folder = os.path.join(extract_to, "assets") 

151 

152 # Attach temporary directory to project (if we created one) 

153 if temp_dir_obj is not None: 

154 project._temp_dir = temp_dir_obj 

155 

156 if self._cancelled: 

157 return 

158 

159 # Progress: Normalizing paths 

160 self.progress_updated.emit(85, 100, "Normalizing asset paths...") 

161 

162 # Normalize asset paths 

163 self._normalize_asset_paths(project, extract_to) 

164 

165 # Progress: Setting up asset resolution 

166 self.progress_updated.emit(95, 100, "Setting up asset resolution...") 

167 

168 # Set asset resolution context 

169 # Only set project folder - search paths are reserved for healing functionality 

170 set_asset_resolution_context(extract_to) 

171 

172 if self._cancelled: 

173 return 

174 

175 # Progress: Complete 

176 self.progress_updated.emit(100, 100, "Loading complete!") 

177 

178 # Emit success 

179 self.load_complete.emit(project) 

180 

181 except Exception as e: 

182 error_msg = f"Error loading project: {str(e)}" 

183 self.load_failed.emit(error_msg) 

184 

185 def _normalize_asset_paths(self, project: Project, project_folder: str): 

186 """ 

187 Normalize asset paths in a loaded project to be relative to the project folder. 

188 """ 

189 normalized_count = 0 

190 

191 for page in project.pages: 

192 for element in page.layout.elements: 

193 if isinstance(element, ImageData) and element.image_path: 

194 original_path = element.image_path 

195 

196 # Skip if already a simple relative path 

197 if not os.path.isabs(original_path) and not original_path.startswith("./projects/"): 

198 continue 

199 

200 # Pattern 1: "./projects/XXX/assets/filename.jpg" -> "assets/filename.jpg" 

201 if "/assets/" in original_path: 

202 parts = original_path.split("/assets/") 

203 if len(parts) == 2: 

204 new_path = os.path.join("assets", parts[1]) 

205 element.image_path = new_path 

206 normalized_count += 1 

207 continue 

208 

209 # Pattern 2: Absolute path - try to make it relative 

210 if os.path.isabs(original_path): 

211 try: 

212 new_path = os.path.relpath(original_path, project_folder) 

213 element.image_path = new_path 

214 normalized_count += 1 

215 except ValueError: 

216 pass 

217 

218 if normalized_count > 0: 

219 print(f"Normalized {normalized_count} asset paths") 

220 

221 

222def load_from_zip_async( 

223 zip_path: str, extract_to: Optional[str] = None, progress_callback=None, complete_callback=None, error_callback=None 

224) -> AsyncProjectLoader: 

225 """ 

226 Load a project from a ZIP file asynchronously. 

227 

228 Args: 

229 zip_path: Path to the ZIP file to load 

230 extract_to: Optional directory to extract to. If None, uses a temporary directory. 

231 progress_callback: Optional callback(current, total, message) for progress updates 

232 complete_callback: Optional callback(project) when loading completes 

233 error_callback: Optional callback(error_msg) when loading fails 

234 

235 Returns: 

236 AsyncProjectLoader instance (already started) 

237 """ 

238 loader = AsyncProjectLoader(zip_path, extract_to) 

239 

240 if progress_callback: 

241 loader.progress_updated.connect(progress_callback) 

242 if complete_callback: 

243 loader.load_complete.connect(complete_callback) 

244 if error_callback: 

245 loader.load_failed.connect(error_callback) 

246 

247 loader.start() 

248 return loader