Coverage for src / mafw / devtools / documentation / builder.py: 96%
153 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"""
5Sphinx documentation building helpers for MAFw versioned documentation.
7This module provides functions for building Sphinx documentation across
8multiple git tags, managing git worktrees, and handling documentation
9zip archives.
10"""
12from __future__ import annotations
14import re
15import shutil
16import subprocess
17import zipfile
18from pathlib import Path
19from typing import Any, List, Tuple
21from mafw.devtools import DevtoolsError, ensure_devtools_available
23ensure_devtools_available()
25from packaging.version import InvalidVersion, Version # noqa: E402
27from mafw.tools.shell_tools import run as _run # noqa: E402
29# ---------------------------
30# Configurable defaults
31# ---------------------------
32DEFAULT_MIN_TAG_REGEX = r'^v([1-9][0-9]*)\.[0-9]+\.[0-9]+(\.[0-9]+)?$'
33"""Regular expression to match stable version tags."""
35DOCS_SUBPATH = Path('docs') / 'source'
36"""The files/directories under each worktree where docs live."""
38SPHINX_BUILD_CMD = 'sphinx-build' # ensure on PATH
39"""Sphinx build command name."""
41OLD_VERSION_TO_BE_PATCHED = ['v1.0.0', 'v1.1.0', 'v1.2.0', 'v1.3.0', 'v1.4.0']
42"""Tags that require patching with the latest conf.py."""
45def run(cmd: List[str], cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
46 """Helper to run commands with consistent behavior.
48 :param cmd: Command to execute as a list of strings
49 :type cmd: List[str]
50 :param cwd: Working directory for command execution, defaults to None
51 :type cwd: Path | None
52 :return: Completed process result
53 :rtype: subprocess.CompletedProcess[str]
54 """
55 return _run(cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False)
58def find_repo_root(start: Path | None = None) -> Path:
59 """Find the repository root directory.
61 The root is detected by walking upwards until a ``pyproject.toml`` file is found.
62 If no such file is found, the starting directory is returned.
64 :param start: Directory from which to start searching, defaults to current working directory
65 :type start: Path | None
66 :return: Resolved repository root directory
67 :rtype: Path
68 """
69 current = (start or Path.cwd()).resolve()
70 while True:
71 if (current / 'pyproject.toml').exists():
72 return current
73 if current.parent == current:
74 return (start or Path.cwd()).resolve()
75 current = current.parent
78def create_docs_zip_for_tag(outdir: Path, tag: str, zip_filepath: Path) -> Path: # pragma: no cover
79 """Create a zip archive for a built documentation version directory.
81 The built docs are expected under ``outdir / tag`` (e.g. ``docs/build/doc/vX.Y.Z``).
82 The produced zip is laid out so that extracting it from the repository root recreates
83 the original directory structure (e.g. ``docs/build/doc/vX.Y.Z/...``).
85 The zip file is created at ``zip_filepath / f"mafw-docs-{tag}.zip"``.
87 Notes
88 -----
89 - Symlinks are skipped to avoid ambiguous extraction behavior across platforms.
91 :param outdir: Output directory that contains the built docs
92 :type outdir: Path
93 :param tag: Version tag (e.g. ``v2.1.0``)
94 :type tag: str
95 :param zip_filepath: Directory where the zip file is written
96 :type zip_filepath: Path
97 :return: Path to the created zip archive
98 :rtype: Path
99 :raises FileNotFoundError: If the built docs directory does not exist
100 :raises NotADirectoryError: If the built docs path is not a directory
101 """
102 outdir = Path(outdir).resolve()
103 zip_filepath = Path(zip_filepath).resolve()
104 zip_filepath.mkdir(parents=True, exist_ok=True)
106 built_dir = outdir / tag
107 if not built_dir.exists():
108 raise FileNotFoundError(f'Built docs directory not found: {built_dir}')
109 if not built_dir.is_dir():
110 raise NotADirectoryError(f'Built docs path is not a directory: {built_dir}')
112 repo_root = find_repo_root()
113 try:
114 prefix = built_dir.relative_to(repo_root)
115 except ValueError:
116 prefix = Path('docs') / 'build' / 'doc' / tag
117 print(f'⚠️ Warning: {built_dir} is not under repo root {repo_root}. Using archive prefix {prefix}.')
119 zip_path = zip_filepath / f'mafw-docs-{tag}.zip'
120 with zipfile.ZipFile(zip_path, mode='w', compression=zipfile.ZIP_DEFLATED) as zf:
121 for fp in built_dir.rglob('*'):
122 if not fp.is_file():
123 continue
124 if fp.is_symlink():
125 continue
126 arcname = prefix / fp.relative_to(built_dir)
127 zf.write(fp, arcname=arcname)
129 return zip_path
132def extract_docs_zip_to_repo_root(zip_path: Path, repo_root: Path | None = None) -> None: # pragma: no cover
133 """Extract a documentation zip archive into the repository root and remove the zip.
135 The zip archive is expected to contain paths rooted at the repository (e.g.
136 ``docs/build/doc/vX.Y.Z/...``) so that extraction recreates the same structure
137 as a normal documentation build.
139 :param zip_path: Zip archive to extract
140 :type zip_path: Path
141 :param repo_root: Repository root directory, defaults to auto-detection
142 :type repo_root: Path | None
143 :raises FileNotFoundError: If ``zip_path`` does not exist
144 :raises zipfile.BadZipFile: If the archive is invalid
145 """
146 zip_path = Path(zip_path).resolve()
147 if not zip_path.exists():
148 raise FileNotFoundError(f'Zip file not found: {zip_path}')
150 root = (repo_root or find_repo_root()).resolve()
151 with zipfile.ZipFile(zip_path) as zf:
152 zf.extractall(path=root)
153 zip_path.unlink()
156def filter_latest_micro(versions: List[Tuple[Version, Any]]) -> List[Tuple[Version, Any]]:
157 """Keep only the latest micro version per minor (major.minor).
159 :param versions: List of (Version, tag) tuples
160 :type versions: List[Tuple[Version, Any]]
161 :return: Filtered list of (Version, tag) tuples
162 :rtype: List[Tuple[Version, Any]]
163 """
164 latest_per_minor: dict[Tuple[int, int], Tuple[Version, Any]] = {}
165 for v, tag in versions:
166 key = (v.major, v.minor)
167 if key not in latest_per_minor or v > latest_per_minor[key][0]: 167 ↛ 165line 167 didn't jump to line 165 because the condition on line 167 was always true
168 latest_per_minor[key] = (v, tag)
169 return sorted(latest_per_minor.values())
172def filter_stable_tags(tags: List[str], regex: str) -> List[str]:
173 """Filter tags based on a regular expression pattern.
175 :param tags: List of tag strings to filter
176 :type tags: List[str]
177 :param regex: Regular expression pattern to match against
178 :type regex: str
179 :return: Filtered list of matching tags
180 :rtype: List[str]
181 """
182 pattern = re.compile(regex)
183 return [t for t in tags if pattern.match(t)]
186def parse_version_tuple(tag: str) -> Tuple[int, ...]:
187 """Parse vX.Y.Z(.W) into tuple of ints for sorting.
189 :param tag: Version tag string
190 :type tag: str
191 :return: Tuple of integers representing the version
192 :rtype: Tuple[int, ...]
193 """
194 if tag.startswith('v'):
195 tag = tag[1:]
196 parts = tag.split('.')
197 # only take numeric parts
198 nums = []
199 for p in parts:
200 if p.isdigit():
201 nums.append(int(p))
202 else:
203 # stop on strange parts; but ideally regex filters those out
204 break
205 return tuple(nums)
208def copy_patch_files(docs_src: Path) -> None:
209 """Copy patch files needed for older versions.
211 :param docs_src: Path to documentation source directory
212 :type docs_src: Path
213 """
214 # Define the patch files to copy
215 patch_files = [
216 ('docs/source/conf.py', docs_src / 'conf.py'),
217 ('docs/source/_static/js/version-switcher.js', docs_src / '_static/js/version-switcher.js'),
218 ('docs/source/_templates/versions.html', docs_src / '_templates/versions.html'),
219 ('docs/source/_templates/layout.html', docs_src / '_templates/layout.html'),
220 ('docs/source/_ext/procparams.py', docs_src / '_ext/procparams.py'),
221 ]
223 # Create directories and copy files
224 for src_path, dst_path in patch_files:
225 dst_path.parent.mkdir(parents=True, exist_ok=True)
226 shutil.copy(Path.cwd() / src_path, dst_path)
229def parse_sphinx_log(log_content: str) -> Tuple[int, int, List[str]]:
230 """
231 Parse Sphinx build log to extract warning and error counts, and warning messages.
233 Only three warnings are reported
235 :param log_content: Sphinx build log
236 :type log_content: str
237 :return: Tuple of warning, error count, warning messages
238 :rtype: Tuple[int, int, List[str]]
239 """
240 warnings = 0
241 warning_messages = []
243 # Look for patterns like "build succeeded, X warning(s)."
244 success_pattern = re.compile(r'build succeeded(?:,\s+(\d+)\s+warning)?', re.IGNORECASE)
245 match = success_pattern.search(log_content)
247 if match:
248 if match.group(1):
249 warnings = int(match.group(1))
251 # Look for explicit warning lines and extract messages
252 warning_pattern = re.compile(r'^.*WARNING:.*$', re.MULTILINE | re.IGNORECASE)
253 warning_lines = warning_pattern.findall(log_content)
254 warnings = max(warnings, len(warning_lines))
256 # Extract just the relevant part of warning messages (limit to first 3)
257 # for line in warning_lines[:3]:
258 # clean_line = ' '.join(line.split())
259 # warning_messages.append(clean_line)
260 warning_messages = warning_lines[:3]
262 if len(warning_lines) > 3:
263 warning_messages.append(f'... and {len(warning_lines) - 3} more warning(s)')
265 # Look for error patterns
266 error_pattern = re.compile(r'ERROR:|CRITICAL:', re.IGNORECASE)
267 errors = len(error_pattern.findall(log_content))
269 return warnings, errors, warning_messages
272def report_build_status(tag: str, success: bool, log: str, build_type: str = 'HTML') -> None:
273 """
274 Report build status with warning/error summary.
276 :param tag: Version tag being built
277 :type tag: str
278 :param success: Whether build succeeded
279 :type success: bool
280 :param log: Build log content
281 :type log: str
282 :param build_type: Type of build (HTML or PDF)
283 :type build_type: str
284 """
285 warnings, errors, warning_messages = parse_sphinx_log(log)
287 status_icon = '✅' if success else '❌'
288 status_text = 'OK' if success else 'FAILED'
290 print(f'{status_icon} {tag} {build_type} build {status_text}', end='')
292 if warnings > 0 or errors > 0:
293 details = []
294 if warnings > 0: 294 ↛ 295line 294 didn't jump to line 295 because the condition on line 294 was never true
295 details.append(f'⚠️ {warnings} warning(s)')
296 if errors > 0: 296 ↛ 298line 296 didn't jump to line 298 because the condition on line 296 was always true
297 details.append(f'❌ {errors} error(s)')
298 print(f' ({", ".join(details)})')
300 # Display warning messages if present
301 if warning_messages: 301 ↛ 302line 301 didn't jump to line 302 because the condition on line 301 was never true
302 for msg in warning_messages:
303 print(f' ⚠️ {msg}')
304 else:
305 print(' (no warnings)')
308def ensure_sphinx_build_available() -> None:
309 """Ensure that the Sphinx Python package is available.
311 ``doc_versioning`` is a development helper shipped with MAFw. The script is
312 typically executed from the optional ``[dev]`` environment (either by
313 activating the development environment and using the console entry point,
314 or via ``hatch run dev.py<version>:multidoc`` on CI/CD).
316 Checking for the ``sphinx-build`` executable alone is not sufficient because
317 the effective availability depends on which Python environment is executing
318 the command. Checking the import spec for ``sphinx`` validates that the
319 correct optional dependencies are installed for the running interpreter.
321 :raises DevtoolsError: If Sphinx is not available.
322 """
323 import importlib.util
325 if importlib.util.find_spec('sphinx') is None:
326 raise DevtoolsError(
327 'Unable to import the "sphinx" package. '
328 'This usually means you are running outside the MAFw development environment. '
329 'Install MAFw with the optional [devtools] feature, or invoke the helper via Hatch '
330 '(e.g. "hatch run dev.py3.14:multidoc --help").'
331 )
334def check_multiversion_structure(outdir: Path) -> bool:
335 """
336 Check if multiversion structure exists (other version directories).
338 :param outdir: Output directory to check
339 :type outdir: Path
340 :return: True if other versions exist
341 :rtype: bool
342 """
343 if not outdir.exists():
344 return False
346 # Count non-latest version directories
347 version_dirs = []
348 for item in outdir.iterdir():
349 if item.is_dir() and item.name != 'latest':
350 # Check if it's not a symlink or if it is, count it
351 version_dirs.append(item.name)
353 return len(version_dirs) > 0
356def parse_mafw_docs_zip_filename(file_name: str) -> Tuple[str, str] | None:
357 """Parse and validate a mafw-docs zip filename.
359 The accepted filename pattern is: ``mafw-docs-vX.Y.Z.zip``.
361 :param file_name: File name to parse
362 :type file_name: str
363 :return: Tuple of (version, normalized_file_name) if valid, otherwise None
364 :rtype: Tuple[str, str] | None
365 """
366 base = Path(file_name).name
367 m = re.fullmatch(r'(mafw-docs)-(v[0-9]+\.[0-9]+\.[0-9]+)\.zip', base)
368 if not m:
369 return None
370 version = m.group(2)
371 return version, f'{m.group(1)}-{version}.zip'
374def normalize_registry_item(item: str) -> Tuple[str, str]:
375 """Normalize a registry item into (version, file_name).
377 The item can be either:
378 - a version string: ``vX.Y.Z``
379 - a file name: ``mafw-docs-vX.Y.Z.zip``
381 :param item: Input item
382 :type item: str
383 :return: Tuple of (version, file_name)
384 :rtype: Tuple[str, str]
385 :raises ValueError: If the item cannot be normalized
386 """
387 item = item.strip()
388 parsed = parse_mafw_docs_zip_filename(item)
389 if parsed is not None:
390 return parsed
391 try:
392 v = Version(item)
393 except InvalidVersion as e:
394 raise ValueError(f'Invalid version or zip filename: {item}') from e
395 if v.is_prerelease or v.is_devrelease:
396 raise ValueError(f'Pre-release/dev versions are not supported here: {item}')
397 version = item
398 return version, f'mafw-docs-{version}.zip'
401def iter_local_mafw_docs_zips(zip_dir: Path) -> List[Tuple[str, Path]]:
402 """List local mafw-docs zip files in a directory.
404 Only files matching ``mafw-docs-vX.Y.Z.zip`` are returned.
406 :param zip_dir: Directory to scan
407 :type zip_dir: Path
408 :return: List of (version, file_path) tuples
409 :rtype: List[Tuple[str, Path]]
410 """
411 zip_dir = Path(zip_dir).resolve()
412 if not zip_dir.exists():
413 return []
414 items: List[Tuple[str, Path]] = []
415 for fp in zip_dir.iterdir():
416 if not fp.is_file():
417 continue
418 parsed = parse_mafw_docs_zip_filename(fp.name)
419 if parsed is None:
420 continue
421 version, _ = parsed
422 items.append((version, fp))
423 items.sort(key=lambda x: parse_version_tuple(x[0]))
424 return items
427def filter_versions_in_range(versions: List[str], from_v: str | None, to_v: str | None) -> List[str]:
428 """Filter versions within an inclusive semantic-version range.
430 :param versions: Input versions list
431 :type versions: List[str]
432 :param from_v: Range start (inclusive)
433 :type from_v: str | None
434 :param to_v: Range end (inclusive)
435 :type to_v: str | None
436 :return: Filtered versions list
437 :rtype: List[str]
438 :raises ValueError: If range bounds are invalid
439 """
440 if from_v is None and to_v is None:
441 return versions
442 if from_v is not None:
443 Version(from_v) # validate
444 if to_v is not None:
445 Version(to_v) # validate
446 if from_v is not None and to_v is not None:
447 if Version(from_v) > Version(to_v):
448 raise ValueError(f'Invalid range: --from {from_v} is greater than --to {to_v}')
449 out: List[str] = []
450 for v in versions:
451 vv = Version(v)
452 if from_v is not None and vv < Version(from_v):
453 continue
454 if to_v is not None and vv > Version(to_v):
455 continue
456 out.append(v)
457 return out
460def generate_pdf_index_page( # pragma: no cover
461 html_outdir: Path, pdf_info: List[dict[str, str]], project_name: str = 'Documentation'
462) -> None:
463 """
464 Generate an HTML page listing all available PDFs.
465 This page will be placed in the root html_versions directory.
466 Order: stable first, then latest, then other releases sorted by version (newest first).
468 :param html_outdir: Output directory for HTML files
469 :type html_outdir: Path
470 :param pdf_info: List of dictionaries containing PDF information
471 :type pdf_info: List[dict[str, str]]
472 :param project_name: Name of the project for the page title, defaults to 'Documentation'
473 :type project_name: str
474 """
475 html_content = f"""<!DOCTYPE html>
476<html>
477<head>
478 <meta charset="utf-8">
479 <title>PDF Downloads - {project_name}</title>
480 <link rel="shortcut icon" href="stable/_static/mafw-logo.svg"/>
481 <style>
482 body {{
483 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
484 max-width: 900px;
485 margin: 40px auto;
486 padding: 20px;
487 line-height: 1.6;
488 }}
489 h1 {{
490 color: #2c3e50;
491 border-bottom: 3px solid #3498db;
492 padding-bottom: 10px;
493 }}
494 .pdf-list {{
495 list-style: none;
496 padding: 0;
497 }}
498 .pdf-item {{
499 background: #f8f9fa;
500 border: 1px solid #dee2e6;
501 border-radius: 5px;
502 padding: 15px 20px;
503 margin: 10px 0;
504 display: flex;
505 justify-content: space-between;
506 align-items: center;
507 transition: all 0.3s;
508 }}
509 .pdf-item:hover {{
510 background: #e9ecef;
511 transform: translateX(5px);
512 }}
513 .pdf-version {{
514 font-weight: bold;
515 font-size: 1.1em;
516 color: #2c3e50;
517 }}
518 .pdf-label {{
519 display: inline-block;
520 padding: 3px 8px;
521 border-radius: 3px;
522 font-size: 0.85em;
523 margin-left: 10px;
524 }}
525 .label-stable {{
526 background: #28a745;
527 color: white;
528 }}
529 .label-latest {{
530 background: #ffc107;
531 color: #000;
532 }}
533 .label-release {{
534 background: #6c757d;
535 color: white;
536 }}
537 .download-btn {{
538 background: #3498db;
539 color: white;
540 padding: 8px 20px;
541 text-decoration: none;
542 border-radius: 5px;
543 transition: background 0.3s;
544 }}
545 .download-btn:hover {{
546 background: #2980b9;
547 }}
548 .failed {{
549 opacity: 0.5;
550 }}
551 .failed .download-btn {{
552 background: #95a5a6;
553 pointer-events: none;
554 }}
555 </style>
556</head>
557<body>
558 <h1>📄 PDF Documentation Downloads</h1>
559 <p>Download the complete documentation in PDF format for any version:</p>
560 <ul class="pdf-list">
561"""
563 # Sort: stable first, then latest, then releases by version (newest first)
564 sorted_info = []
565 stable_item = None
566 latest_item = None
567 release_items = []
569 for info in pdf_info:
570 if info['label'] == 'alias':
571 continue
572 if info['label'] == 'stable':
573 stable_item = info
574 elif info['label'] == 'latest':
575 latest_item = info
576 else: # release
577 release_items.append(info)
579 # Sort releases by version (newest first)
580 release_items.sort(key=lambda x: parse_version_tuple(x['version']), reverse=True)
582 # Build final order
583 if stable_item:
584 sorted_info.append(stable_item)
585 if latest_item:
586 sorted_info.append(latest_item)
587 sorted_info.extend(release_items)
589 for info in sorted_info:
590 label_class = f'label-{info["label"]}'
591 label_text = info['label'].upper()
592 item_class = '' if info['built'] else 'failed'
594 if info['built']:
595 # PDF is in the same directory as HTML for each version
596 pdf_link = f'{info["version"]}/{info["version"]}.pdf'
597 html_content += f"""
598 <li class="pdf-item {item_class}">
599 <div>
600 <span class="pdf-version">{info['version']}</span>
601 <span class="pdf-label {label_class}">{label_text}</span>
602 </div>
603 <a href="{pdf_link}" class="download-btn" download>Download PDF</a>
604 </li>
605"""
606 else:
607 html_content += f"""
608 <li class="pdf-item {item_class}">
609 <div>
610 <span class="pdf-version">{info['version']}</span>
611 <span class="pdf-label {label_class}">{label_text}</span>
612 <span style="color: #e74c3c; margin-left: 10px;">(Build failed)</span>
613 </div>
614 <span class="download-btn">Unavailable</span>
615 </li>
616"""
618 html_content += """
619 </ul>
620 <p style="margin-top: 40px; color: #6c757d; font-size: 0.9em;">
621 💡 Tip: The PDF version contains the complete documentation for offline reading.
622 </p>
623</body>
624</html>
625"""
627 # Write to root of html_versions
628 pdf_page = html_outdir / 'pdf_downloads.html'
629 with open(pdf_page, 'w', encoding='utf-8') as f:
630 f.write(html_content)
631 print(f'📝 Generated PDF index page: {pdf_page}')
634def build_for_tag( # pragma: no cover
635 tag: str, outdir: Path, tmproot: Path, use_latest_conf: bool = False, keep_tmp: bool = False
636) -> Tuple[bool, str]:
637 """Create worktree for tag, run sphinx-build, save log.
639 :param tag: Git tag to build documentation for
640 :type tag: str
641 :param outdir: Output directory for built documentation
642 :type outdir: Path
643 :param tmproot: Root temporary directory
644 :type tmproot: Path
645 :param use_latest_conf: Whether to use latest conf.py, defaults to False
646 :type use_latest_conf: bool
647 :param keep_tmp: Whether to keep temporary files, defaults to False
648 :type keep_tmp: bool
649 :return: Tuple of (success, log_contents)
650 :rtype: Tuple[bool, str]
651 """
652 worktree_path = tmproot / tag
653 try:
654 proc = run(['git', 'worktree', 'add', '-q', str(worktree_path), tag])
655 if proc.returncode != 0:
656 return False, f'git worktree add failed:\n{proc.stdout}'
657 docs_src = worktree_path / DOCS_SUBPATH
658 if not docs_src.exists():
659 return False, f'docs source {docs_src} does not exist for tag {tag}'
661 if use_latest_conf or tag in OLD_VERSION_TO_BE_PATCHED:
662 copy_patch_files(docs_src)
664 out_for_tag = outdir / tag
665 out_for_tag.mkdir(parents=True, exist_ok=True)
667 sp = run([SPHINX_BUILD_CMD, '-b', 'html', str(docs_src), str(out_for_tag)], cwd=worktree_path)
668 log = sp.stdout
669 with open(out_for_tag / 'sphinx-build.log', 'w', encoding='utf-8') as f:
670 f.write(log)
671 success = sp.returncode == 0
672 return success, log
673 finally:
674 if not keep_tmp:
675 run(['git', 'worktree', 'remove', '-f', str(worktree_path)])
678def build_pdf_for_tag( # pragma: no cover
679 tag: str, html_tag_dir: Path, tmproot: Path, use_latest_conf: bool = False, keep_tmp: bool = False
680) -> Tuple[bool, str, Path | None]:
681 """Create worktree for tag, run sphinx-build with latex builder, then make PDF.
683 :param tag: Git tag to build PDF for
684 :type tag: str
685 :param html_tag_dir: Directory containing HTML output for the tag
686 :type html_tag_dir: Path
687 :param tmproot: Root temporary directory
688 :type tmproot: Path
689 :param use_latest_conf: Whether to use latest conf.py, defaults to False
690 :type use_latest_conf: bool
691 :param keep_tmp: Whether to keep temporary files, defaults to False
692 :type keep_tmp: bool
693 :return: Tuple of (success, log_contents, pdf_path)
694 :rtype: Tuple[bool, str, Path | None]
695 """
696 worktree_path = tmproot / f'{tag}_pdf'
697 pdf_path = None
698 try:
699 proc = run(['git', 'worktree', 'add', '-q', str(worktree_path), tag])
700 if proc.returncode != 0:
701 return False, f'git worktree add failed:\n{proc.stdout}', None
703 docs_src = worktree_path / DOCS_SUBPATH
704 if not docs_src.exists():
705 return False, f'docs source {docs_src} does not exist for tag {tag}', None
707 if use_latest_conf or tag in OLD_VERSION_TO_BE_PATCHED:
708 copy_patch_files(docs_src)
710 latex_out = tmproot / f'{tag}_latex'
711 latex_out.mkdir(parents=True, exist_ok=True)
713 sp = run([SPHINX_BUILD_CMD, '-b', 'latex', str(docs_src), str(latex_out)], cwd=worktree_path)
714 log = sp.stdout
716 if sp.returncode != 0:
717 return False, f'Sphinx latex build failed:\n{log}', None
719 makefile = latex_out / 'Makefile'
720 if makefile.exists():
721 sp_pdf = run(['make'], cwd=latex_out)
722 else:
723 tex_files = list(latex_out.glob('*.tex'))
724 if not tex_files:
725 return False, 'No .tex file found in latex output', None
726 sp_pdf = run(['pdflatex', '-interaction=nonstopmode', tex_files[0].name], cwd=latex_out)
728 log += '\n' + sp_pdf.stdout
730 pdf_files = list(latex_out.glob('*.pdf'))
731 if not pdf_files:
732 return False, f'PDF generation failed:\n{log}', None
734 html_tag_dir.mkdir(parents=True, exist_ok=True)
735 pdf_path = html_tag_dir / f'{tag}.pdf'
736 pdf_file = latex_out / 'mafw.pdf'
737 shutil.copy(pdf_file, pdf_path)
739 success = sp_pdf.returncode == 0
740 return success, log, pdf_path
742 finally:
743 if not keep_tmp:
744 run(['git', 'worktree', 'remove', '-f', str(worktree_path)])