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
« 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.
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
18from mafw.devtools.documentation.builder import parse_version_tuple
21def write_versions_json(outdir: Path, versions: list[dict[str, str]]) -> None:
22 """
23 Write versions information to a JSON file.
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')
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.
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
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)
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)
77def write_redirect_page(outdir: Path, name: str, target_tag: str) -> None:
78 """
79 Create a redirect page for a version alias.
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}')
109def write_legacy_redirect_page(outdir: Path) -> None:
110 """
111 Create a legacy redirect page at the root of the output directory.
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}')
143def write_redirects_file(outdir: Path) -> None:
144 """
145 Create a _redirects file for GitLab Pages.
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
153# Redirect old PDF URL to new PDF downloads page
154/doc/mafw.pdf /doc/pdf_downloads.html 301
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"""
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')
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.
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>
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>
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>
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"""
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')
312def get_directory_size(path: Path) -> int:
313 """
314 Calculate total size of a directory in bytes.
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
328def format_size(bytes_size: float) -> str:
329 """
330 Format bytes to human-readable size.
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'
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).
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
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)}')
366 if current_size <= max_size_bytes:
367 print('✅ Size is within limit. No pruning needed.')
368 return [], current_size
370 # Find all version directories
371 protected_versions = {'stable', 'latest', 'dev'}
372 version_dirs = []
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))
382 # Sort by version (oldest first) using semantic versioning
383 version_dirs.sort(key=lambda x: parse_version_tuple(x[0]))
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)}')
389 print(f'\n🛡️ Protected versions (will never be removed): {", ".join(protected_versions)}')
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
397 print(f'\n🗑️ {"[DRY RUN] Would remove" if dry_run else "Removing"} {name} ({format_size(size)})...')
399 if not dry_run:
400 shutil.rmtree(path)
402 removed.append(name)
403 current_size -= size
404 print(f' New total size: {format_size(current_size)}')
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.')
410 return removed, current_size
413def regenerate_versions_json_after_pruning(outdir: Path, removed_versions: list[str]) -> None:
414 """
415 Regenerate versions.json after pruning, excluding removed versions.
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'
424 if not versions_file.exists():
425 print('⚠️ versions.json not found, skipping regeneration')
426 return
428 # Read existing versions.json
429 with open(versions_file, encoding='utf-8') as f:
430 versions = json.load(f)
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)
437 if removed_count == 0:
438 print('ℹ️ No versions removed from versions.json')
439 return
441 print('\n🔄 Regenerating versions.json...')
442 print(f' Removed {removed_count} entries')
444 # Write updated versions.json
445 write_versions_json(outdir, versions)
448def ensure_versions_json_exists(outdir: Path) -> bool:
449 """
450 Ensure versions.json exists in outdir. If not, try to copy from another version.
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'
459 if versions_file.exists():
460 return True
462 print('⚠️ versions.json not found in output directory')
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
474 print('❌ Could not find versions.json in any version directory')
475 return False