Coverage for src/mafw/devtools/documentation/versions.py: 98%

136 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-26 09:13 +0000

1# Copyright 2025–2026 European Union 

2# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu) 

3# SPDX-License-Identifier: EUPL-1.2 

4""" 

5Version management helpers for MAFw versioned documentation. 

6 

7This module provides functions for writing ``versions.json``, creating 

8redirect pages, mirroring version directories, pruning old versions, 

9and generating landing pages. 

10""" 

11 

12from __future__ import annotations 

13 

14import json 

15import shutil 

16from pathlib import Path 

17 

18from mafw.devtools.documentation.builder import parse_version_tuple 

19 

20 

21def write_versions_json(outdir: Path, versions: list[dict[str, str]]) -> None: 

22 """ 

23 Write versions information to a JSON file. 

24 

25 :param outdir: Output directory for the JSON file 

26 :type outdir: Path 

27 :param versions: List of version information dictionaries 

28 :type versions: list[dict[str, str]] 

29 """ 

30 p = outdir / 'versions.json' 

31 with open(p, 'w', encoding='utf-8') as f: 

32 json.dump(versions, f, indent=2) 

33 print(f'🧾 Wrote versions.json to {p}') 

34 for v in versions: 

35 if v['label'] == 'alias': 

36 sub = v['version'] 

37 else: 

38 sub = v['path'] 

39 shutil.copy(p, outdir / sub) 

40 shutil.copy(p, outdir / sub / 'generated') 

41 

42 

43def mirror_version(outdir: Path, src_tag: str, target_tag: str, use_symlink: bool = True) -> None: 

44 """ 

45 Mirror a version directory from one tag to another. 

46 Can use symlinks for efficiency or copy for compatibility. 

47 

48 :param outdir: Output directory containing version directories 

49 :type outdir: Path 

50 :param src_tag: Source tag directory name 

51 :type src_tag: str 

52 :param target_tag: Target tag directory name 

53 :type target_tag: str 

54 :param use_symlink: Whether to use symlink instead of copying, defaults to True 

55 :type use_symlink: bool 

56 """ 

57 src = outdir / src_tag 

58 dst = outdir / target_tag 

59 

60 # Remove existing destination if it exists 

61 if dst.exists() or dst.is_symlink(): 

62 if dst.is_symlink(): 

63 dst.unlink() 

64 else: 

65 shutil.rmtree(dst) 

66 

67 if use_symlink: 

68 print(f'🔗 Symlinking {target_tag} -> {src_tag}') 

69 # Create relative symlink 

70 dst.symlink_to(src_tag, target_is_directory=True) 

71 else: 

72 print(f'🪞 Mirroring {src_tag} to {target_tag}') 

73 dst.mkdir(parents=True, exist_ok=True) 

74 shutil.copytree(src, dst, dirs_exist_ok=True) 

75 

76 

77def write_redirect_page(outdir: Path, name: str, target_tag: str) -> None: 

78 """ 

79 Create a redirect page for a version alias. 

80 

81 :param outdir: Output directory for the redirect page 

82 :type outdir: Path 

83 :param name: Name of the redirect alias (e.g., 'stable', 'dev') 

84 :type name: str 

85 :param target_tag: Tag that the redirect should point to 

86 :type target_tag: str 

87 """ 

88 d = outdir / name 

89 d.mkdir(parents=True, exist_ok=True) 

90 target = f'../{target_tag}/index.html' # relative path from stable/index.html to tag/index 

91 html = f"""<!doctype html> 

92<html> 

93 <head> 

94 <meta charset="utf-8"> 

95 <meta http-equiv="refresh" content="0; url={target}"> 

96 <link rel="canonical" href="{target}"> 

97 <title>Redirecting to {target_tag}</title> 

98 </head> 

99 <body> 

100 <p>Redirecting to <a href="{target}">{target}</a></p> 

101 </body> 

102</html> 

103""" 

104 with open(d / 'index.html', 'w', encoding='utf-8') as f: 

105 f.write(html) 

106 print(f'🧾 Wrote redirect page {d / "index.html"} -> {target}') 

107 

108 

109def write_legacy_redirect_page(outdir: Path) -> None: 

110 """ 

111 Create a legacy redirect page at the root of the output directory. 

112 

113 :param outdir: Output directory for the redirect page 

114 :type outdir: Path 

115 """ 

