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
« 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."""
6from __future__ import annotations
8import re
9import shutil
10import sys
11import tempfile
12from pathlib import Path
13from typing import Tuple
15import click
16import tomlkit
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)
63def _coerce_with_zip_file(ctx: click.Context, param: click.Parameter, value: bool) -> bool:
64 """Coerce ``--with-zip-file`` and ``--with-upload-zip`` constraints.
66 If zip creation is disabled, uploading must also be disabled.
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
83def _coerce_with_upload_zip(ctx: click.Context, param: click.Parameter, value: bool) -> bool:
84 """Coerce ``--with-upload-zip`` and ``--with-zip-file`` constraints.
86 If upload is enabled, zip creation is automatically enabled.
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
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()
198 if with_upload_zip:
199 with_zip_file = True
200 if not with_zip_file:
201 with_upload_zip = False
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.')
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)
217 stable_tags = [r[1] for r in versions]
218 print('🌿 Candidate stable tags (sorted):', stable_tags)
220 highest = stable_tags[-1]
221 print('🏷️ Highest stable tag:', highest)
223 tmproot = Path(tempfile.mkdtemp(prefix='mafw-docs-'))
224 print('🔧 Temporary root:', tmproot)
226 versions_list = []
227 pdf_info_list = []
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
236 for tag in stable_tags:
237 used_cache_for_tag = False
238 success = False
239 log = ''
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.')
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')
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')
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)
294 pdf_info_list.append(
295 {
296 'version': tag,
297 'label': 'stable' if tag == highest else 'release',
298 'built': pdf_built,
299 }
300 )
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
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}')
322 mirror_version(outdir, highest, 'stable', use_symlink=use_symlinks)
324 for group in REQUIREMENTS_GROUPS:
325 generate_requirements_rst(group, repo_root=find_repo_root())
327 generate_python_versions_rst(repo_root=find_repo_root())
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')
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)
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)
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')
366 with open(latest_out / 'latest_pdf_build.log', 'w', encoding='utf-8') as f:
367 f.write(pdf_log)
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.')
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')
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']})
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'})
392 write_versions_json(outdir, versions_json)
393 write_legacy_redirect_page(outdir)
395 build_root = outdir.parent
396 write_root_landing_page(build_root, project_name.replace(' documentation', ''))
397 write_redirects_file(build_root)
399 if build_pdf:
400 generate_pdf_index_page(outdir, pdf_info_list, project_name)
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)
411 if not keep_temp:
412 try:
413 shutil.rmtree(tmproot)
414 except Exception:
415 pass
417 print('🎉 All done. Built versions placed under:', outdir)
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).
436 Places output in the 'latest' folder.
437 """
438 ensure_sphinx_build_available()
439 outdir = Path(outdir).resolve()
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)
451 generated_docs = Path('docs') / 'source' / 'generated'
452 if generated_docs.exists():
453 print(f' - Removing {generated_docs}')
454 shutil.rmtree(generated_docs)
456 print('📘 Building documentation for current working tree...')
458 has_other_versions = check_multiversion_structure(outdir)
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')
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]: ')
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)
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)
481 latest_out = outdir / 'latest'
482 latest_out.mkdir(parents=True, exist_ok=True)
484 for group in REQUIREMENTS_GROUPS:
485 generate_requirements_rst(group, repo_root=find_repo_root())
487 generate_python_versions_rst(repo_root=find_repo_root())
489 print('\n🔨 Building HTML...')
490 sp = run([SPHINX_BUILD_CMD, '-b', 'html', str(curr_docs), str(latest_out)])
492 log_file = latest_out / 'sphinx-build.log'
493 with open(log_file, 'w', encoding='utf-8') as f:
494 f.write(sp.stdout)
496 html_success = sp.returncode == 0
497 report_build_status('latest', html_success, sp.stdout, 'HTML')
499 if not html_success:
500 print(f'❌ HTML build failed. Check log: {log_file}')
501 sys.exit(1)
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')
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
518 try:
519 latex_out = tmproot / 'latex'
520 latex_out.mkdir(parents=True, exist_ok=True)
522 sp = run([SPHINX_BUILD_CMD, '-b', 'latex', str(curr_docs), str(latex_out)])
523 pdf_log = sp.stdout
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
537 if sp_pdf:
538 pdf_log += '\n' + sp_pdf.stdout
539 pdf_files = list(latex_out.glob('*.pdf'))
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')
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')
554 with open(latest_out / 'latest_pdf_build.log', 'w', encoding='utf-8') as f:
555 f.write(pdf_log)
557 finally:
558 shutil.rmtree(tmproot)
560 if not pdf_success:
561 print(f'❌ PDF build failed. Check log: {latest_out / "latest_pdf_build.log"}')
562 sys.exit(1)
564 print('\n✅ Documentation built successfully!')
565 print(f'📂 Output: {latest_out}')
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()
577 redirects_content = f"""# Redirects for GitLab Pages
578# See: https://docs.gitlab.com/ee/user/project/pages/redirects.html
580# Redirect old PDF URL to new PDF downloads page
581{old_pdf_path} {new_pdf_path} 301
582"""
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"""
592 redirects_file = outdir / '_redirects'
593 outdir.mkdir(parents=True, exist_ok=True)
595 with open(redirects_file, 'w', encoding='utf-8') as f:
596 f.write(redirects_content)
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')
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')
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.
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'
644 if not pyproject_path.exists():
645 print(f'❌ Error: {pyproject_path} not found.')
646 return
648 doc = tomlkit.loads(pyproject_path.read_text(encoding='utf-8'))
649 project = doc.get('project', {})
650 optional = project.get('optional-dependencies', {})
652 valid_groups = {'base'} | set(optional.keys())
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.')
660 generate_python_versions_rst(repo_root=find_repo_root())
662 if update_readme:
663 readme_path = repo_root / 'README.rst'
664 if not readme_path.exists():
665 return
667 content = readme_path.read_text(encoding='utf-8')
668 original_content = content
669 replacements: list[tuple[str, str, str]] = []
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
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 )
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 )
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)
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)}')