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

166 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-26 09:13 +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 

13 

14import click 

15import tomlkit 

16 

17from mafw.devtools.documentation.builder import ( 

18 SPHINX_BUILD_CMD, 

19 build_for_tag, 

20 build_pdf_for_tag, 

21 check_multiversion_structure, 

22 create_docs_zip_for_tag, 

23 ensure_sphinx_build_available, 

24 extract_docs_zip_to_repo_root, 

25 filter_latest_micro, 

26 find_repo_root, 

27 generate_pdf_index_page, 

28 report_build_status, 

29 run, 

30) 

31from mafw.devtools.documentation.requirements import ( 

32 PYTHON_VERSIONS_REQUIREMENTS_FILENAME, 

33 REQUIREMENTS_GROUPS, 

34 generate_python_versions_rst, 

35 generate_requirements_rst, 

36) 

37from mafw.devtools.documentation.versions import ( 

38 ensure_versions_json_exists, 

39 mirror_version, 

40 prune_old_versions, 

41 regenerate_versions_json_after_pruning, 

42 write_legacy_redirect_page, 

43 write_redirects_file, 

44 write_root_landing_page, 

45 write_versions_json, 

46) 

47from mafw.devtools.git import ( 

48 get_git_tags, 

49 git_rev_of, 

50 is_ancestor, 

51) 

52from mafw.devtools.gitlab import ( 

53 GitlabAPIConfiguration, 

54 build_gitlab_api_configuration, 

55) 

56from mafw.devtools.gitlab.docs_registry import ( 

57 download_docs_zip_from_gitlab_generic_registry, 

58 upload_docs_zip_to_gitlab_generic_registry, 

59) 

60 

61 

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

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

64 

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

66 

67 :param ctx: Click context 

68 :type ctx: click.Context 

69 :param param: Click parameter being processed 

70 :type param: click.Parameter 

71 :param value: Parsed option value 

72 :type value: bool 

73 :return: Option value (possibly coerced) 

74 :rtype: bool 

75 """ 

76 _ = param 

77 if value is False: 

78 ctx.params['with_upload_zip'] = False 

79 return value 

80 

81 

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

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

84 

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

86 

87 :param ctx: Click context 

88 :type ctx: click.Context 

89 :param param: Click parameter being processed 

90 :type param: click.Parameter 

91 :param value: Parsed option value 

92 :type value: bool 

93 :return: Option value (possibly coerced) 

94 :rtype: bool 

95 """ 

96 _ = param 

97 if value is True: 

98 ctx.params['with_zip_file'] = True 

99 return value 

100 

101 

102@click.command() 

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

104@click.option( 

105 '--include-dev/--no-include-dev', 

106 is_flag=True, 

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

108) 

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

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

111@click.option( 

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

113 is_flag=True, 

114 default=True, 

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

116) 

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

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

119@click.option( 

120 '--use-symlinks/--no-use-symlinks', 

121 is_flag=True, 

122 default=True, 

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

124) 

125@click.option( 

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

127) 

128@click.option( 

129 '--zip-filepath', 

130 default='.', 

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

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

133) 

134@click.option( 

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

136 is_flag=True, 

137 default=False, 

138 callback=_coerce_with_zip_file, 

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

140) 

141@click.option( 

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

143 is_flag=True, 

144 default=False, 

145 callback=_coerce_with_upload_zip, 

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

147) 

148@click.option( 

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

150 is_flag=True, 

151 default=False, 

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

153) 

154@click.option( 

155 '--gitlab-api-url', 

156 default=None, 

157 envvar='CI_API_V4_URL', 

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

159) 

160@click.option( 

161 '--gitlab-project-id', 

162 default=None, 

163 type=int, 

164 envvar='CI_PROJECT_ID', 

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

166) 

167@click.option( 

168 '--gitlab-token', 

169 default=None, 

170 envvar='CI_JOB_TOKEN', 

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

172) 

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

174 outdir: Path, 

175 include_dev: bool, 

176 min_vers: str, 

177 keep_temp: bool, 

178 use_latest_conf: bool, 

179 build_pdf: bool, 

180 project_name: str, 

181 use_symlinks: bool, 

182 max_size: int, 

183 zip_filepath: Path, 

184 with_zip_file: bool, 

185 with_upload_zip: bool, 

186 with_cached_packages: bool, 

187 gitlab_api_url: str | None, 

188 gitlab_project_id: int | None, 

189 gitlab_token: str | None, 

190) -> None: 

191 """Build multiversion documentation.""" 

192 ensure_sphinx_build_available() 

193 outdir = Path(outdir).resolve() 

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

195 zip_filepath = Path(zip_filepath).resolve() 

196 

197 if with_upload_zip: 

198 with_zip_file = True 

199 if not with_zip_file: 

200 with_upload_zip = False 

201 

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

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

204 if p.returncode != 0: 

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

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

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

208 

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

210 versions = get_git_tags(min_vers) 

211 versions = filter_latest_micro(versions) 

