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
« 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.
7This module provides functions for writing ``versions.json``, creating
8redirect pages, mirroring version directories, pruning old versions,
9and generating landing pages.
10"""
12from __future__ import annotations
14import json
15import shutil
16from pathlib import Path
17from typing import List, Tuple
19from mafw.devtools.documentation.builder import parse_version_tuple
22def write_versions_json(outdir: Path, versions: List[dict[str, str]]) -> None:
23 """
24 Write versions information to a JSON file.
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')
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.
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
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)
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)
78def write_redirect_page(outdir: Path, name: str, target_tag: str) -> None:
79 """
80 Create a redirect page for a version alias.
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}')
110def write_legacy_redirect_page(outdir: Path) -> None:
111 """
112 Create a legacy redirect page at the root of the output directory.
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}')
144def write_redirects_file(outdir: Path) -> None:
145 """
146 Create a _redirects file for GitLab Pages.
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
154# Redirect old PDF URL to new PDF downloads page
155/doc/mafw.pdf /doc/pdf_downloads.html 301
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"""
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')
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.
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>
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>
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>
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"""
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')
313def get_directory_size(path: Path) -> int:
314 """
315 Calculate total size of a directory in bytes.
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
329def format_size(bytes_size: float) -> str:
330 """
331 Format bytes to human-readable size.
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'
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).
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
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)}')
367 if current_size <= max_size_bytes:
368 print('✅ Size is within limit. No pruning needed.')
369 return [], current_size
371 # Find all version directories
372 protected_versions = {'stable', 'latest', 'dev'}
373 version_dirs = []
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))
383 # Sort by version (oldest first) using semantic versioning
384 version_dirs.sort(key=lambda x: parse_version_tuple(x[0]))
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)}')
390 print(f'\n🛡️ Protected versions (will never be removed): {", ".join(protected_versions)}')
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
398 print(f'\n🗑️ {"[DRY RUN] Would remove" if dry_run else "Removing"} {name} ({format_size(size)})...')
400 if not dry_run:
401 shutil.rmtree(path)
403 removed.append(name)
404 current_size -= size
405 print(f' New total size: {format_size(current_size)}')
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.')
411 return removed, current_size
414def regenerate_versions_json_after_pruning(outdir: Path, removed_versions: List[str]) -> None:
415 """
416 Regenerate versions.json after pruning, excluding removed versions.
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'
425 if not versions_file.exists():
426 print('⚠️ versions.json not found, skipping regeneration')
427 return
429 # Read existing versions.json
430 with open(versions_file, 'r', encoding='utf-8') as f:
431 versions = json.load(f)
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)
438 if removed_count == 0:
439 print('ℹ️ No versions removed from versions.json')
440 return
442 print('\n🔄 Regenerating versions.json...')
443 print(f' Removed {removed_count} entries')
445 # Write updated versions.json
446 write_versions_json(outdir, versions)
449def ensure_versions_json_exists(outdir: Path) -> bool:
450 """
451 Ensure versions.json exists in outdir. If not, try to copy from another version.
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'
460 if versions_file.exists():
461 return True
463 print('⚠️ versions.json not found in output directory')
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
475 print('❌ Could not find versions.json in any version directory')
476 return False