Coverage for src / mafw / devtools / cli / documentation / build.py: 95%

167 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"""CLI commands for building documentation.""" 

5 

6from __future__ import annotations 

7 

8import re 

9import shutil 

10import sys 

11import tempfile 

12from pathlib import Path 

13from typing import Tuple 

14 

15import click 

16import tomlkit 

17 

18from mafw.devtools.documentation.builder import ( 

19 SPHINX_BUILD_CMD, 

20 build_for_tag, 

21 build_pdf_for_tag, 

22 check_multiversion_structure, 

23 create_docs_zip_for_tag, 

24 ensure_sphinx_build_available, 

25 extract_docs_zip_to_repo_root, 

26 filter_latest_micro, 

27 find_repo_root, 

28 generate_pdf_index_page, 

29 report_build_status, 

30 run, 

31) 

32from mafw.devtools.documentation.requirements import ( 

33 PYTHON_VERSIONS_REQUIREMENTS_FILENAME, 

34 REQUIREMENTS_GROUPS, 

35 generate_python_versions_rst, 

36 generate_requirements_rst, 

37) 

38from mafw.devtools.documentation.versions import ( 

39 ensure_versions_json_exists, 

40 mirror_version, 

41 prune_old_versions, 

42 regenerate_versions_json_after_pruning, 

43 write_legacy_redirect_page, 

44 write_redirects_file, 

45 write_root_landing_page, 

46 write_versions_json, 

47) 

48from mafw.devtools.git import ( 

49 get_git_tags, 

50 git_rev_of, 

51 is_ancestor, 

52) 

53from mafw.devtools.gitlab import ( 

54 GitlabAPIConfiguration, 

55 build_gitlab_api_configuration, 

56) 

57from mafw.devtools.gitlab.docs_registry import ( 

58 download_docs_zip_from_gitlab_generic_registry, 

59 upload_docs_zip_to_gitlab_generic_registry, 

60) 

61 

62 

63def _coerce_with_zip_file(ctx: click.Context, param: click.Parameter, value: bool) -> bool: 

64 """Coerce ``--with-zip-file`` and ``--with-upload-zip`` constraints. 

65 

66 If zip creation is disabled, uploading must also be disabled. 

67 

68 :param ctx: Click context 

69 :type ctx: click.Context 

70 :param param: Click parameter being processed 

71 :type param: click.Parameter 

72 :param value: Parsed option value 

73 :type value: bool 

74 :return: Option value (possibly coerced) 

75 :rtype: bool 

76 """ 

77 _ = param 

78 if value is False: 

79 ctx.params['with_upload_zip'] = False 

80 return value 

81 

82 

83def _coerce_with_upload_zip(ctx: click.Context, param: click.Parameter, value: bool) -> bool: 

84 """Coerce ``--with-upload-zip`` and ``--with-zip-file`` constraints. 

85 

86 If upload is enabled, zip creation is automatically enabled. 

87 

88 :param ctx: Click context 

89 :type ctx: click.Context 

90 :param param: Click parameter being processed 

91 :type param: click.Parameter 

92 :param value: Parsed option value 

93 :type value: bool 

94 :return: Option value (possibly coerced) 

95 :rtype: bool 

96 """ 

97 _ = param 

98 if value is True: 

99 ctx.params['with_zip_file'] = True 

100 return value 

101 

102 

103@click.command() 

104@click.option('--outdir', '-o', default='docs/build/doc', help='Output directory (docs/build/doc)') 

105@click.option( 

106 '--include-dev/--no-include-dev', 

107 is_flag=True, 

108 help='If true and current branch is ahead of stable, create dev redirect. (True)', 

109) 

110@click.option('--min-vers', default='v1.0.0', help='Minimum version to consider (default: v1.0.0).') 

111@click.option('--keep-temp/--no-keep-temp', default=False, help='Do not remove temp dir (for debugging).') 

112@click.option( 

113 '--use-latest-conf/--no-use-latest-conf', 

114 is_flag=True, 

115 default=True, 

116 help='Use the latest conf.py for all builds. (True)', 

117) 

118@click.option('--build-pdf/--no-build-pdf', is_flag=True, default=False, help='Also build PDF versions. (False)') 

119@click.option('--project-name', default='MAFw documentation', help='Project name for PDF index page.') 