212 if not versions: 

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

214 sys.exit(1) 

215 

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

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

218 

219 highest = stable_tags[-1] 

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

221 

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

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

224 

225 versions_list = [] 

226 pdf_info_list = [] 

227 

228 api_config: GitlabAPIConfiguration | None = None 

229 if with_cached_packages or with_upload_zip: 

230 try: 

231 api_config = build_gitlab_api_configuration(gitlab_api_url, gitlab_project_id, gitlab_token) 

232 except ValueError as e: 

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

234 

235 for tag in stable_tags: 

236 used_cache_for_tag = False 

237 success = False 

238 log = '' 

239 

240 if with_cached_packages and api_config is not None: 

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

242 downloaded = download_docs_zip_from_gitlab_generic_registry(api_config, tag, zip_filepath) 

243 if downloaded is not None: 

244 try: 

245 extract_docs_zip_to_repo_root(downloaded) 

246 html_tag_dir = outdir / tag 

247 if not html_tag_dir.exists(): 

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

249 used_cache_for_tag = True 

250 success = True 

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

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

253 except Exception as e: 

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

255 

256 if not used_cache_for_tag: 

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

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

259 versions_list.append( 

260 { 

261 'version': tag, 

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

263 'built': success, 

264 } 

265 ) 

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

267 

268 pdf_built = False 

269 if build_pdf: 

270 html_tag_dir = outdir / tag 

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

272 if used_cache_for_tag and expected_pdf.exists(): 

273 pdf_built = True 

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

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

276 else: 

277 if used_cache_for_tag and not expected_pdf.exists(): 

278 print( 

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

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

281 ) 

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

283 pdf_success, pdf_log, pdf_path = build_pdf_for_tag( 

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

285 ) 

286 pdf_built = pdf_success 

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

288 

289 if html_tag_dir.exists(): 

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

291 f.write(pdf_log) 

292 

293 pdf_info_list.append( 

294 { 

295 'version': tag, 

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

297 'built': pdf_built, 

298 } 

299 ) 

300 

301 if used_cache_for_tag: 

302 if with_upload_zip: 

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

304 continue 

305 

306 if with_zip_file: 

307 try: 

308 zip_path = create_docs_zip_for_tag(outdir, tag, zip_filepath) 

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

310 if with_upload_zip: 

311 if api_config is None: 

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

313 uploaded = upload_docs_zip_to_gitlab_generic_registry(api_config, tag, zip_path) 

314 if uploaded: 

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

316 except (FileNotFoundError, NotADirectoryError) as e: 

317 if with_upload_zip: 

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

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

320 

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

322 

323 for group in REQUIREMENTS_GROUPS: 

324 generate_requirements_rst(group, repo_root=find_repo_root()) 

325 

326 generate_python_versions_rst(repo_root=find_repo_root()) 

327 

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

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

330 if curr_docs.exists(): 

331 latest_out = outdir / 'latest' 

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

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

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

335 f.write(sp.stdout) 

336 latest_ok = sp.returncode == 0 

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

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

339 

340 latest_pdf_built = False 

341 if build_pdf and curr_docs.exists(): 

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

343 latex_out = tmproot / 'latest_latex' 

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

345 

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

347 pdf_log = sp.stdout 

348 if sp.returncode == 0: 

349 makefile = latex_out / 'Makefile' 

350 if makefile.exists(): 

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

352 else: 

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

354 if tex_files: 

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

356 

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

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

359 if pdf_files: 

360 pdf_file = latex_out / 'mafw.pdf' 

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

362 latest_pdf_built = True 

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

364 

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

366 f.write(pdf_log) 

367 

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

369 else: 

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

371 

372 head_rev = git_rev_of('HEAD') 

373 highest_rev = git_rev_of(highest) 

374 dev_label = None 

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

376 dev_label = 'dev' 

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

378 if include_dev: 

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

380 else: 

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

382 

383 versions_json = [] 

384 for v in versions_list: 

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

386 

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

388 if dev_label and include_dev: 

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

390 

391 write_versions_json(outdir, versions_json) 

392 write_legacy_redirect_page(outdir) 

393 

394 build_root = outdir.parent 

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

396 write_redirects_file(build_root) 

397 

398 if build_pdf: 

399 generate_pdf_index_page(outdir, pdf_info_list, project_name) 

400 

401 if max_size > 0: 

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

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

404 if removed_versions: 

405 regenerate_versions_json_after_pruning(outdir, removed_versions) 

406 if build_pdf: 

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

408 generate_pdf_index_page(outdir, pdf_info_list, project_name) 

409 

410 if not keep_temp: 

411 try: 

412 shutil.rmtree(tmproot) 

413 except Exception: 

414 pass 

415 

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

417 

418 

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

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

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

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

423@click.option( 

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

425) 

426def build_current_only( 

427 outdir: Path, 

428 build_pdf: bool = False, 

429 project_name: str = 'Documentation', 

430 yes: bool = False, 

431 from_scratch: bool = False, 

432) -> None: 

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

