Source code for bundle.docs.builder

# Copyright 2026 HorusElohim

# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements.  See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License.  You may obtain a copy of the License at

#   http://www.apache.org/licenses/LICENSE-2.0

# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied.  See the License for the
# specific language governing permissions and limitations
# under the License.

from __future__ import annotations

import shutil
import tempfile
from pathlib import Path

from bundle.core import ProcessStream, logger, tracer

from .config import DocsConfig
from .discovery import find_readme_files, find_subpackages

log = logger.get_logger(__name__)


[docs] class DocsBuilder: """Orchestrates Sphinx documentation builds. Creates an ephemeral staging directory with generated conf.py and index.md, then invokes sphinx-build via subprocess. """ def __init__(self, config: DocsConfig): self.config = config
[docs] @tracer.Async.decorator.call_raise async def build(self) -> Path: """Generate Sphinx project files and run the build. Returns: Path to the output directory containing built HTML. """ staging = Path(tempfile.mkdtemp(prefix="bundle_docs_")) log.info("Staging directory: %s", staging) try: self._write_conf_py(staging) self._write_index(staging) await self._run_sphinx(staging) finally: shutil.rmtree(staging, ignore_errors=True) return self.config.output_dir
def _write_conf_py(self, staging: Path) -> None: """Write the generated conf.py into the staging directory.""" conf_path = staging / "conf.py" conf_path.write_text(self.config.generate_conf_py()) log.info("Generated conf.py at %s", conf_path) def _write_index(self, staging: Path) -> None: """Write the root index.md into the staging directory.""" sections = [] # Copy the project's root README into staging so Sphinx processes it as MyST root_readme = self.config.source_dir / "README.md" if root_readme.exists(): shutil.copy2(root_readme, staging / "readme_root.md") sections.append("```{include} readme_root.md\n```\n") else: sections.append(f"# {self.config.project_name}\n") # Module guide toctree (from subpackage READMEs copied into staging) modules_dir = staging / "modules" module_entries = self._copy_module_readmes(modules_dir) if module_entries: toctree_items = "\n".join(module_entries) sections.append(f"```{{toctree}}\n:maxdepth: 2\n:caption: Module Guides\n\n{toctree_items}\n```\n") # API reference toctree (generated by autoapi) sections.append("```{toctree}\n:maxdepth: 3\n:caption: API Reference\n\nautoapi/index\n```\n") index_path = staging / "index.md" index_path.write_text("\n".join(sections)) log.info("Generated index.md at %s", index_path) def _copy_module_readmes(self, modules_dir: Path) -> list[str]: """Copy subpackage README.md files into the staging modules/ directory. Each README is copied as a proper Sphinx source file so MyST renders it fully. Returns: List of toctree entry paths (relative to staging). """ entries = [] for pkg_dir_str in self.config.package_dirs: pkg_path = self.config.source_dir / pkg_dir_str if not pkg_path.is_dir(): continue subpackages = find_subpackages(pkg_path) for sub in subpackages: sub_readme = pkg_path / sub / "README.md" if not sub_readme.exists(): continue modules_dir.mkdir(parents=True, exist_ok=True) dest = modules_dir / f"{sub}.md" shutil.copy2(sub_readme, dest) entries.append(f"modules/{sub}") log.info("Copied README for module '%s'", sub) return entries async def _run_sphinx(self, staging: Path) -> None: """Invoke sphinx-build via ProcessStream.""" self.config.output_dir.mkdir(parents=True, exist_ok=True) cmd = f'python -m sphinx -b html "{staging}" "{self.config.output_dir}" --keep-going' log.info("Running: %s", cmd) proc = ProcessStream(name="sphinx-build") await proc(cmd) log.info("Documentation built at: %s", self.config.output_dir)