Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-01-10 09:16:57

0001 #!/usr/bin/env python3
0002 
0003 # /// script
0004 # requires-python = ">=3.11"
0005 # dependencies = [
0006 #   "typer",
0007 #   "rich",
0008 #   "livereload",
0009 # ]
0010 # ///
0011 
0012 import logging
0013 import re
0014 import shlex
0015 from pathlib import Path
0016 from typing import Annotated
0017 
0018 import livereload
0019 import subprocess
0020 import typer
0021 from rich.console import Console
0022 from rich.logging import RichHandler
0023 
0024 
0025 logger = logging.getLogger("docs.serve")
0026 
0027 app = typer.Typer()
0028 console = Console()
0029 
0030 
0031 def build_docs(build_dir: Path) -> None:
0032     subprocess.run(["cmake", "--build", build_dir, "--target", "docs"], check=True)
0033 
0034 
0035 def find_source_dir(build_dir: Path) -> Path:
0036     cmake_cache = build_dir / "CMakeCache.txt"
0037     if not cmake_cache.is_file():
0038         logger.error("CMake cache file %s is missing", cmake_cache)
0039         raise typer.Exit(1)
0040 
0041     source_dir: Path | None = None
0042     with cmake_cache.open(encoding="utf-8") as handle:
0043         for line in handle:
0044             if line.startswith("Acts_SOURCE_DIR:STATIC="):
0045                 source_dir = Path(line.split("=", 1)[1].strip())
0046                 break
0047 
0048     if source_dir is None or not source_dir.is_dir():
0049         logger.error("Could not determine source directory from CMake cache")
0050         raise typer.Exit(1)
0051 
0052     return source_dir
0053 
0054 
0055 @app.command()
0056 def main(
0057     build_dir: Annotated[Path, typer.Option(file_okay=False, exists=True)] = Path(
0058         "build"
0059     ),
0060     port: int = 8000,
0061     verbose: Annotated[bool, typer.Option("-v", "--verbose")] = False,
0062 ):
0063 
0064     logger.setLevel(logging.DEBUG if verbose else logging.INFO)
0065     logger.addHandler(RichHandler())
0066 
0067     build_dir = build_dir.resolve()
0068     logger.info("Using build directory: %s", build_dir)
0069     source_dir = find_source_dir(build_dir)
0070     logger.debug("Source directory: %s", source_dir)
0071     docs_dir = (source_dir / "docs").resolve()
0072     if not docs_dir.is_dir():
0073         logger.error("Docs directory %s is missing", docs_dir)
0074         raise typer.Exit(1)
0075 
0076     logger.info("Docs directory: %s", docs_dir)
0077 
0078     logger.info("Initial documentation build...")
0079     build_docs(build_dir)
0080 
0081     output_dir = build_dir / "docs/html"
0082     if not output_dir.exists():
0083         console.print("[red]Error: output directory does not exist.[/red]")
0084         raise typer.Exit(1)
0085 
0086     doxygen_inputs, file_patterns = parse_doxygen_lists(build_dir)
0087     logger.debug(doxygen_inputs)
0088     logger.debug(file_patterns)
0089 
0090     server = livereload.Server()
0091 
0092     def rebuild(changed=None):
0093         if changed is not None:
0094             logger.info("Changes detected: %s => rebuilding...", changed)
0095         else:
0096             logger.info("Rebuilding documentation...")
0097         build_docs(build_dir)
0098 
0099     for target in expand_watch_patterns(docs_dir, doxygen_inputs, file_patterns):
0100         logger.debug("Watching %s", target)
0101         server.watch(target, rebuild)
0102 
0103     doxy_template = docs_dir / "Doxyfile.in"
0104     if not doxy_template.exists():
0105         logger.error("Doxygen template %s is missing", doxy_template)
0106         raise typer.Exit(1)
0107 
0108     server.watch(str(doxy_template), rebuild)
0109 
0110     layout_file = docs_dir / "DoxygenLayout.xml"
0111     if layout_file.exists():
0112         server.watch(str(layout_file), rebuild)
0113 
0114     server.watch(str(source_dir / "CONTRIBUTING.md"), rebuild)
0115 
0116     server.watch(str(docs_dir / "examples/**/*.cpp"), rebuild)
0117     server.watch(str(docs_dir / "examples/**/*.py"), rebuild)
0118     server.watch(str(docs_dir / "*.bib"), rebuild)
0119 
0120     server.serve(root=str(output_dir), port=port)
0121 
0122 
0123 def parse_doxygen_lists(build_dir: Path) -> tuple[list[str], list[str]]:
0124     """Return the INPUT and FILE_PATTERNS entries from the generated Doxyfile."""
0125     doxyfile = (build_dir / "docs" / "Doxyfile").resolve()
0126     if not doxyfile.is_file():
0127         raise FileNotFoundError(f"Could not find Doxyfile at {doxyfile}")
0128 
0129     targets = {"INPUT": [], "FILE_PATTERNS": []}
0130     collecting: str | None = None
0131 
0132     def _rstrip_continuation(value: str) -> tuple[str, bool]:
0133         value = value.rstrip()
0134         if value.endswith("\\"):
0135             return value[:-1].rstrip(), True
0136         return value, False
0137 
0138     def _extend(target_list: list[str], chunk: str) -> None:
0139         if not chunk:
0140             return
0141         tokens = shlex.split(chunk, comments=False, posix=True)
0142         for token in tokens:
0143             cleaned = token.rstrip(",")
0144             if cleaned:
0145                 target_list.append(cleaned)
0146 
0147     with doxyfile.open(encoding="utf-8") as handle:
0148         for raw_line in handle:
0149             line = raw_line.strip()
0150             if not line or line.startswith("#"):
0151                 continue
0152 
0153             if collecting:
0154                 chunk, carry_on = _rstrip_continuation(line)
0155                 _extend(targets[collecting], chunk)
0156                 if not carry_on:
0157                     collecting = None
0158                 continue
0159 
0160             match = re.match(r"^(INPUT|FILE_PATTERNS)\s*(\+?=)\s*(.*)$", line)
0161             if not match:
0162                 continue
0163 
0164             tag = match.group(1)
0165             chunk, carry_on = _rstrip_continuation(match.group(3))
0166             _extend(targets[tag], chunk)
0167             if carry_on:
0168                 collecting = tag
0169 
0170     return targets["INPUT"], targets["FILE_PATTERNS"]
0171 
0172 
0173 def expand_watch_patterns(
0174     docs_dir: Path, inputs: list[str], file_patterns: list[str]
0175 ) -> list[str]:
0176     """Return file or glob patterns for each Doxygen INPUT entry."""
0177     patterns = [pattern for pattern in (file_patterns or []) if pattern]
0178     resolved_docs = docs_dir.resolve()
0179     watch_targets: dict[str, None] = {}
0180 
0181     for entry in inputs:
0182         if not entry:
0183             continue
0184 
0185         path = Path(entry)
0186         if not path.is_absolute():
0187             path = (resolved_docs / path).resolve()
0188         else:
0189             path = path.resolve()
0190 
0191         if path.is_file():
0192             watch_targets[str(path)] = None
0193             continue
0194 
0195         if path.is_dir():
0196             if patterns:
0197                 for pattern in patterns:
0198                     watch_targets[str(path / "**" / pattern)] = None
0199             else:
0200                 watch_targets[str(path / "**" / "*")] = None
0201             continue
0202 
0203         logger.warning("Doxygen input %s does not exist; skipping.", path)
0204 
0205     return list(watch_targets.keys())
0206 
0207 
0208 if "__main__" == __name__:
0209     app()