116 html = """<!doctype html> 

117<html> 

118 <head> 

119 <meta charset="utf-8"> 

120 <script> 

121 // Detect if we're in /doc/ subdirectory and redirect accordingly 

122 const path = window.location.pathname; 

123 const targetUrl = path.startsWith('/doc/')  

124 ? '/doc/stable/index.html'  

125 : 'stable/index.html'; 

126 window.location.replace(targetUrl); 

127 </script> 

128 <meta http-equiv="refresh" content="0; url=stable/index.html"> 

129 <link rel="canonical" href="stable/index.html"> 

130 <title>Redirecting to stable documentation</title> 

131 </head> 

132 <body> 

133 <p>Redirecting to <a href="stable/index.html">Documentation of the last stable release</a></p> 

134 </body> 

135</html> 

136""" 

137 d = outdir / Path('index.html') 

138 with open(d, 'w', encoding='utf-8') as f: 

139 f.write(html) 

140 print(f'🧾 Wrote legacy redirect page {d}') 

141 

142 

143def write_redirects_file(outdir: Path) -> None: 

144 """ 

145 Create a _redirects file for GitLab Pages. 

146 

147 :param outdir: Output directory for the redirects file 

148 :type outdir: Path 

149 """ 

150 redirects_content = """# Redirects for GitLab Pages 

151# See: https://docs.gitlab.com/ee/user/project/pages/redirects.html 

152 

153# Redirect old PDF URL to new PDF downloads page 

154/doc/mafw.pdf /doc/pdf_downloads.html 301 

155 

156# Redirect /doc root to stable documentation 

157# Note: These are specific patterns to avoid redirecting /doc/pdf_downloads.html 

158/doc/ /doc/stable/ 301 

159/doc/index.html /doc/stable/index.html 301 

160/doc/doc_tutorial.html /doc/stable/doc_tutorial.html 301 

161""" 

162 

163 redirects_file = outdir / '_redirects' 

164 with open(redirects_file, 'w', encoding='utf-8') as f: 

165 f.write(redirects_content) 

166 print(f'🔀 Wrote _redirects file: {redirects_file}') 

167 print(' Note: Copy this file to the public/ directory root for GitLab Pages') 

168 

169 

170def write_root_landing_page(build_root: Path, project_name: str = 'MAFw') -> None: 

171 """ 

172 Create a landing page for the project root with links to documentation and coverage. 

173 

174 :param build_root: Root build directory (should contain 'doc' subdirectory) 

175 :type build_root: Path 

176 :param project_name: Project name for the page title 

177 :type project_name: str 

178 """ 

