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)