"""Sphinx renderer for doc-forge. The Sphinx renderer generates Sphinx-compatible documentation from the doc-forge documentation model. It creates reStructuredText files with autodoc directives and generates the necessary Sphinx configuration. This renderer follows the ADS specification by: - Emitting .rst files with autodoc directives - Supporting build operations via Sphinx APIs - Providing static build capability (serve is optional) """ from __future__ import annotations import logging from pathlib import Path from typing import Any, Dict, List, Optional try: import sphinx from sphinx.application import Sphinx from sphinx.util.docutils import docutils_namespace except ImportError as e: raise ImportError( "sphinx is required for Sphinx renderer. " "Install with: pip install doc-forge[sphinx]" ) from e from docforge.model import Project, Module from .base import BaseRenderer, RendererConfig logger = logging.getLogger(__name__) class SphinxRenderer(BaseRenderer): """Sphinx documentation renderer. The SphinxRenderer converts the doc-forge documentation model into Sphinx-compatible source files. It generates reStructuredText files with autodoc directives and creates the necessary Sphinx configuration. Generated output structure: docs/ ├── source/ │ ├── index.rst │ ├── conf.py │ ├── module1.rst │ └── module2.rst └── build/ Attributes: name: Renderer identifier ("sphinx") """ def __init__(self) -> None: """Initialize the Sphinx renderer.""" super().__init__("sphinx") def generate_sources(self, project: Project, out_dir: Path) -> None: """Generate Sphinx source files. Creates reStructuredText files for each module and generates the Sphinx configuration (conf.py). Args: project: The documentation project to render out_dir: Directory where source files should be written """ self.validate_project(project) self.ensure_output_dir(out_dir) # Create source directory structure source_dir = out_dir / "source" source_dir.mkdir(exist_ok=True) logger.info(f"Generating Sphinx sources in {source_dir}") # Generate index.rst self._generate_index(project, source_dir) # Generate conf.py self._generate_sphinx_config(project, source_dir) # Generate module files for module in project.get_all_modules(): self._generate_module_file(module, source_dir) logger.info(f"Generated {len(project.get_all_modules())} module files") def build(self, config: RendererConfig) -> None: """Build Sphinx documentation. Uses Sphinx application to generate the final HTML documentation. Args: config: Configuration for the build process """ self.validate_project(config.project) source_dir = config.out_dir / "source" build_dir = config.out_dir / "build" if not source_dir.exists(): raise ValueError(f"Source directory not found: {source_dir}") logger.info(f"Building Sphinx documentation from {source_dir}") # Get build options from config builder_name = config.get_extra("builder", "html") # Create Sphinx application and build with docutils_namespace(): app = Sphinx( srcdir=str(source_dir), confdir=str(source_dir), outdir=str(build_dir / builder_name), doctreedir=str(build_dir / "doctrees"), buildername=builder_name, verbosity=config.get_extra("verbosity", 1), ) app.build() logger.info(f"Sphinx build completed successfully ({builder_name})") def serve(self, config: RendererConfig) -> None: """Serve Sphinx documentation locally. Sphinx doesn't have a built-in serve command like MkDocs. This method provides a simple static file server for the built HTML. Args: config: Configuration for the serve process """ # Build first if needed build_dir = config.out_dir / "build" / "html" if not build_dir.exists(): logger.info("Building documentation before serving...") self.build(config) # Get serve options host = config.get_extra("host", "127.0.0.1") port = config.get_extra("port", 8000) logger.info(f"Serving Sphinx documentation at http://{host}:{port}") logger.info(f"Serving from: {build_dir}") # Simple HTTP server import http.server import socketserver import os os.chdir(build_dir) with socketserver.TCPServer((host, port), http.server.SimpleHTTPRequestHandler) as httpd: logger.info(f"Press Ctrl+C to stop serving") try: httpd.serve_forever() except KeyboardInterrupt: logger.info("Stopping server...") httpd.shutdown() def _generate_index(self, project: Project, source_dir: Path) -> None: """Generate the index.rst file. Args: project: The documentation project source_dir: Source directory """ index_path = source_dir / "index.rst" content = [f"{project.name}", "=" * len(project.name), ""] if project.version: content.append(f"**Version:** {project.version}") content.append("") content.append(".. toctree::") content.append(" :maxdepth: 2") content.append(" :caption: Modules:") content.append("") for entry in project.nav.entries: content.append(f" {entry.path}") content.append("") content.append("Indices and tables") content.append("==================") content.append("") content.append("* :ref:`genindex`") content.append("* :ref:`modindex`") content.append("* :ref:`search`") index_path.write_text("\n".join(content), encoding="utf-8") logger.debug(f"Generated {index_path}") def _generate_module_file(self, module: Module, source_dir: Path) -> None: """Generate a reStructuredText file for a module. Args: module: The module to generate documentation for source_dir: Source directory """ module_path = source_dir / f"{module.path}.rst" content = [f"{module.path}", "=" * len(module.path), ""] if module.has_docstring(): content.append(module.docstring) content.append("") content.append(".. automodule:: " + module.path) content.append(" :members:") content.append(" :undoc-members:") content.append(" :show-inheritance:") module_path.write_text("\n".join(content), encoding="utf-8") logger.debug(f"Generated {module_path}") def _generate_sphinx_config(self, project: Project, source_dir: Path) -> None: """Generate the conf.py configuration file. Args: project: The documentation project source_dir: Source directory """ config_path = source_dir / "conf.py" content = [ f'"""Sphinx configuration for {project.name}."""', "", "import os", "import sys", "", "# Add the project root to the Python path", "sys.path.insert(0, os.path.abspath('../..'))", "", f"# Project information", f"project = '{project.name}'", "copyright = '2024, Project Authors'", "author = 'Project Authors'", f"release = '{project.version or '0.1.0'}'", "", "# General configuration", "extensions = [", " 'sphinx.ext.autodoc',", " 'sphinx.ext.viewcode',", " 'sphinx.ext.napoleon',", " 'sphinx.ext.intersphinx',", "]", "", "# Templates path", "templates_path = ['_templates']", "", "# Output formatting", "html_theme = 'alabaster'", "html_static_path = ['_static']", "", "# Autodoc settings", "autodoc_default_options = {", " 'members': True,", " 'member-order': 'bysource',", " 'special-members': '__init__',", " 'undoc-members': True,", " 'exclude-members': '__weakref__'", "}", "", "# Napoleon settings", "napoleon_google_docstring = True", "napoleon_numpy_docstring = True", "napoleon_include_init_with_doc = False", "napoleon_include_private_with_doc = False", ] config_path.write_text("\n".join(content), encoding="utf-8") logger.debug(f"Generated {config_path}") class SphinxConfig(RendererConfig): """Sphinx-specific renderer configuration. Extends the base RendererConfig with Sphinx-specific options. Attributes: builder: Sphinx builder to use (html, latex, etc.) theme: HTML theme to use extensions: Sphinx extensions to enable """ def __init__( self, out_dir: Path, project: Project, builder: str = "html", theme: str = "alabaster", extensions: Optional[List[str]] = None, **extra, ) -> None: """Initialize Sphinx configuration. Args: out_dir: Output directory for generated files project: The documentation project being rendered builder: Sphinx builder to use theme: HTML theme to use extensions: Sphinx extensions to enable **extra: Additional configuration options """ super().__init__(out_dir, project, extra) self.builder = builder self.theme = theme self.extensions = extensions or [ "sphinx.ext.autodoc", "sphinx.ext.viewcode", "sphinx.ext.napoleon", ]