Coverage for pyPhotoAlbum/version_manager.py: 71%

133 statements  

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

1""" 

2Version management and migration system for pyPhotoAlbum projects 

3""" 

4 

5import os 

6import uuid 

7from datetime import datetime, timezone 

8from typing import Dict, Any, Optional, Callable, List 

9 

10# Current data version - increment when making breaking changes to data format 

11CURRENT_DATA_VERSION = "3.0" 

12 

13# Version history and compatibility information 

14VERSION_HISTORY = { 

15 "1.0": { 

16 "description": "Initial format with basic serialization", 

17 "released": "2024-01-01", 

18 "breaking_changes": [], 

19 "compatible_with": ["1.0"], 

20 }, 

21 "2.0": { 

22 "description": "Fixed asset path handling - paths now stored relative to project folder", 

23 "released": "2025-01-11", 

24 "breaking_changes": [ 

25 "Asset paths changed from absolute/full-project-relative to project-relative", 

26 "Added automatic path normalization for legacy projects", 

27 ], 

28 "compatible_with": ["1.0", "2.0"], # 2.0 can read 1.0 with migration 

29 }, 

30 "3.0": { 

31 "description": "Added merge conflict resolution support with UUIDs, timestamps, and project IDs", 

32 "released": "2025-01-22", 

33 "breaking_changes": [ 

34 "Added required UUID fields to all pages and elements", 

35 "Added created/last_modified timestamps to projects, pages, and elements", 

36 "Added project_id for merge detection (same ID = merge, different ID = concatenate)", 

37 "Added deletion tracking (deleted flag and deleted_at timestamp)", 

38 ], 

39 "compatible_with": ["1.0", "2.0", "3.0"], # 3.0 can read older versions with migration 

40 }, 

41} 

42 

43 

44class VersionCompatibility: 

45 """Handles version compatibility checks and migrations""" 

46 

47 @staticmethod 

48 def is_compatible(file_version: str) -> bool: 

49 """ 

50 Check if a file version is compatible with the current version. 

51 

52 Args: 

53 file_version: Version string from the file 

54 

55 Returns: 

56 True if compatible, False otherwise 

57 """ 

58 current_info = VERSION_HISTORY.get(CURRENT_DATA_VERSION, {}) 

59 compatible_versions = current_info.get("compatible_with", []) 

60 return file_version in compatible_versions 

61 

62 @staticmethod 

63 def needs_migration(file_version: str) -> bool: 

64 """ 

65 Check if a file needs migration to work with current version. 

66 

67 Args: 

68 file_version: Version string from the file 

69 

70 Returns: 

71 True if migration is needed, False otherwise 

72 """ 

73 # If versions don't match but are compatible, migration may be needed 

74 return file_version != CURRENT_DATA_VERSION and VersionCompatibility.is_compatible(file_version) 

75 

76 @staticmethod 

77 def get_version_info(version: str) -> Optional[Dict[str, Any]]: 

78 """Get information about a specific version.""" 

79 return VERSION_HISTORY.get(version) 

80 

81 @staticmethod 

82 def get_migration_path(from_version: str, to_version: str) -> Optional[List[str]]: 

83 """ 

84 Get the migration path from one version to another. 

85 

86 Args: 

87 from_version: Starting version 

88 to_version: Target version 

89 

90 Returns: 

91 List of version steps needed, or None if no path exists 

92 """ 

93 # For now, we only support direct migration paths 

94 # In the future, we could implement multi-step migrations 

95 

96 if from_version == to_version: 

97 return [] 

98 

99 from_info = VERSION_HISTORY.get(from_version) 

100 to_info = VERSION_HISTORY.get(to_version) 

101 

102 if not from_info or not to_info: 

103 return None 

104 

105 # Check if direct migration is possible 

106 compatible_versions = to_info.get("compatible_with", []) 

107 if from_version in compatible_versions: 

108 return [from_version, to_version] 

109 

110 return None 

111 

112 

113class DataMigration: 

114 """Handles data migrations between versions""" 

115 

116 # Registry of migration functions 

117 _migrations: Dict[tuple, Callable] = {} 

118 

119 @classmethod 

120 def register_migration(cls, from_version: str, to_version: str): 

121 """Decorator to register a migration function""" 

122 

123 def decorator(func): 

124 cls._migrations[(from_version, to_version)] = func 

125 return func 

126 

127 return decorator 

128 

129 @classmethod 

130 def migrate(cls, data: Dict[str, Any], from_version: str, to_version: str) -> Dict[str, Any]: 

131 """ 

132 Migrate data from one version to another. 

133 

134 Args: 

135 data: Project data dictionary 

136 from_version: Current version of the data 

137 to_version: Target version 

138 

139 Returns: 

140 Migrated data dictionary 

141 """ 

142 if from_version == to_version: 

143 return data 

144 

145 # Get migration path 

146 migration_path = VersionCompatibility.get_migration_path(from_version, to_version) 

147 if not migration_path: 

148 raise ValueError(f"No migration path from {from_version} to {to_version}") 

149 

150 # Apply migrations in sequence 

151 current_data = data 

152 for i in range(len(migration_path) - 1): 

153 step_from = migration_path[i] 

154 step_to = migration_path[i + 1] 

155 migration_key = (step_from, step_to) 

156 

157 if migration_key in cls._migrations: 

158 print(f"Applying migration: {step_from}{step_to}") 

159 current_data = cls._migrations[migration_key](current_data) 

160 else: 

161 print(f"Warning: No explicit migration for {step_from}{step_to}, using as-is") 

162 

163 return current_data 

164 

165 

166# Register migrations 

167 

168 

169@DataMigration.register_migration("1.0", "2.0") 