120@click.option( 

121 '--use-symlinks/--no-use-symlinks', 

122 is_flag=True, 

123 default=True, 

124 help='Use symlinks for stable/dev aliases instead of copying. (True)', 

125) 

126@click.option( 

127 '--max-size', '-s', default=0, help='Maximum artifact size in MB. If exceeded, prune old versions (0 = no limit)' 

128) 

129@click.option( 

130 '--zip-filepath', 

131 default='.', 

132 type=click.Path(file_okay=False, dir_okay=True, path_type=Path), 

133 help='Directory where per-tag zip archives are written when --with-zip-file is enabled. (.)', 

134) 

135@click.option( 

136 '--with-zip-file/--without-zip-file', 

137 is_flag=True, 

138 default=False, 

139 callback=_coerce_with_zip_file, 

140 help='Create a per-tag zip archive (mafw-docs-<tag>.zip) for each stable tag under --outdir.', 

141) 

142@click.option( 

143 '--with-upload-zip/--without-upload-zip', 

144 is_flag=True, 

145 default=False, 

146 callback=_coerce_with_upload_zip, 

147 help='Upload the per-tag documentation zip to the GitLab Generic Package Registry. Implies --with-zip-file.', 

148) 

149@click.option( 

150 '--with-cached-packages/--without-cached-packages', 

151 is_flag=True, 

152 default=False, 

153 help='Use cached documentation zip packages from the GitLab Generic Package Registry instead of rebuilding tags.', 

154) 

155@click.option( 

156 '--gitlab-api-url', 

157 default=None, 

158 envvar='CI_API_V4_URL', 

159 help='GitLab API v4 base URL (env: CI_API_V4_URL).', 

160) 

161@click.option( 

162 '--gitlab-project-id', 

163 default=None, 

164 type=int, 

165 envvar='CI_PROJECT_ID', 

166 help='GitLab project numeric id (env: CI_PROJECT_ID).', 

167) 

168@click.option( 

169 '--gitlab-token', 

170 default=None, 

171 envvar='CI_JOB_TOKEN', 

172 help='GitLab token value (env: CI_JOB_TOKEN).', 

173) 

174def build( # pragma: no cover — complex CI orchestration tested via integration pipeline 

175 outdir: Path, 

176 include_dev: bool, 

177 min_vers: str, 

178 keep_temp: bool, 

179 use_latest_conf: bool, 

180 build_pdf: bool, 

181 project_name: str, 

182 use_symlinks: bool, 

183 max_size: int, 

184 zip_filepath: Path, 

185 with_zip_file: bool, 

186 with_upload_zip: bool, 

187 with_cached_packages: bool, 

188 gitlab_api_url: str | None, 

189 gitlab_project_id: int | None, 

190 gitlab_token: str | None, 

191) -> None: 

192 """Build multiversion documentation.""" 

193 ensure_sphinx_build_available() 

194 outdir = Path(outdir).resolve() 

195 outdir.mkdir(parents=True, exist_ok=True) 

196 zip_filepath = Path(zip_filepath).resolve() 

197 

198 if with_upload_zip: 

199 with_zip_file = True 

200 if not with_zip_file: 

201 with_upload_zip = False 

202 

203 print('🔍 Fetching remote tags...') 

204 p = run(['git', 'fetch', '--tags', '--quiet']) 

205 if p.returncode != 0: 

206 print('⚠️ Warning: git fetch --tags failed. Continuing with local tags.') 

207 print(f' Error output: {p.stdout[:200]}...' if p.stdout else ' (no output)') 

208 print(' This is normal in CI if tags are already present or fetch is restricted.') 

209 

210 print('🔍 Collecting git tags...') 

211 versions = get_git_tags(min_vers) 

212 versions = filter_latest_micro(versions) 

213 if not versions: 

214 print('No valid tags found. Aborting.') 

215 sys.exit(1) 

216 

217 stable_tags = [r[1] for r in versions] 

218 print('🌿 Candidate stable tags (sorted):', stable_tags) 

219 

220 highest = stable_tags[-1] 

221 print('🏷️ Highest stable tag:', highest) 

222 

223 tmproot = Path(tempfile.mkdtemp(prefix='mafw-docs-')) 

224 print('🔧 Temporary root:', tmproot) 

225 

226 versions_list = [] 

227 pdf_info_list = [] 

228 

229 api_config: GitlabAPIConfiguration | None = None 

230 if with_cached_packages or with_upload_zip: 

