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

137 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-06-28 13:34 +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 

17from typing import List, Tuple 

18 

19from mafw.devtools.documentation.builder import parse_version_tuple 

20 

21 

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

23 """ 

24 Write versions information to a JSON file. 

25 

26 :param outdir: Output directory for the JSON file 

27 :type outdir: Path 

28 :param versions: List of version information dictionaries 

29 :type versions: List[dict[str, str]] 

30 """ 

31 p = outdir / 'versions.json' 

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

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

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

35 for v in versions: 

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

37 sub = v['version'] 

38 else: 

39 sub = v['path'] 

40 shutil.copy(p, outdir / sub) 

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

42 

43 

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

45 """ 

46 Mirror a version directory from one tag to another. 

47 Can use symlinks for efficiency or copy for compatibility. 

48 

49 :param outdir: Output directory containing version directories 

50 :type outdir: Path 

51 :param src_tag: Source tag directory name 

52 :type src_tag: str 

53 :param target_tag: Target tag directory name 

54 :type target_tag: str 

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

56 :type use_symlink: bool 

57 """ 

58 src = outdir / src_tag 

59 dst = outdir / target_tag 

60 

61 # Remove existing destination if it exists 

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

63 if dst.is_symlink(): 

64 dst.unlink() 

65 else: 

66 shutil.rmtree(dst) 

67 

68 if use_symlink: 

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

70 # Create relative symlink 

71 dst.symlink_to(src_tag, target_is_directory=True) 

72 else: 

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

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

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

76 

77 

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

79 """ 

80 Create a redirect page for a version alias. 

81 

82 :param outdir: Output directory for the redirect page 

83 :type outdir: Path 

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

85 :type name: str 

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

87 :type target_tag: str 

88 """ 

89 d = outdir / name 

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

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

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

93<html> 

94 <head> 

95 <meta charset="utf-8"> 

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

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

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

99 </head> 

100 <body> 

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

102 </body> 

103</html> 

104""" 

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

106 f.write(html) 

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

108 

109 

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

111 """ 

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

113 

114 :param outdir: Output directory for the redirect page 

115 :type outdir: Path 

116 """ 

117 html = """<!doctype html> 

118<html> 

119 <head> 

120 <meta charset="utf-8"> 

121 <script> 

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

123 const path = window.location.pathname; 

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

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

126 : 'stable/index.html'; 

127 window.location.replace(targetUrl); 

128 </script> 

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

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

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

132 </head> 

133 <body> 

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

135 </body> 

136</html> 

137""" 

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

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

140 f.write(html) 

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

142 

143 

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

145 """ 

146 Create a _redirects file for GitLab Pages. 

147 

148 :param outdir: Output directory for the redirects file 

149 :type outdir: Path 

150 """ 

151 redirects_content = """# Redirects for GitLab Pages 

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

153 

154# Redirect old PDF URL to new PDF downloads page 

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

156 

157# Redirect /doc root to stable documentation 

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

159/doc/ /doc/stable/ 301 

160/doc/index.html /doc/stable/index.html 301 

161/doc/doc_tutorial.html /doc/stable/doc_tutorial.html 301 

162""" 

163 

164 redirects_file = outdir / '_redirects' 

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

166 f.write(redirects_content) 

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

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

169 

170 

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

172 """ 

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

174 

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

176 :type build_root: Path 

177 :param project_name: Project name for the page title 

178 :type project_name: str 

179 """ 

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

181<html> 

182<head> 

183 <meta charset="utf-8"> 

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

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

186 <style> 

187 body {{ 

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

189 max-width: 1000px; 

190 margin: 0 auto; 

191 padding: 40px 20px; 

192 line-height: 1.6; 

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

194 min-height: 100vh; 

195 }} 

196 .container {{ 

197 background: white; 

198 border-radius: 10px; 

199 padding: 40px; 

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

201 }} 

202 h1 {{ 

203 color: #2c3e50; 

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

205 padding-bottom: 15px; 

206 margin-top: 0; 

207 }} 

208 .section {{ 

209 margin: 30px 0; 

210 padding: 25px; 

211 background: #f8f9fa; 

212 border-radius: 8px; 

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

214 }} 

215 .section h2 {{ 

216 color: #2c3e50; 

217 margin-top: 0; 

218 display: flex; 

219 align-items: center; 

220 gap: 10px; 

221 }} 

222 .links {{ 

223 display: flex; 

224 flex-wrap: wrap; 

225 gap: 15px; 

226 margin-top: 15px; 

227 }} 

228 .link-btn {{ 

229 display: inline-block; 

230 background: #3498db; 

231 color: white; 

232 padding: 12px 24px; 

233 text-decoration: none; 

234 border-radius: 5px; 

235 transition: all 0.3s; 

236 font-weight: 500; 

237 }} 

238 .link-btn:hover {{ 

239 background: #2980b9; 

240 transform: translateY(-2px); 

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

242 }} 

243 .link-btn.secondary {{ 

244 background: #95a5a6; 

245 }} 

246 .link-btn.secondary:hover {{ 

247 background: #7f8c8d; 

248 }} 

249 .description {{ 

250 color: #555; 

251 margin: 10px 0; 

252 }} 

253 .icon {{ 

254 font-size: 1.5em; 

255 }} 

256 </style> 

257</head> 

258<body> 

259 <div class="container"> 

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

261 <p class="description"> 

262 Welcome to the {project_name} project documentation portal.  

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

264 </p> 

265 

266 <div class="section"> 

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

268 <p class="description"> 

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

270 </p> 

271 <div class="links"> 

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

273 📘 Latest Stable Documentation 

274 </a> 

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

276 🔬 Development Version 

277 </a> 

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

279 📄 Download PDFs 

280 </a> 

281 </div> 

282 </div> 

283 

284 <div class="section"> 

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

286 <p class="description"> 

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

288 </p> 

289 <div class="links"> 

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

291 📊 View Coverage Report 

292 </a> 

293 </div> 

294 </div> 

295 

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

297 <p> 

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

299 </p> 

300 </div> 

301 </div> 

302</body> 

303</html> 

304""" 

