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

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. 

6 

7This module provides functions for building Sphinx documentation across 

8multiple git tags, managing git worktrees, and handling documentation 

9zip archives. 

10""" 

11 

12from __future__ import annotations 

13 

14import re 

15import shutil 

16import subprocess 

17import zipfile 

18from pathlib import Path 

19from typing import Any, List, Tuple 

20 

21from mafw.devtools import DevtoolsError, ensure_devtools_available 

22 

23ensure_devtools_available() 

24 

25from packaging.version import InvalidVersion, Version # noqa: E402 

26 

27from mafw.tools.shell_tools import run as _run # noqa: E402 

28 

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.""" 

34 

35DOCS_SUBPATH = Path('docs') / 'source' 

36"""The files/directories under each worktree where docs live.""" 

37 

38SPHINX_BUILD_CMD = 'sphinx-build' # ensure on PATH 

39"""Sphinx build command name.""" 

40 

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.""" 

43 

44 

45def run(cmd: List[str], cwd: Path | None = None) -> subprocess.CompletedProcess[str]: 

46 """Helper to run commands with consistent behavior. 

47 

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) 

56 

57 

58def find_repo_root(start: Path | None = None) -> Path: 

59 """Find the repository root directory. 

60 

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. 

63 

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 

76 

77 

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. 

80 

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/...``). 

84 

85 The zip file is created at ``zip_filepath / f"mafw-docs-{tag}.zip"``. 

86 

87 Notes 

88 ----- 

89 - Symlinks are skipped to avoid ambiguous extraction behavior across platforms. 

90 

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) 

105 

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}') 

111 

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}.') 

118 

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) 

128 

129 return zip_path 

130 

131 

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. 

134 

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. 

138 

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}') 

149 

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() 

154 

155 

156def filter_latest_micro(versions: List[Tuple[Version, Any]]) -> List[Tuple[Version, Any]]: 

157 """Keep only the latest micro version per minor (major.minor). 

158 

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()) 

170 

171 

172def filter_stable_tags(tags: List[str], regex: str) -> List[str]: 

173 """Filter tags based on a regular expression pattern. 

174 

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)] 

184 

185 

186def parse_version_tuple(tag: str) -> Tuple[int, ...]: 

187 """Parse vX.Y.Z(.W) into tuple of ints for sorting. 

188 

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) 

206 

207 

208def copy_patch_files(docs_src: Path) -> None: 

209 """Copy patch files needed for older versions. 

210 

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 ] 

222 

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) 

227 

228 

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. 

232 

233 Only three warnings are reported 

234 

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 = [] 

242 

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) 

246 

247 if match: 

248 if match.group(1): 

249 warnings = int(match.group(1)) 

250 

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)) 

255 

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] 

261 

262 if len(warning_lines) > 3: 

263 warning_messages.append(f'... and {len(warning_lines) - 3} more warning(s)') 

264 

265 # Look for error patterns 

266 error_pattern = re.compile(r'ERROR:|CRITICAL:', re.IGNORECASE) 

267 errors = len(error_pattern.findall(log_content)) 

268 

269 return warnings, errors, warning_messages 

270 

271 

272def report_build_status(tag: str, success: bool, log: str, build_type: str = 'HTML') -> None: 

273 """ 

274 Report build status with warning/error summary. 

275 

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) 

286 

287 status_icon = '✅' if success else '❌' 

288 status_text = 'OK' if success else 'FAILED' 

289 

290 print(f'{status_icon} {tag} {build_type} build {status_text}', end='') 

291 

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)})') 

299 

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)') 

306 

307 

308def ensure_sphinx_build_available() -> None: 

309 """Ensure that the Sphinx Python package is available. 

310 

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). 

315 

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. 

320 

321 :raises DevtoolsError: If Sphinx is not available. 

322 """ 

323 import importlib.util 

324 

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 ) 

332 

333 

334def check_multiversion_structure(outdir: Path) -> bool: 

335 """ 

336 Check if multiversion structure exists (other version directories). 

337 

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 

345 

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) 

352 

353 return len(version_dirs) > 0 

354 

355 

356def parse_mafw_docs_zip_filename(file_name: str) -> Tuple[str, str] | None: 

357 """Parse and validate a mafw-docs zip filename. 

358 

359 The accepted filename pattern is: ``mafw-docs-vX.Y.Z.zip``. 

360 

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' 

372 

373 

374def normalize_registry_item(item: str) -> Tuple[str, str]: 

375 """Normalize a registry item into (version, file_name). 

376 

377 The item can be either: 

378 - a version string: ``vX.Y.Z`` 

379 - a file name: ``mafw-docs-vX.Y.Z.zip`` 

380 

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' 

399 

400 

401def iter_local_mafw_docs_zips(zip_dir: Path) -> List[Tuple[str, Path]]: 

402 """List local mafw-docs zip files in a directory. 

403 

404 Only files matching ``mafw-docs-vX.Y.Z.zip`` are returned. 

405 

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 

425 

426 

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. 

429 

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 

458 

459 

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). 

467 

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""" 

562 

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 = [] 

568 

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) 

578 

579 # Sort releases by version (newest first) 

580 release_items.sort(key=lambda x: parse_version_tuple(x['version']), reverse=True) 

581 

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) 

588 

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' 

593 

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""" 

617 

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""" 

626 

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}') 

632 

633 

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. 

638 

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}' 

660 

661 if use_latest_conf or tag in OLD_VERSION_TO_BE_PATCHED: 

662 copy_patch_files(docs_src) 

663 

664 out_for_tag = outdir / tag 

665 out_for_tag.mkdir(parents=True, exist_ok=True) 

666 

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)]) 

676 

677 

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. 

682 

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 

702 

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 

706 

707 if use_latest_conf or tag in OLD_VERSION_TO_BE_PATCHED: 

708 copy_patch_files(docs_src) 

709 

710 latex_out = tmproot / f'{tag}_latex' 

711 latex_out.mkdir(parents=True, exist_ok=True) 

712 

713 sp = run([SPHINX_BUILD_CMD, '-b', 'latex', str(docs_src), str(latex_out)], cwd=worktree_path) 

714 log = sp.stdout 

715 

716 if sp.returncode != 0: 

717 return False, f'Sphinx latex build failed:\n{log}', None 

718 

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) 

727 

728 log += '\n' + sp_pdf.stdout 

729 

730 pdf_files = list(latex_out.glob('*.pdf')) 

731 if not pdf_files: 

732 return False, f'PDF generation failed:\n{log}', None 

733 

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) 

738 

739 success = sp_pdf.returncode == 0 

740 return success, log, pdf_path 

741 

742 finally: 

743 if not keep_tmp: 

744 run(['git', 'worktree', 'remove', '-f', str(worktree_path)])