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
« 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."""
6from __future__ import annotations
8import re
9import shutil
10import sys
11import tempfile
12from pathlib import Path
14import click
15import tomlkit
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)
62def _coerce_with_zip_file(ctx: click.Context, param: click.Parameter, value: bool) -> bool:
63 """Coerce ``--with-zip-file`` and ``--with-upload-zip`` constraints.
65 If zip creation is disabled, uploading must also be disabled.
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
82def _coerce_with_upload_zip(ctx: click.Context, param: click.Parameter, value: bool) -> bool:
83 """Coerce ``--with-upload-zip`` and ``--with-zip-file`` constraints.
85 If upload is enabled, zip creation is automatically enabled.
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
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()
197 if with_upload_zip:
198 with_zip_file = True
199 if not with_zip_file:
200 with_upload_zip = False
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.')
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)
216 stable_tags = [r[1] for r in versions]
217 print('🌿 Candidate stable tags (sorted):', stable_tags)
219 highest = stable_tags[-1]
220 print('🏷️ Highest stable tag:', highest)
222 tmproot = Path(tempfile.mkdtemp(prefix='mafw-docs-'))
223 print('🔧 Temporary root:', tmproot)
225 versions_list = []
226 pdf_info_list = []
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
235 for tag in stable_tags:
236 used_cache_for_tag = False
237 success = False
238 log = ''
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.')
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')
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')
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)
293 pdf_info_list.append(
294 {
295 'version': tag,
296 'label': 'stable' if tag == highest else 'release',
297 'built': pdf_built,
298 }
299 )
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
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}')
321 mirror_version(outdir, highest, 'stable', use_symlink=use_symlinks)
323 for group in REQUIREMENTS_GROUPS:
324 generate_requirements_rst(group, repo_root=find_repo_root())
326 generate_python_versions_rst(repo_root=find_repo_root())
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')
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)
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)
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')
365 with open(latest_out / 'latest_pdf_build.log', 'w', encoding='utf-8') as f:
366 f.write(pdf_log)
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.')
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')
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']})
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'})
391 write_versions_json(outdir, versions_json)
392 write_legacy_redirect_page(outdir)
394 build_root = outdir.parent
395 write_root_landing_page(build_root, project_name.replace(' documentation', ''))
396 write_redirects_file(build_root)
398 if build_pdf:
399 generate_pdf_index_page(outdir, pdf_info_list, project_name)
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)
410 if not keep_temp:
411 try:
412 shutil.rmtree(tmproot)
413 except Exception:
414 pass
416 print('🎉 All done. Built versions placed under:', outdir)
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).
435 Places output in the 'latest' folder.
436 """
437 ensure_sphinx_build_available()
438 outdir = Path(outdir).resolve()
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)
450 generated_docs = Path('docs') / 'source' / 'generated'
451 if generated_docs.exists():
452 print(f' - Removing {generated_docs}')
453 shutil.rmtree(generated_docs)
455 print('📘 Building documentation for current working tree...')
457 has_other_versions = check_multiversion_structure(outdir)
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')
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]: ')
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)
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)
480 latest_out = outdir / 'latest'
481 latest_out.mkdir(parents=True, exist_ok=True)
483 for group in REQUIREMENTS_GROUPS:
484 generate_requirements_rst(group, repo_root=find_repo_root())
486 generate_python_versions_rst(repo_root=find_repo_root())
488 print('\n🔨 Building HTML...')
489 sp = run([SPHINX_BUILD_CMD, '-b', 'html', str(curr_docs), str(latest_out)])
491 log_file = latest_out / 'sphinx-build.log'
492 with open(log_file, 'w', encoding='utf-8') as f:
493 f.write(sp.stdout)
495 html_success = sp.returncode == 0
496 report_build_status('latest', html_success, sp.stdout, 'HTML')
498 if not html_success:
499 print(f'❌ HTML build failed. Check log: {log_file}')
500 sys.exit(1)
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')
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
517 try:
518 latex_out = tmproot / 'latex'
519 latex_out.mkdir(parents=True, exist_ok=True)
521 sp = run([SPHINX_BUILD_CMD, '-b', 'latex', str(curr_docs), str(latex_out)])
522 pdf_log = sp.stdout
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
536 if sp_pdf:
537 pdf_log += '\n' + sp_pdf.stdout
538 pdf_files = list(latex_out.glob('*.pdf'))
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')
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')
553 with open(latest_out / 'latest_pdf_build.log', 'w', encoding='utf-8') as f:
554 f.write(pdf_log)
556 finally:
557 shutil.rmtree(tmproot)
559 if not pdf_success:
560 print(f'❌ PDF build failed. Check log: {latest_out / "latest_pdf_build.log"}')
561 sys.exit(1)
563 print('\n✅ Documentation built successfully!')
564 print(f'📂 Output: {latest_out}')
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()
576 redirects_content = f"""# Redirects for GitLab Pages
577# See: https://docs.gitlab.com/ee/user/project/pages/redirects.html
579# Redirect old PDF URL to new PDF downloads page
580{old_pdf_path} {new_pdf_path} 301
581"""
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"""
591 redirects_file = outdir / '_redirects'
592 outdir.mkdir(parents=True, exist_ok=True)
594 with open(redirects_file, 'w', encoding='utf-8') as f:
595 f.write(redirects_content)
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')
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')
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.
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'
643 if not pyproject_path.exists():
644 print(f'❌ Error: {pyproject_path} not found.')
645 return
647 doc = tomlkit.loads(pyproject_path.read_text(encoding='utf-8'))
648 project = doc.get('project', {})
649 optional = project.get('optional-dependencies', {})
651 valid_groups = {'base'} | set(optional.keys())
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.')
659 generate_python_versions_rst(repo_root=find_repo_root())
661 if update_readme:
662 readme_path = repo_root / 'README.rst'
663 if not readme_path.exists():
664 return
666 content = readme_path.read_text(encoding='utf-8')
667 original_content = content
668 replacements: list[tuple[str, str, str]] = []
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
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 )
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 )
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)
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)}')