231 try: 

232 api_config = build_gitlab_api_configuration(gitlab_api_url, gitlab_project_id, gitlab_token) 

233 except ValueError as e: 

234 raise click.ClickException(str(e)) from e 

235 

236 for tag in stable_tags: 

237 used_cache_for_tag = False 

238 success = False 

239 log = '' 

240 

241 if with_cached_packages and api_config is not None: 

242 print(f'📦 Trying cached docs package for tag {tag} ...') 

243 downloaded = download_docs_zip_from_gitlab_generic_registry(api_config, tag, zip_filepath) 

244 if downloaded is not None: 

245 try: 

246 extract_docs_zip_to_repo_root(downloaded) 

247 html_tag_dir = outdir / tag 

248 if not html_tag_dir.exists(): 

249 raise FileNotFoundError(f'Expected extracted directory missing: {html_tag_dir}') 

250 used_cache_for_tag = True 

251 success = True 

252 log = 'Used cached package from GitLab Generic Package Registry.' 

253 print(f'✅ Using cached docs for tag {tag} (downloaded + extracted)') 

254 except Exception as e: 

255 print(f'⚠️ Warning: cached package failed for {tag} ({e}); falling back to build.') 

256 

257 if not used_cache_for_tag: 

258 print(f'📘 Building HTML for tag {tag} ...') 

259 success, log = build_for_tag(tag, outdir, tmproot, use_latest_conf=use_latest_conf, keep_tmp=keep_temp) 

260 versions_list.append( 

261 { 

262 'version': tag, 

263 'label': 'stable' if tag == highest else 'release', 

264 'built': success, 

265 } 

266 ) 

267 report_build_status(tag, success, log, 'HTML') 

268 

269 pdf_built = False 

270 if build_pdf: 

271 html_tag_dir = outdir / tag 

272 expected_pdf = html_tag_dir / f'{tag}.pdf' 

273 if used_cache_for_tag and expected_pdf.exists(): 

274 pdf_built = True 

275 pdf_log = 'PDF found in cached package; skipped PDF generation.' 

276 report_build_status(tag, True, pdf_log, 'PDF') 

277 else: 

278 if used_cache_for_tag and not expected_pdf.exists(): 

279 print( 

280 f'⚠️ Cached package for {tag} does not include the PDF; generating it locally. ' 

281 'The used documentation will differ from the cached package.' 

282 ) 

283 print(f'📕 Building PDF for tag {tag} ...') 

284 pdf_success, pdf_log, pdf_path = build_pdf_for_tag( 

285 tag, html_tag_dir, tmproot, use_latest_conf=use_latest_conf, keep_tmp=keep_temp 

286 ) 

287 pdf_built = pdf_success 

288 report_build_status(tag, pdf_success, pdf_log, 'PDF') 

289 

290 if html_tag_dir.exists(): 

291 with open(html_tag_dir / f'{tag}_pdf_build.log', 'w', encoding='utf-8') as f: 

292 f.write(pdf_log) 

293 

294 pdf_info_list.append( 

295 { 

296 'version': tag, 

297 'label': 'stable' if tag == highest else 'release', 

298 'built': pdf_built, 

299 } 

300 ) 

301 

302 if used_cache_for_tag: 

303 if with_upload_zip: 

304 print(f'ℹ️ Upload skipped for {tag} because cached documentation was used.') 

305 continue 

306 

307 if with_zip_file: 

308 try: 

309 zip_path = create_docs_zip_for_tag(outdir, tag, zip_filepath) 

310 print(f'📦 Created docs zip for {tag}: {zip_path}') 

311 if with_upload_zip: 

312 if api_config is None: 

313 raise click.ClickException('GitLab configuration is required to upload documentation zips.') 

314 uploaded = upload_docs_zip_to_gitlab_generic_registry(api_config, tag, zip_path) 

315 if uploaded: 

316 print(f'☁️ Uploaded docs zip for {tag} (mafw-docs/{tag}/{zip_path.name})') 

317 except (FileNotFoundError, NotADirectoryError) as e: 

318 if with_upload_zip: 

319 raise click.ClickException(f'Cannot create/upload zip for {tag}: {e}') from e 

320 print(f'⚠️ Warning: skipping zip creation for {tag}: {e}') 

321 

322 mirror_version(outdir, highest, 'stable', use_symlink=use_symlinks) 

323 