179 html_content = f"""<!DOCTYPE html> 

180<html> 

181<head> 

182 <meta charset="utf-8"> 

183 <title>{project_name} - Documentation Hub</title> 

184 <link rel="shortcut icon" href="doc/stable/_static/mafw-logo.svg"/> 

185 <style> 

186 body {{ 

187 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; 

188 max-width: 1000px; 

189 margin: 0 auto; 

190 padding: 40px 20px; 

191 line-height: 1.6; 

192 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 

193 min-height: 100vh; 

194 }} 

195 .container {{ 

196 background: white; 

197 border-radius: 10px; 

198 padding: 40px; 

199 box-shadow: 0 10px 40px rgba(0,0,0,0.1); 

200 }} 

201 h1 {{ 

202 color: #2c3e50; 

203 border-bottom: 3px solid #3498db; 

204 padding-bottom: 15px; 

205 margin-top: 0; 

206 }} 

207 .section {{ 

208 margin: 30px 0; 

209 padding: 25px; 

210 background: #f8f9fa; 

211 border-radius: 8px; 

212 border-left: 4px solid #3498db; 

213 }} 

214 .section h2 {{ 

215 color: #2c3e50; 

216 margin-top: 0; 

217 display: flex; 

218 align-items: center; 

219 gap: 10px; 

220 }} 

221 .links {{ 

222 display: flex; 

223 flex-wrap: wrap; 

224 gap: 15px; 

225 margin-top: 15px; 

226 }} 

227 .link-btn {{ 

228 display: inline-block; 

229 background: #3498db; 

230 color: white; 

231 padding: 12px 24px; 

232 text-decoration: none; 

233 border-radius: 5px; 

234 transition: all 0.3s; 

235 font-weight: 500; 

236 }} 

237 .link-btn:hover {{ 

238 background: #2980b9; 

239 transform: translateY(-2px); 

240 box-shadow: 0 4px 12px rgba(52, 152, 219, 0.4); 

241 }} 

242 .link-btn.secondary {{ 

243 background: #95a5a6; 

244 }} 

245 .link-btn.secondary:hover {{ 

246 background: #7f8c8d; 

247 }} 

248 .description {{ 

249 color: #555; 

250 margin: 10px 0; 

251 }} 

252 .icon {{ 

253 font-size: 1.5em; 

254 }} 

255 </style> 

256</head> 

257<body> 

258 <div class="container"> 

259 <h1>📚 {project_name} Documentation Hub</h1> 

260 <p class="description"> 

261 Welcome to the {project_name} project documentation portal.  

262 Access the latest documentation, download PDFs, or view test coverage reports. 

263 </p> 

264 

265 <div class="section"> 

266 <h2><span class="icon">📖</span> Documentation</h2> 

267 <p class="description"> 

268 Browse the complete documentation with tutorials, API reference, and guides. 

269 </p> 

270 <div class="links"> 

271 <a href="doc/stable/index.html" class="link-btn"> 

272 📘 Latest Stable Documentation 

273 </a> 

274 <a href="doc/latest/index.html" class="link-btn secondary"> 

275 🔬 Development Version 

276 </a> 

277 <a href="doc/pdf_downloads.html" class="link-btn secondary"> 

278 📄 Download PDFs 

279 </a> 

280 </div> 

281 </div> 

282 

283 <div class="section"> 

284 <h2><span class="icon">🧪</span> Test Coverage</h2> 

285 <p class="description"> 

286 View detailed test coverage reports showing which parts of the codebase are tested. 

287 </p> 

288 <div class="links"> 

289 <a href="coverage/index.html" class="link-btn"> 

290 📊 View Coverage Report 

291 </a> 

292 </div> 

293 </div> 

294 

295 <div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #dee2e6; color: #6c757d; font-size: 0.9em;"> 

296 <p> 

297 💡 <strong>Tip:</strong> Bookmark the stable documentation link for quick access to the latest version. 

298 </p> 

299 </div> 

300 </div> 

301</body> 

302</html> 

303""" 

304 

305 landing_page = build_root / 'index.html' 

306 with open(landing_page, 'w', encoding='utf-8') as f: 

307 f.write(html_content) 

308 print(f'🏠 Generated root landing page: {landing_page}') 

309 print(' Note: This should be copied to public/index.html in GitLab CI') 

310 

311 

312def get_directory_size(path: Path) -> int: 

313 """ 

314 Calculate total size of a directory in bytes. 

315 

316 :param path: Directory path 

317 :type path: Path 

318 :return: Total size in bytes 

319 :rtype: int 

320 """ 

321 total = 0 

322 for item in path.rglob('*'): 

323 if item.is_file(): 

324 total += item.stat().st_size 

325 return total 

326 

327 

328def format_size(bytes_size: float) -> str: 

329 """ 

330 Format bytes to human-readable size. 

331 

332 :param bytes_size: Size in bytes 

333 :type bytes_size: int 

334 :return: Formatted size string 

335 :rtype: str 

336 """ 

337 for unit in ['B', 'KB', 'MB', 'GB']: 

338 if bytes_size < 1024.0: 

339 return f'{bytes_size:.2f} {unit}' 

340 bytes_size /= 1024.0 

341 return f'{bytes_size:.2f} TB' 

342 

343 

344def prune_old_versions(outdir: Path, max_size_mb: int = 100, dry_run: bool = False) -> tuple[list[str], int]: 

345 """ 

346 Remove oldest version directories until total size is below threshold. 

347 Always keeps 'stable', 'latest', and 'dev' (if present). 

348 

349 :param outdir: Output directory containing version directories 

350 :type outdir: Path 

351 :param max_size_mb: Maximum size in megabytes 

352 :type max_size_mb: int 

353 :param dry_run: If True, only report what would be deleted 

354 :type dry_run: bool 

355 :return: Tuple of (list of removed versions, final size in bytes) 

356 :rtype: tuple[list[str], int] 

357 """ 

358 outdir = Path(outdir).resolve() 

359 max_size_bytes = max_size_mb * 1024 * 1024 

360 

361 # Get current total size 

362 current_size = get_directory_size(outdir) 

363 print(f'📊 Current total size: {format_size(current_size)}') 