434 

435 Places output in the 'latest' folder. 

436 """ 

437 ensure_sphinx_build_available() 

438 outdir = Path(outdir).resolve() 

439 

440 if from_scratch: 

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

442 latest_out = outdir / 'latest' 

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

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

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

446 latest_out.unlink() 

447 else: 

448 shutil.rmtree(latest_out) 

449 

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

451 if generated_docs.exists(): 

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

453 shutil.rmtree(generated_docs) 

454 

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

456 

457 has_other_versions = check_multiversion_structure(outdir) 

458 

459 if not has_other_versions: 

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

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

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

463 print(' $ multiversion-doc build') 

464 

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

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

467 response = 'y' 

468 else: 

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

470 

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

472 print('❌ Aborted') 

473 sys.exit(0) 

474 

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

476 if not curr_docs.exists(): 

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

478 sys.exit(1) 

479 

480 latest_out = outdir / 'latest' 

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

482 

483 for group in REQUIREMENTS_GROUPS: 

484 generate_requirements_rst(group, repo_root=find_repo_root()) 

485 

486 generate_python_versions_rst(repo_root=find_repo_root()) 

487 

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

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

490 

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

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

493 f.write(sp.stdout) 

494 

495 html_success = sp.returncode == 0 

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

497 

498 if not html_success: 

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

500 sys.exit(1) 

501 

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

503 if not ensure_versions_json_exists(outdir): 

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

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

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

507 else: 

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

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

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

511 

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

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

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

515 pdf_success = False 

516 

517 try: 

518 latex_out = tmproot / 'latex' 

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

520 

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

522 pdf_log = sp.stdout 

523 

524 if sp.returncode == 0: 

525 makefile = latex_out / 'Makefile' 

526 if makefile.exists(): 

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

528 else: 

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

530 if tex_files: 

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

532 else: 

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

534 sp_pdf = None 

535 

536 if sp_pdf: 

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

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

539 

540 if pdf_files: 

541 pdf_path = latest_out / 'latest.pdf' 

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

543 pdf_success = sp_pdf.returncode == 0 

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

545 

546 if pdf_success: 

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

548 else: 

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

550 else: 

551 print('❌ LaTeX build failed') 

552 

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

554 f.write(pdf_log) 

555 

556 finally: 

557 shutil.rmtree(tmproot) 

558 

559 if not pdf_success: 

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

561 sys.exit(1) 

562 

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

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

565 

566 

567@click.command() 

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

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

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

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

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

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

574 outdir = Path(outdir).resolve() 

575 

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

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

578 

579# Redirect old PDF URL to new PDF downloads page 

580{old_pdf_path} {new_pdf_path} 301 

581""" 

582 

583 if redirect_root: 

584 redirects_content += """ 

585# Redirect /doc root to stable documentation 

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

587/doc/ /doc/stable/ 301 

588/doc/index.html /doc/stable/index.html 301 

589""" 

590 

591 redirects_file = outdir / '_redirects' 

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

593 

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

595 f.write(redirects_content) 

596 

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

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

599 if redirect_root: 

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

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

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

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

604 print(' ') 

605 print(' pages:') 

606 print(' script:') 

607 print(' - mkdir -p public') 

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

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

610 print(' artifacts:') 

611 print(' paths:') 

612 print(' - public') 

613 

614 

615@click.command() 

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

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

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

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

620 build_root = Path(build_root).resolve() 

621 write_root_landing_page(build_root, project_name) 

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

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

624 

625 

626@click.command() 

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

628@click.option( 

629 '--update-readme/--no-update-readme', 

630 is_flag=True, 

631 default=False, 

632 show_default=True, 

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

634) 

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

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

637 

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

639 """ 

640 repo_root = find_repo_root() 

641 pyproject_path = repo_root / 'pyproject.toml' 

642 

643 if not pyproject_path.exists(): 

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

645 return 

646 

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

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

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

650 

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

652 

653 for group in groups: 

654 if group in valid_groups: 

655 generate_requirements_rst(group, repo_root=find_repo_root()) 

656 else: 

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

658 

659 generate_python_versions_rst(repo_root=find_repo_root()) 

660 

661 if update_readme: 

662 readme_path = repo_root / 'README.rst' 

663 if not readme_path.exists(): 

664 return 

665 

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

667 original_content = content 

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

669 

670 for group in groups: 

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

672 continue 

673 

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

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

676 replacements.append( 

677 ( 

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

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

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

681 ) 

682 ) 

683 

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

685 if versions_file.exists(): 

686 replacements.append( 

687 ( 

688 '.. BEGIN GENERATED PYTHON VERSIONS', 

689 '.. END GENERATED PYTHON VERSIONS', 

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

691 ) 

692 ) 

693 

694 for start_marker, end_marker, replacement in replacements: 

695 pattern = re.compile( 

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

697 re.DOTALL, 

698 ) 

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

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

701 

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

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

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