324 for group in REQUIREMENTS_GROUPS: 

325 generate_requirements_rst(group, repo_root=find_repo_root()) 

326 

327 generate_python_versions_rst(repo_root=find_repo_root()) 

328 

329 print("📘 Building latest (current branch) into 'latest' ...") 

330 curr_docs = Path('docs') / 'source' 

331 if curr_docs.exists(): 

332 latest_out = outdir / 'latest' 

333 latest_out.mkdir(parents=True, exist_ok=True) 

334 sp = run([SPHINX_BUILD_CMD, '-b', 'html', str(curr_docs), str(latest_out)]) 

335 with open(latest_out / 'sphinx-build.log', 'w', encoding='utf-8') as f: 

336 f.write(sp.stdout) 

337 latest_ok = sp.returncode == 0 

338 versions_list.append({'version': 'latest', 'label': 'latest', 'built': latest_ok}) 

339 report_build_status('latest', latest_ok, sp.stdout, 'HTML') 

340 

341 latest_pdf_built = False 

342 if build_pdf and curr_docs.exists(): 

343 print('📕 Building PDF for latest ...') 

344 latex_out = tmproot / 'latest_latex' 

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

346 

347 sp = run([SPHINX_BUILD_CMD, '-b', 'latex', str(curr_docs), str(latex_out)]) 

348 pdf_log = sp.stdout 

349 if sp.returncode == 0: 

350 makefile = latex_out / 'Makefile' 

351 if makefile.exists(): 

352 sp_pdf = run(['make'], cwd=latex_out) 

353 else: 

354 tex_files = list(latex_out.glob('*.tex')) 

355 if tex_files: 

356 sp_pdf = run(['pdflatex', '-interaction=nonstopmode', tex_files[0].name], cwd=latex_out) 

357 

358 pdf_log += '\n' + sp_pdf.stdout 

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

360 if pdf_files: 

361 pdf_file = latex_out / 'mafw.pdf' 

362 shutil.copy(pdf_file, latest_out / 'latest.pdf') 

363 latest_pdf_built = True 

364 report_build_status('latest', True, pdf_log, 'PDF') 

365 

366 with open(latest_out / 'latest_pdf_build.log', 'w', encoding='utf-8') as f: 

367 f.write(pdf_log) 

368 

369 pdf_info_list.append({'version': 'latest', 'label': 'latest', 'built': latest_pdf_built}) 

370 else: 

371 print('❌ No local docs/source for latest. Skipping latest build.') 

372 

373 head_rev = git_rev_of('HEAD') 

374 highest_rev = git_rev_of(highest) 

375 dev_label = None 

376 if is_ancestor(highest_rev, head_rev) and head_rev != highest_rev: 

377 dev_label = 'dev' 

378 print('🔍 Current branch is ahead of stable -> creating dev alias') 

379 if include_dev: 

380 mirror_version(outdir, 'latest', dev_label, use_symlink=use_symlinks) 

381 else: 

382 print('🔍 Current branch is not ahead of stable (or identical) -> no dev alias created') 

383 

384 versions_json = [] 

385 for v in versions_list: 

386 versions_json.append({'version': v['version'], 'label': v['label'], 'built': v['built'], 'path': v['version']}) 

387 

388 versions_json.append({'version': 'stable', 'label': 'alias', 'path': highest}) 

389 if dev_label and include_dev: 

390 versions_json.append({'version': 'dev', 'label': 'alias', 'path': 'latest'}) 

391 

392 write_versions_json(outdir, versions_json) 

393 write_legacy_redirect_page(outdir) 

394 

395 build_root = outdir.parent 

396 write_root_landing_page(build_root, project_name.replace(' documentation', '')) 

397 write_redirects_file(build_root) 

398 

399 if build_pdf: 

400 generate_pdf_index_page(outdir, pdf_info_list, project_name) 

401 

402 if max_size > 0: 

403 print(f'\n📏 Checking artifact size (limit: {max_size} MB)...') 

404 removed_versions, final_size = prune_old_versions(outdir, max_size, dry_run=False) 

405 if removed_versions: 

406 regenerate_versions_json_after_pruning(outdir, removed_versions) 

407 if build_pdf: 

408 pdf_info_list = [p for p in pdf_info_list if p['version'] not in removed_versions] 

409 generate_pdf_index_page(outdir, pdf_info_list, project_name) 

410 

411 if not keep_temp: 