364 print(f'🎯 Target maximum: {format_size(max_size_bytes)}') 

365 

366 if current_size <= max_size_bytes: 

367 print('✅ Size is within limit. No pruning needed.') 

368 return [], current_size 

369 

370 # Find all version directories 

371 protected_versions = {'stable', 'latest', 'dev'} 

372 version_dirs = [] 

373 

374 for item in outdir.iterdir(): 

375 if item.is_dir() and item.name not in protected_versions: 

376 # Skip if it's a symlink (it's an alias) 

377 if item.is_symlink(): 

378 continue 

379 size = get_directory_size(item) 

380 version_dirs.append((item.name, size, item)) 

381 

382 # Sort by version (oldest first) using semantic versioning 

383 version_dirs.sort(key=lambda x: parse_version_tuple(x[0])) 

384 

385 print(f'\n📦 Found {len(version_dirs)} version directories (excluding protected):') 

386 for name, size, _ in version_dirs: 

387 print(f'{name}: {format_size(size)}') 

388 

389 print(f'\n🛡️ Protected versions (will never be removed): {", ".join(protected_versions)}') 

390 

391 # Remove oldest versions until we're under the limit 

392 removed = [] 

393 for name, size, path in version_dirs: 

394 if current_size <= max_size_bytes: 394 ↛ 395line 394 didn't jump to line 395 because the condition on line 394 was never true

395 break 

396 

397 print(f'\n🗑️ {"[DRY RUN] Would remove" if dry_run else "Removing"} {name} ({format_size(size)})...') 

398 

399 if not dry_run: 

400 shutil.rmtree(path) 

401 

402 removed.append(name) 

403 current_size -= size 

404 print(f' New total size: {format_size(current_size)}') 

405 

406 if not removed: 

407 print(f'\n⚠️ Warning: Cannot reduce size below {format_size(max_size_bytes)}') 

408 print(' All remaining versions are protected or size target is too aggressive.') 

409 

410 return removed, current_size 

411 

412 

413def regenerate_versions_json_after_pruning(outdir: Path, removed_versions: list[str]) -> None: 

414 """ 

415 Regenerate versions.json after pruning, excluding removed versions. 

416 

417 :param outdir: Output directory containing version directories 

418 :type outdir: Path 

419 :param removed_versions: List of version names that were removed 

420 :type removed_versions: list[str] 

421 """ 

422 versions_file = outdir / 'versions.json' 

423 

424 if not versions_file.exists(): 

425 print('⚠️ versions.json not found, skipping regeneration') 

426 return 

427 

428 # Read existing versions.json 

429 with open(versions_file, encoding='utf-8') as f: 

430 versions = json.load(f) 

431 

432 # Filter out removed versions 

433 original_count = len(versions) 

434 versions = [v for v in versions if v['version'] not in removed_versions and v.get('path') not in removed_versions] 

435 removed_count = original_count - len(versions) 

436 

437 if removed_count == 0: 

438 print('ℹ️ No versions removed from versions.json') 

439 return 

440 

441 print('\n🔄 Regenerating versions.json...') 

442 print(f' Removed {removed_count} entries') 

443 

444 # Write updated versions.json 

445 write_versions_json(outdir, versions) 

446 

447 

448def ensure_versions_json_exists(outdir: Path) -> bool: 

449 """ 

450 Ensure versions.json exists in outdir. If not, try to copy from another version. 

451 

452 :param outdir: Output directory that should contain versions.json 

453 :type outdir: Path 

454 :return: True if versions.json exists or was successfully copied 

455 :rtype: bool 

456 """ 

457 versions_file = outdir / 'versions.json' 

458 

459 if versions_file.exists(): 

460 return True 

461 

462 print('⚠️ versions.json not found in output directory') 

463 

464 # Look for versions.json in other version directories 

465 for item in outdir.iterdir(): 

466 if item.is_dir() and not item.is_symlink(): 466 ↛ 465line 466 didn't jump to line 465 because the condition on line 466 was always true

467 candidate = item / 'versions.json' 

468 if candidate.exists(): 468 ↛ 465line 468 didn't jump to line 465 because the condition on line 468 was always true

469 print(f'📋 Copying versions.json from {item.name}/') 

470 shutil.copy(candidate, versions_file) 

471 shutil.copy(candidate, outdir / 'generated/versions.json') 

472 return True 

473 

474 print('❌ Could not find versions.json in any version directory') 

475 return False