170def migrate_1_0_to_2_0(data: Dict[str, Any]) -> Dict[str, Any]: 

171 """ 

172 Migrate from version 1.0 to 2.0. 

173 

174 Main changes: 

175 - Asset paths are normalized to be relative to project folder 

176 - This is now handled automatically in load_from_zip via _normalize_asset_paths 

177 """ 

178 print("Migration 1.0 → 2.0: Asset paths will be normalized during load") 

179 

180 # Update version in data 

181 data["data_version"] = "2.0" 

182 

183 # Note: Actual path normalization is handled in load_from_zip 

184 # This migration mainly updates the version number 

185 

186 return data 

187 

188 

189@DataMigration.register_migration("2.0", "3.0") 

190def migrate_2_0_to_3_0(data: Dict[str, Any]) -> Dict[str, Any]: 

191 """ 

192 Migrate from version 2.0 to 3.0. 

193 

194 Main changes: 

195 - Add UUIDs to all pages and elements 

196 - Add timestamps (created, last_modified) to project, pages, and elements 

197 - Add project_id to project 

198 - Add deletion tracking (deleted, deleted_at) to pages and elements 

199 """ 

200 print("Migration 2.0 → 3.0: Adding UUIDs, timestamps, and project_id") 

201 

202 # Get current timestamp for migration 

203 now = datetime.now(timezone.utc).isoformat() 

204 

205 # Add project-level fields 

206 if "project_id" not in data: 

207 data["project_id"] = str(uuid.uuid4()) 

208 print(f" Generated project_id: {data['project_id']}") 

209 

210 if "created" not in data: 

211 data["created"] = now 

212 

213 if "last_modified" not in data: 

214 data["last_modified"] = now 

215 

216 # Migrate pages 

217 for page_data in data.get("pages", []): 

218 # Add UUID 

219 if "uuid" not in page_data: 

220 page_data["uuid"] = str(uuid.uuid4()) 

221 

222 # Add timestamps 

223 if "created" not in page_data: 

224 page_data["created"] = now 

225 if "last_modified" not in page_data: 

226 page_data["last_modified"] = now 

227 

228 # Add deletion tracking 

229 if "deleted" not in page_data: 

230 page_data["deleted"] = False 

231 if "deleted_at" not in page_data: 

232 page_data["deleted_at"] = None 

233 

234 # Migrate elements in page layout 

235 layout_data = page_data.get("layout", {}) 

236 for element_data in layout_data.get("elements", []): 

237 # Add UUID 

238 if "uuid" not in element_data: 

239 element_data["uuid"] = str(uuid.uuid4()) 

240 

241 # Add timestamps 

242 if "created" not in element_data: 

243 element_data["created"] = now 

244 if "last_modified" not in element_data: 

245 element_data["last_modified"] = now 

246 

247 # Add deletion tracking 

248 if "deleted" not in element_data: 

249 element_data["deleted"] = False 

250 if "deleted_at" not in element_data: 

251 element_data["deleted_at"] = None 

252 

253 # Update version 

254 data["data_version"] = "3.0" 

255 

256 print(f" Migrated {len(data.get('pages', []))} pages to v3.0") 

257 

258 return data 

259 

260 

261def check_version_compatibility(file_version: str, file_path: str = "") -> tuple[bool, Optional[str]]: 

262 """ 

263 Check version compatibility and provide user-friendly messages. 

264 

265 Args: 

266 file_version: Version from the file 

267 file_path: Optional path to the file (for error messages) 

268 

269 Returns: 

270 Tuple of (is_compatible, error_message) 

271 """ 

272 if not file_version: 

273 return True, None # No version specified, assume compatible 

274 

275 if VersionCompatibility.is_compatible(file_version): 

276 if VersionCompatibility.needs_migration(file_version): 

277 print(f"File version {file_version} is compatible but needs migration to {CURRENT_DATA_VERSION}") 

278 return True, None 

279 

280 # Not compatible 

281 file_info = VersionCompatibility.get_version_info(file_version) 

282 current_info = VersionCompatibility.get_version_info(CURRENT_DATA_VERSION) 

283 

284 error_msg = f"Incompatible file version: {file_version}\n\n" 

285 error_msg += f"This file was created with version {file_version}, " 

286 error_msg += f"but this application uses version {CURRENT_DATA_VERSION}.\n\n" 

287 

288 if file_info: 

289 error_msg += f"File version info:\n" 

290 error_msg += f" Description: {file_info.get('description', 'Unknown')}\n" 

291 error_msg += f" Released: {file_info.get('released', 'Unknown')}\n" 

292 breaking_changes = file_info.get("breaking_changes", []) 

293 if breaking_changes: 

294 error_msg += f" Breaking changes:\n" 

295 for change in breaking_changes: 

296 error_msg += f" - {change}\n" 

297 

298 error_msg += f"\nPlease use a compatible version of pyPhotoAlbum to open this file." 

299 

300 return False, error_msg 

301 

302 

303def format_version_info() -> str: 

304 """Format version information for display""" 

305 info = [ 

306 f"pyPhotoAlbum Data Format Version: {CURRENT_DATA_VERSION}", 

307 "", 

308 "Version History:", 

309 ] 

310 

311 for version in sorted(VERSION_HISTORY.keys(), reverse=True): 

312 version_info = VERSION_HISTORY[version] 

313 info.append(f"\n Version {version}") 

314 info.append(f" Description: {version_info.get('description', 'Unknown')}") 

315 info.append(f" Released: {version_info.get('released', 'Unknown')}") 

316 

317 breaking_changes = version_info.get("breaking_changes", []) 

318 if breaking_changes: 

319 info.append(f" Breaking changes:") 

320 for change in breaking_changes: 

321 info.append(f" - {change}") 

322 

323 return "\n".join(info)