412 try: 

413 shutil.rmtree(tmproot) 

414 except Exception: 

415 pass 

416 

417 print('🎉 All done. Built versions placed under:', outdir) 

418 

419 

420@click.command(name='current') 

421@click.option('--outdir', '-o', default='docs/build/doc', help='Output directory (docs/build/doc)') 

422@click.option('--build-pdf/--no-build-pdf', is_flag=True, default=False, help='Also build PDF versions. (False)') 

423@click.option('--yes', '-y', is_flag=True, default=False, help='Automatically answer yes to all questions.') 

424@click.option( 

425 '--from-scratch', is_flag=True, default=False, help='Remove output and generated folders before building.' 

426) 

427def build_current_only( 

428 outdir: Path, 

429 build_pdf: bool = False, 

430 project_name: str = 'Documentation', 

431 yes: bool = False, 

432 from_scratch: bool = False, 

433) -> None: 

434 """Build documentation only for the current working tree (no git worktrees). 

435 

436 Places output in the 'latest' folder. 

437 """ 

438 ensure_sphinx_build_available() 

439 outdir = Path(outdir).resolve() 

440 

441 if from_scratch: 

442 print('🧹 Cleaning up before building from scratch...') 

443 latest_out = outdir / 'latest' 

444 if latest_out.exists(): 444 ↛ 451line 444 didn't jump to line 451 because the condition on line 444 was always true

445 print(f' - Removing {latest_out}') 

446 if latest_out.is_symlink(): 446 ↛ 447line 446 didn't jump to line 447 because the condition on line 446 was never true

447 latest_out.unlink() 

448 else: 

449 shutil.rmtree(latest_out) 

450 

451 generated_docs = Path('docs') / 'source' / 'generated' 

452 if generated_docs.exists(): 

453 print(f' - Removing {generated_docs}') 

454 shutil.rmtree(generated_docs) 

455 

456 print('📘 Building documentation for current working tree...') 

457 

458 has_other_versions = check_multiversion_structure(outdir) 

459 

460 if not has_other_versions: 

461 print('\n⚠️ Warning: No other version directories found!') 

462 print(' The version switcher and navigation may not work correctly.') 

463 print(' Consider running the full build at least once:') 

464 print(' $ multiversion-doc build') 

465 

466 if yes: 466 ↛ 467line 466 didn't jump to line 467 because the condition on line 466 was never true

467 print('\nContinue anyway? [y/N]: y (auto)') 

468 response = 'y' 

469 else: 

470 response = input('\nContinue anyway? [y/N]: ') 

471 

472 if response.lower() not in ('y', 'yes'): 472 ↛ 476line 472 didn't jump to line 476 because the condition on line 472 was always true

473 print('❌ Aborted') 

474 sys.exit(0) 

475 

476 curr_docs = Path('docs') / 'source' 

477 if not curr_docs.exists(): 

478 print(f'❌ Documentation source not found: {curr_docs}') 

479 sys.exit(1) 

480 

481 latest_out = outdir / 'latest' 

482 latest_out.mkdir(parents=True, exist_ok=True) 

483 

484 for group in REQUIREMENTS_GROUPS: 

485 generate_requirements_rst(group, repo_root=find_repo_root()) 

486 

487 generate_python_versions_rst(repo_root=find_repo_root()) 

488 

489 print('\n🔨 Building HTML...') 

490 sp = run([SPHINX_BUILD_CMD, '-b', 'html', str(curr_docs), str(latest_out)]) 

491 

492 log_file = latest_out / 'sphinx-build.log' 

493 with open(log_file, 'w', encoding='utf-8') as f: 

494 f.write(sp.stdout) 

495 

496 html_success = sp.returncode == 0 

497 report_build_status('latest', html_success, sp.stdout, 'HTML') 

498 

499 if not html_success: 

500 print(f'❌ HTML build failed. Check log: {log_file}') 

501 sys.exit(1) 

502 

503 print('\n🔍 Checking for versions.json...') 

504 if not ensure_versions_json_exists(outdir): 

505 print('⚠️ Version switcher may not work without versions.json') 

506 print(' Run the full build to generate it:') 

507 print(' $ python doc_versioning.py build') 

508 else: 

509 shutil.copy(outdir / 'versions.json', latest_out / 'versions.json') 

510 shutil.copy(outdir / 'versions.json', latest_out / 'generated/versions.json') 

