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

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 

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

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 from mafw.devtools.documentation.requirements import _load_default_python_version 

327 

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 ) 

335 

336 

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

338 """ 

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

340 

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 

348 

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) 

355 

356 return len(version_dirs) > 0 

357 

358 

359def parse_mafw_docs_zip_filename(file_name: str) -> tuple[str, str] | None: 

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

361 

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

363 

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' 

375 

376 

377def normalize_registry_item(item: str) -> tuple[str, str]: 

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

379 

380 The item can be either: 

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

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

383 

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' 

402 

403 

404def iter_local_mafw_docs_zips(zip_dir: Path) -> list[tuple[str, Path]]: 

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

406 

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

408 

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 

428 

429 

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. 

432 

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 

461 

462 

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

470 

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

565 

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

571 

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) 

581 

582 # Sort releases by version (newest first) 

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

584 

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) 

591 

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' 

596 

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

620 

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

629 

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

635 

636 

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. 

641 

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

663 

664 if use_latest_conf or tag in OLD_VERSION_TO_BE_PATCHED: 

665 copy_patch_files(docs_src) 

666 

667 out_for_tag = outdir / tag 

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

669 

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

679 

680 

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. 

685 

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 

705 

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 

709 

710 if use_latest_conf or tag in OLD_VERSION_TO_BE_PATCHED: 

711 copy_patch_files(docs_src) 

712 

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

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

715 

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

717 log = sp.stdout 

718 

719 if sp.returncode != 0: 

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

721 

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) 

730 

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

732 

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

734 if not pdf_files: 

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

736 

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) 

741 

742 success = sp_pdf.returncode == 0 

743 return success, log, pdf_path 

744 

745 finally: 

746 if not keep_tmp: 

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