305 

306 landing_page = build_root / 'index.html' 

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

308 f.write(html_content) 

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

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

311 

312 

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

314 """ 

315 Calculate total size of a directory in bytes. 

316 

317 :param path: Directory path 

318 :type path: Path 

319 :return: Total size in bytes 

320 :rtype: int 

321 """ 

322 total = 0 

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

324 if item.is_file(): 

325 total += item.stat().st_size 

326 return total 

327 

328 

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

330 """ 

331 Format bytes to human-readable size. 

332 

333 :param bytes_size: Size in bytes 

334 :type bytes_size: int 

335 :return: Formatted size string 

336 :rtype: str 

337 """ 

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

339 if bytes_size < 1024.0: 

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

341 bytes_size /= 1024.0 

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

343 

344 

345def prune_old_versions(outdir: Path, max_size_mb: int = 100, dry_run: bool = False) -> Tuple[List[str], int]: 

346 """ 

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

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

349 

350 :param outdir: Output directory containing version directories 

351 :type outdir: Path 

352 :param max_size_mb: Maximum size in megabytes 

353 :type max_size_mb: int 

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

355 :type dry_run: bool 

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

357 :rtype: Tuple[List[str], int] 

358 """ 

359 outdir = Path(outdir).resolve() 

360 max_size_bytes = max_size_mb * 1024 * 1024 

361 

362 # Get current total size 

363 current_size = get_directory_size(outdir) 

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

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

366 

367 if current_size <= max_size_bytes: 

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

369 return [], current_size 

370 

371 # Find all version directories 

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

373 version_dirs = [] 

374 

375 for item in outdir.iterdir(): 

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

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

378 if item.is_symlink(): 

379 continue 

380 size = get_directory_size(item) 

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

382 

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

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

385 

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

387 for name, size, _ in version_dirs: 

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

389 

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

391 

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

393 removed = [] 

394 for name, size, path in version_dirs: 

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

396 break 

397 

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

399 

400 if not dry_run: 

401 shutil.rmtree(path) 

402 

403 removed.append(name) 

404 current_size -= size 

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

406 

407 if not removed: 

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

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

410 

411 return removed, current_size 

412 

413 

414def regenerate_versions_json_after_pruning(outdir: Path, removed_versions: List[str]) -> None: 

415 """ 

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

417 

418 :param outdir: Output directory containing version directories 

419 :type outdir: Path 

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

421 :type removed_versions: List[str] 

422 """ 

423 versions_file = outdir / 'versions.json' 

424 

425 if not versions_file.exists(): 

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

427 return 

428 

429 # Read existing versions.json 

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

431 versions = json.load(f) 

432 

433 # Filter out removed versions 

434 original_count = len(versions) 

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

436 removed_count = original_count - len(versions) 

437 

438 if removed_count == 0: 

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

440 return 

441 

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

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

444 

445 # Write updated versions.json 

446 write_versions_json(outdir, versions) 

447 

448 

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

450 """ 

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

452 

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

454 :type outdir: Path 

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

456 :rtype: bool 

457 """ 

458 versions_file = outdir / 'versions.json' 

459 

460 if versions_file.exists(): 

461 return True 

462 

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

464 

465 # Look for versions.json in other version directories 

466 for item in outdir.iterdir(): 

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

468 candidate = item / 'versions.json' 

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

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

471 shutil.copy(candidate, versions_file) 

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

473 return True 

474 

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

476 return False