511 print('✅ versions.json is available') 

512 

513 if build_pdf: # pragma: no cover — PDF generation requires LaTeX toolchain 

514 print('\n🔨 Building PDF...') 

515 tmproot = Path(tempfile.mkdtemp(prefix='mafw-docs-current-')) 

516 pdf_success = False 

517 

518 try: 

519 latex_out = tmproot / 'latex' 

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

521 

522 sp = run([SPHINX_BUILD_CMD, '-b', 'latex', str(curr_docs), str(latex_out)]) 

523 pdf_log = sp.stdout 

524 

525 if sp.returncode == 0: 

526 makefile = latex_out / 'Makefile' 

527 if makefile.exists(): 

528 sp_pdf = run(['make'], cwd=latex_out) 

529 else: 

530 tex_files = list(latex_out.glob('*.tex')) 

531 if tex_files: 

532 sp_pdf = run(['pdflatex', '-interaction=nonstopmode', tex_files[0].name], cwd=latex_out) 

533 else: 

534 print('❌ No .tex file found') 

535 sp_pdf = None 

536 

537 if sp_pdf: 

538 pdf_log += '\n' + sp_pdf.stdout 

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

540 

541 if pdf_files: 

542 pdf_path = latest_out / 'latest.pdf' 

543 shutil.copy(pdf_files[0], pdf_path) 

544 pdf_success = sp_pdf.returncode == 0 

545 report_build_status('latest', pdf_success, pdf_log, 'PDF') 

546 

547 if pdf_success: 

548 print(f'📄 PDF saved to: {pdf_path}') 

549 else: 

550 print('❌ PDF generation failed: no PDF file produced') 

551 else: 

552 print('❌ LaTeX build failed') 

553 

554 with open(latest_out / 'latest_pdf_build.log', 'w', encoding='utf-8') as f: 

555 f.write(pdf_log) 

556 

557 finally: 

558 shutil.rmtree(tmproot) 

559 

560 if not pdf_success: 

561 print(f'❌ PDF build failed. Check log: {latest_out / "latest_pdf_build.log"}') 

562 sys.exit(1) 

563 

564 print('\n✅ Documentation built successfully!') 

565 print(f'📂 Output: {latest_out}') 

566 

567 

568@click.command() 

569@click.option('--outdir', '-o', default='docs/build/doc', help='Output directory for _redirects file') 

570@click.option('--old-pdf-path', default='/doc/mafw.pdf', help='Old PDF URL path to redirect from') 

571@click.option('--new-pdf-path', default='/doc/pdf_downloads.html', help='New PDF downloads page to redirect to') 

572@click.option('--redirect-root/--no-redirect-root', default=True, help='Redirect /doc/ root to stable') 

573def redirects(outdir: Path, old_pdf_path: Path, new_pdf_path: Path, redirect_root: bool) -> None: 

574 """Generate _redirects file for GitLab Pages.""" 

575 outdir = Path(outdir).resolve() 

576 

577 redirects_content = f"""# Redirects for GitLab Pages 

578# See: https://docs.gitlab.com/ee/user/project/pages/redirects.html 

579 

580# Redirect old PDF URL to new PDF downloads page 

581{old_pdf_path} {new_pdf_path} 301 

582""" 

583 

584 if redirect_root: 

585 redirects_content += """ 

586# Redirect /doc root to stable documentation 

587# Note: These are specific patterns to avoid redirecting /doc/pdf_downloads.html 

588/doc/ /doc/stable/ 301 

589/doc/index.html /doc/stable/index.html 301 

590""" 

591 

592 redirects_file = outdir / '_redirects' 

593 outdir.mkdir(parents=True, exist_ok=True) 

594 

595 with open(redirects_file, 'w', encoding='utf-8') as f: 

596 f.write(redirects_content) 

597 

598 print(f'🔀 Generated _redirects file: {redirects_file}') 

599 print(f' Redirects {old_pdf_path}{new_pdf_path} (301)') 

600 if redirect_root: 

601 print(' Redirects /doc/ → /doc/stable/ (301)') 

602 print(' Redirects /doc/index.html → /doc/stable/index.html (301)') 

603 print('\n📋 GitLab CI/CD setup:') 

604 print(' Make sure your .gitlab-ci.yml copies this file to public/ root:') 

605 print(' ') 

606 print(' pages:') 

607 print(' script:') 

