Coverage for src/mafw/devtools/documentation/builder.py: 96%
155 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-09-01 19:49 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-09-01 19:49 +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
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 ``devtools documentation`` 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>:devtools documentation`` 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 from mafw.devtools.documentation.requirements import _load_default_python_version
328 py_version = _load_default_python_version()
329 raise DevtoolsError(
330 'Unable to import the "sphinx" package. '
331 'This usually means you are running outside the MAFw development environment. '
332 'Install MAFw with the optional [devtools] feature, or invoke the helper via Hatch '
333 f'(e.g. "hatch run dev.py{py_version}:devtools documentation --help").'
334 )
337def check_multiversion_structure(outdir: Path) -> bool:
338 """
339 Check if multiversion structure exists (other version directories).
341 :param outdir: Output directory to check
342 :type outdir: Path
343 :return: True if other versions exist
344 :rtype: bool
345 """
346 if not outdir.exists():
347 return False
349 # Count non-latest version directories
350 version_dirs = []
351 for item in outdir.iterdir():
352 if item.is_dir() and item.name != 'latest':
353 # Check if it's not a symlink or if it is, count it
354 version_dirs.append(item.name)
356 return len(version_dirs) > 0
359def parse_mafw_docs_zip_filename(file_name: str) -> tuple[str, str] | None:
360 """Parse and validate a mafw-docs zip filename.
362 The accepted filename pattern is: ``mafw-docs-vX.Y.Z.zip``.
364 :param file_name: File name to parse
365 :type file_name: str
366 :return: Tuple of (version, normalized_file_name) if valid, otherwise None
367 :rtype: tuple[str, str] | None
368 """
369 base = Path(file_name).name
370 m = re.fullmatch(r'(mafw-docs)-(v[0-9]+\.[0-9]+\.[0-9]+)\.zip', base)
371 if not m:
372 return None
373 version = m.group(2)
374 return version, f'{m.group(1)}-{version}.zip'
377def normalize_registry_item(item: str) -> tuple[str, str]:
378 """Normalize a registry item into (version, file_name).
380 The item can be either:
381 - a version string: ``vX.Y.Z``
382 - a file name: ``mafw-docs-vX.Y.Z.zip``
384 :param item: Input item
385 :type item: str
386 :return: Tuple of (version, file_name)
387 :rtype: tuple[str, str]
388 :raises ValueError: If the item cannot be normalized
389 """
390 item = item.strip()
391 parsed = parse_mafw_docs_zip_filename(item)
392 if parsed is not None:
393 return parsed
394 try:
395 v = Version(item)
396 except InvalidVersion as e:
397 raise ValueError(f'Invalid version or zip filename: {item}') from e
398 if v.is_prerelease or v.is_devrelease:
399 raise ValueError(f'Pre-release/dev versions are not supported here: {item}')
400 version = item
401 return version, f'mafw-docs-{version}.zip'
404def iter_local_mafw_docs_zips(zip_dir: Path) -> list[tuple[str, Path]]:
405 """List local mafw-docs zip files in a directory.
407 Only files matching ``mafw-docs-vX.Y.Z.zip`` are returned.
409 :param zip_dir: Directory to scan
410 :type zip_dir: Path
411 :return: List of (version, file_path) tuples
412 :rtype: list[tuple[str, Path]]
413 """
414 zip_dir = Path(zip_dir).resolve()
415 if not zip_dir.exists():
416 return []
417 items: list[tuple[str, Path]] = []
418 for fp in zip_dir.iterdir():
419 if not fp.is_file():
420 continue
421 parsed = parse_mafw_docs_zip_filename(fp.name)
422 if parsed is None:
423 continue
424 version, _ = parsed
425 items.append((version, fp))
426 items.sort(key=lambda x: parse_version_tuple(x[0]))
427 return items
430def filter_versions_in_range(versions: list[str], from_v: str | None, to_v: str | None) -> list[str]:
431 """Filter versions within an inclusive semantic-version range.
433 :param versions: Input versions list
434 :type versions: list[str]
435 :param from_v: Range start (inclusive)
436 :type from_v: str | None
437 :param to_v: Range end (inclusive)
438 :type to_v: str | None
439 :return: Filtered versions list
440 :rtype: list[str]
441 :raises ValueError: If range bounds are invalid
442 """
443 if from_v is None and to_v is None:
444 return versions
445 if from_v is not None:
446 Version(from_v) # validate
447 if to_v is not None:
448 Version(to_v) # validate
449 if from_v is not None and to_v is not None:
450 if Version(from_v) > Version(to_v):
451 raise ValueError(f'Invalid range: --from {from_v} is greater than --to {to_v}')
452 out: list[str] = []
453 for v in versions:
454 vv = Version(v)
455 if from_v is not None and vv < Version(from_v):
456 continue
457 if to_v is not None and vv > Version(to_v):
458 continue
459 out.append(v)
460 return out
463def generate_pdf_index_page( # pragma: no cover
464 html_outdir: Path, pdf_info: list[dict[str, str]], project_name: str = 'Documentation'
465) -> None:
466 """
467 Generate an HTML page listing all available PDFs.
468 This page will be placed in the root html_versions directory.
469 Order: stable first, then latest, then other releases sorted by version (newest first).
471 :param html_outdir: Output directory for HTML files
472 :type html_outdir: Path
473 :param pdf_info: List of dictionaries containing PDF information
474 :type pdf_info: list[dict[str, str]]
475 :param project_name: Name of the project for the page title, defaults to 'Documentation'
476 :type project_name: str
477 """
478 html_content = f"""<!DOCTYPE html>
479<html>
480<head>
481 <meta charset="utf-8">
482 <title>PDF Downloads - {project_name}</title>
483 <link rel="shortcut icon" href="stable/_static/mafw-logo.svg"/>
484 <style>
485 body {{
486 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
487 max-width: 900px;
488 margin: 40px auto;
489 padding: 20px;
490 line-height: 1.6;
491 }}
492 h1 {{
493 color: #2c3e50;
494 border-bottom: 3px solid #3498db;
495 padding-bottom: 10px;
496 }}
497 .pdf-list {{
498 list-style: none;
499 padding: 0;
500 }}
501 .pdf-item {{
502 background: #f8f9fa;
503 border: 1px solid #dee2e6;
504 border-radius: 5px;
505 padding: 15px 20px;
506 margin: 10px 0;
507 display: flex;
508 justify-content: space-between;
509 align-items: center;
510 transition: all 0.3s;
511 }}
512 .pdf-item:hover {{
513 background: #e9ecef;
514 transform: translateX(5px);
515 }}
516 .pdf-version {{
517 font-weight: bold;
518 font-size: 1.1em;
519 color: #2c3e50;
520 }}
521 .pdf-label {{
522 display: inline-block;
523 padding: 3px 8px;
524 border-radius: 3px;
525 font-size: 0.85em;
526 margin-left: 10px;
527 }}
528 .label-stable {{
529 background: #28a745;
530 color: white;
531 }}
532 .label-latest {{
533 background: #ffc107;
534 color: #000;
535 }}
536 .label-release {{
537 background: #6c757d;
538 color: white;
539 }}
540 .download-btn {{
541 background: #3498db;
542 color: white;
543 padding: 8px 20px;
544 text-decoration: none;
545 border-radius: 5px;
546 transition: background 0.3s;
547 }}
548 .download-btn:hover {{
549 background: #2980b9;
550 }}
551 .failed {{
552 opacity: 0.5;
553 }}
554 .failed .download-btn {{
555 background: #95a5a6;
556 pointer-events: none;
557 }}
558 </style>
559</head>
560<body>
561 <h1>📄 PDF Documentation Downloads</h1>
562 <p>Download the complete documentation in PDF format for any version:</p>
563 <ul class="pdf-list">
564"""
566 # Sort: stable first, then latest, then releases by version (newest first)
567 sorted_info = []
568 stable_item = None
569 latest_item = None
570 release_items = []
572 for info in pdf_info:
573 if info['label'] == 'alias':
574 continue
575 if info['label'] == 'stable':
576 stable_item = info
577 elif info['label'] == 'latest':
578 latest_item = info
579 else: # release
580 release_items.append(info)
582 # Sort releases by version (newest first)
583 release_items.sort(key=lambda x: parse_version_tuple(x['version']), reverse=True)
585 # Build final order
586 if stable_item:
587 sorted_info.append(stable_item)
588 if latest_item:
589 sorted_info.append(latest_item)
590 sorted_info.extend(release_items)
592 for info in sorted_info:
593 label_class = f'label-{info["label"]}'
594 label_text = info['label'].upper()
595 item_class = '' if info['built'] else 'failed'
597 if info['built']:
598 # PDF is in the same directory as HTML for each version
599 pdf_link = f'{info["version"]}/{info["version"]}.pdf'
600 html_content += f"""
601 <li class="pdf-item {item_class}">
602 <div>
603 <span class="pdf-version">{info['version']}</span>
604 <span class="pdf-label {label_class}">{label_text}</span>
605 </div>
606 <a href="{pdf_link}" class="download-btn" download>Download PDF</a>
607 </li>
608"""
609 else:
610 html_content += f"""
611 <li class="pdf-item {item_class}">
612 <div>
613 <span class="pdf-version">{info['version']}</span>
614 <span class="pdf-label {label_class}">{label_text}</span>
615 <span style="color: #e74c3c; margin-left: 10px;">(Build failed)</span>
616 </div>
617 <span class="download-btn">Unavailable</span>
618 </li>
619"""
621 html_content += """
622 </ul>
623 <p style="margin-top: 40px; color: #6c757d; font-size: 0.9em;">
624 💡 Tip: The PDF version contains the complete documentation for offline reading.
625 </p>
626</body>
627</html>
628"""
630 # Write to root of html_versions
631 pdf_page = html_outdir / 'pdf_downloads.html'
632 with open(pdf_page, 'w', encoding='utf-8') as f:
633 f.write(html_content)
634 print(f'📝 Generated PDF index page: {pdf_page}')
637def build_for_tag( # pragma: no cover
638 tag: str, outdir: Path, tmproot: Path, use_latest_conf: bool = False, keep_tmp: bool = False
639) -> tuple[bool, str]:
640 """Create worktree for tag, run sphinx-build, save log.
642 :param tag: Git tag to build documentation for
643 :type tag: str
644 :param outdir: Output directory for built documentation
645 :type outdir: Path
646 :param tmproot: Root temporary directory
647 :type tmproot: Path
648 :param use_latest_conf: Whether to use latest conf.py, defaults to False
649 :type use_latest_conf: bool
650 :param keep_tmp: Whether to keep temporary files, defaults to False
651 :type keep_tmp: bool
652 :return: Tuple of (success, log_contents)
653 :rtype: tuple[bool, str]
654 """
655 worktree_path = tmproot / tag
656 try:
657 proc = run(['git', 'worktree', 'add', '-q', str(worktree_path), tag])
658 if proc.returncode != 0:
659 return False, f'git worktree add failed:\n{proc.stdout}'
660 docs_src = worktree_path / DOCS_SUBPATH
661 if not docs_src.exists():
662 return False, f'docs source {docs_src} does not exist for tag {tag}'
664 if use_latest_conf or tag in OLD_VERSION_TO_BE_PATCHED:
665 copy_patch_files(docs_src)
667 out_for_tag = outdir / tag
668 out_for_tag.mkdir(parents=True, exist_ok=True)
670 sp = run([SPHINX_BUILD_CMD, '-b', 'html', str(docs_src), str(out_for_tag)], cwd=worktree_path)
671 log = sp.stdout
672 with open(out_for_tag / 'sphinx-build.log', 'w', encoding='utf-8') as f:
673 f.write(log)
674 success = sp.returncode == 0
675 return success, log
676 finally:
677 if not keep_tmp:
678 run(['git', 'worktree', 'remove', '-f', str(worktree_path)])
681def build_pdf_for_tag( # pragma: no cover
682 tag: str, html_tag_dir: Path, tmproot: Path, use_latest_conf: bool = False, keep_tmp: bool = False
683) -> tuple[bool, str, Path | None]:
684 """Create worktree for tag, run sphinx-build with latex builder, then make PDF.
686 :param tag: Git tag to build PDF for
687 :type tag: str
688 :param html_tag_dir: Directory containing HTML output for the tag
689 :type html_tag_dir: Path
690 :param tmproot: Root temporary directory
691 :type tmproot: Path
692 :param use_latest_conf: Whether to use latest conf.py, defaults to False
693 :type use_latest_conf: bool
694 :param keep_tmp: Whether to keep temporary files, defaults to False
695 :type keep_tmp: bool
696 :return: Tuple of (success, log_contents, pdf_path)
697 :rtype: tuple[bool, str, Path | None]
698 """
699 worktree_path = tmproot / f'{tag}_pdf'
700 pdf_path = None
701 try:
702 proc = run(['git', 'worktree', 'add', '-q', str(worktree_path), tag])
703 if proc.returncode != 0:
704 return False, f'git worktree add failed:\n{proc.stdout}', None
706 docs_src = worktree_path / DOCS_SUBPATH
707 if not docs_src.exists():
708 return False, f'docs source {docs_src} does not exist for tag {tag}', None
710 if use_latest_conf or tag in OLD_VERSION_TO_BE_PATCHED:
711 copy_patch_files(docs_src)
713 latex_out = tmproot / f'{tag}_latex'
714 latex_out.mkdir(parents=True, exist_ok=True)
716 sp = run([SPHINX_BUILD_CMD, '-b', 'latex', str(docs_src), str(latex_out)], cwd=worktree_path)
717 log = sp.stdout
719 if sp.returncode != 0:
720 return False, f'Sphinx latex build failed:\n{log}', None
722 makefile = latex_out / 'Makefile'
723 if makefile.exists():
724 sp_pdf = run(['make'], cwd=latex_out)
725 else:
726 tex_files = list(latex_out.glob('*.tex'))
727 if not tex_files:
728 return False, 'No .tex file found in latex output', None
729 sp_pdf = run(['pdflatex', '-interaction=nonstopmode', tex_files[0].name], cwd=latex_out)
731 log += '\n' + sp_pdf.stdout
733 pdf_files = list(latex_out.glob('*.pdf'))
734 if not pdf_files:
735 return False, f'PDF generation failed:\n{log}', None
737 html_tag_dir.mkdir(parents=True, exist_ok=True)
738 pdf_path = html_tag_dir / f'{tag}.pdf'
739 pdf_file = latex_out / 'mafw.pdf'
740 shutil.copy(pdf_file, pdf_path)
742 success = sp_pdf.returncode == 0
743 return success, log, pdf_path
745 finally:
746 if not keep_tmp:
747 run(['git', 'worktree', 'remove', '-f', str(worktree_path)])