608 print(' - mkdir -p public') 

609 print(f' - cp -r {outdir}/* public/doc/') 

610 print(f' - cp {redirects_file} public/_redirects') 

611 print(' artifacts:') 

612 print(' paths:') 

613 print(' - public') 

614 

615 

616@click.command() 

617@click.option('--build-root', '-b', default='docs/build', help='Build root directory containing doc/ subdirectory') 

618@click.option('--project-name', default='MAFw', help='Project name for the landing page') 

619def landing(build_root: Path, project_name: str) -> None: 

620 """Generate root landing page for project.""" 

621 build_root = Path(build_root).resolve() 

622 write_root_landing_page(build_root, project_name) 

623 print('\n📋 GitLab CI/CD: Copy this to public/index.html:') 

624 print(f' cp {build_root}/index.html public/index.html') 

625 

626 

627@click.command() 

628@click.argument('groups', nargs=-1, required=True) 

629@click.option( 

630 '--update-readme/--no-update-readme', 

631 is_flag=True, 

632 default=False, 

633 show_default=True, 

634 help='Update README.rst with generated requirements.', 

635) 

636def requirements(groups: Tuple[str, ...], update_readme: bool) -> None: 

637 """Generate RST requirement files from pyproject.toml. 

638 

639 GROUPS is a list of dependency groups to process (e.g., 'base', 'seaborn'). 

640 """ 

641 repo_root = find_repo_root() 

642 pyproject_path = repo_root / 'pyproject.toml' 

643 

644 if not pyproject_path.exists(): 

645 print(f'❌ Error: {pyproject_path} not found.') 

646 return 

647 

648 doc = tomlkit.loads(pyproject_path.read_text(encoding='utf-8')) 

649 project = doc.get('project', {}) 

650 optional = project.get('optional-dependencies', {}) 

651 

652 valid_groups = {'base'} | set(optional.keys()) 

653 

654 for group in groups: 

655 if group in valid_groups: 

656 generate_requirements_rst(group, repo_root=find_repo_root()) 

657 else: 

658 print(f'⚠️ Warning: dependency group "{group}" not found in pyproject.toml.') 

659 

660 generate_python_versions_rst(repo_root=find_repo_root()) 

661 

662 if update_readme: 

663 readme_path = repo_root / 'README.rst' 

664 if not readme_path.exists(): 

665 return 

666 

667 content = readme_path.read_text(encoding='utf-8') 

668 original_content = content 

669 replacements: list[tuple[str, str, str]] = [] 

670 

671 for group in groups: 

672 if group not in valid_groups: 672 ↛ 673line 672 didn't jump to line 673 because the condition on line 672 was never true

673 continue 

674 

675 req_file = repo_root / 'docs' / 'source' / 'requirements' / f'{group}_requirements.rst' 

676 if req_file.exists(): 676 ↛ 671line 676 didn't jump to line 671 because the condition on line 676 was always true

677 replacements.append( 

678 ( 

679 f'.. BEGIN GENERATED REQUIREMENTS {group.upper()}', 

680 f'.. END GENERATED REQUIREMENTS {group.upper()}', 

681 req_file.read_text(encoding='utf-8').strip(), 

682 ) 

683 ) 

684 

685 versions_file = repo_root / 'docs' / 'source' / 'requirements' / PYTHON_VERSIONS_REQUIREMENTS_FILENAME 

686 if versions_file.exists(): 

687 replacements.append( 

688 ( 

689 '.. BEGIN GENERATED PYTHON VERSIONS', 

690 '.. END GENERATED PYTHON VERSIONS', 

691 versions_file.read_text(encoding='utf-8').strip(), 

692 ) 

693 ) 

694 

695 for start_marker, end_marker, replacement in replacements: 

696 pattern = re.compile( 

697 rf'({re.escape(start_marker)}\n)(.*?)(\n{re.escape(end_marker)})', 

698 re.DOTALL, 

699 ) 

700 if pattern.search(content): 700 ↛ 695line 700 didn't jump to line 695 because the condition on line 700 was always true

701 content = pattern.sub(rf'\1\n{replacement}\n\3', content) 

702 

703 if content != original_content: 703 ↛ exitline 703 didn't return from function 'requirements' because the condition on line 703 was always true

704 readme_path.write_text(content, encoding='utf-8') 

705 print(f'📝 Updated {readme_path.relative_to(repo_root)}')