diff --git a/README.md b/README.md new file mode 100644 index 0000000..145070d --- /dev/null +++ b/README.md @@ -0,0 +1,243 @@ +# doc-forge + +A renderer-agnostic Python documentation compiler that converts Python source code and docstrings into a structured, semantic documentation model and emits multiple downstream representations. + +## Features + +- **Single Source of Truth**: Python source code and docstrings are the only authoritative input +- **Renderer Agnosticism**: MkDocs, Sphinx, MCP, or future renderers don't influence the core model +- **Deterministic Output**: Given the same codebase, outputs are reproducible +- **AI-Native Documentation**: Structured, queryable, and machine-consumable +- **Library-First Design**: All functionality accessible as a Python API + +## Installation + +```bash +pip install doc-forge +``` + +### Optional Dependencies + +```bash +# For MkDocs rendering +pip install doc-forge[mkdocs] + +# For Sphinx rendering +pip install doc-forge[sphinx] + +# For MCP support +pip install doc-forge[mcp] + +# For development +pip install doc-forge[dev] +``` + +## Quick Start + +### Command Line Interface + +```bash +# Generate MkDocs documentation +doc-forge generate --renderer mkdocs mypackage + +# Build final HTML documentation +doc-forge build --renderer mkdocs mypackage + +# Serve documentation locally +doc-forge serve --renderer mkdocs mypackage + +# Export to MCP format +doc-forge export mypackage + +# Start live MCP server +doc-forge server mypackage +``` + +### Python API + +```python +from docforge.loader import GriffeLoader +from docforge.renderers import MkDocsRenderer +from pathlib import Path + +# Load your project +loader = GriffeLoader() +project = loader.load_project(["mypackage", "mypackage.utils"]) + +# Generate MkDocs sources +renderer = MkDocsRenderer() +renderer.generate_sources(project, Path("docs")) + +# Build final documentation +from docforge.renderers.base import RendererConfig +config = RendererConfig(Path("docs"), project) +renderer.build(config) +``` + +## Architecture + +doc-forge follows this high-level architecture: + +``` +Python Source Code + ↓ +Introspection Layer (Griffe) + ↓ +Documentation Model (doc-forge core) + ↓ +Renderer / Exporter Layer + ├── MkDocs + ├── Sphinx + ├── MCP (static JSON) + └── MCP Server (live) +``` + +## Core Components + +### Documentation Model + +- **Project**: Root container for all documentation +- **Module**: Represents Python modules +- **DocObject**: Base class for classes, functions, variables, etc. +- **Navigation**: Hierarchical structure for browsing + +### Renderers + +- **MkDocs Renderer**: Generates Markdown with mkdocstrings directives +- **Sphinx Renderer**: Generates reStructuredText with autodoc directives + +### Exporters + +- **MCP Exporter**: Creates static JSON bundles for machine consumption +- **MCP Server**: Live server for real-time documentation access + +## CLI Commands + +### `generate` +Generate renderer-specific source files without building final artifacts. + +```bash +doc-forge generate --renderer mkdocs --out-dir docs mypackage +``` + +### `build` +Build final documentation artifacts (HTML, etc.). + +```bash +doc-forge build --renderer sphinx mypackage +``` + +### `serve` +Start a local development server. + +```bash +doc-forge serve --renderer mkdocs --port 9000 mypackage +``` + +### `export` +Export to MCP format for machine consumption. + +```bash +doc-forge export --out-dir mcp mypackage +``` + +### `server` +Start live MCP server for real-time access. + +```bash +doc-forge server --host 0.0.0.0 --port 8080 mypackage +``` + +## Configuration + +doc-forge is designed to work with minimal configuration. Most settings are derived automatically from your Python code structure. + +### MkDocs Configuration + +The MkDocs renderer automatically generates `mkdocs.yml` with sensible defaults: + +```yaml +site_name: Your Project +plugins: + - mkdocstrings +theme: + name: material +``` + +### Sphinx Configuration + +The Sphinx renderer automatically generates `conf.py` with standard extensions: + +```python +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.viewcode', + 'sphinx.ext.napoleon', +] +``` + +## MCP Integration + +doc-forge provides two ways to integrate with MCP (Model Context Protocol): + +### Static Export +```bash +doc-forge export mypackage +``` +Creates a static JSON bundle in `mcp/` directory that can be loaded by MCP clients. + +### Live Server +```bash +doc-forge server mypackage +``` +Starts a live MCP server providing real-time access to documentation resources: + +- `docs://index` - Project metadata +- `docs://nav` - Navigation structure +- `docs://module/{module}` - Individual module data + +## Development + +### Setup + +```bash +git clone https://github.com/doc-forge/doc-forge +cd doc-forge +pip install -e ".[dev]" +``` + +### Running Tests + +```bash +pytest +``` + +### Code Quality + +```bash +black docforge/ +ruff check docforge/ +mypy docforge/ +``` + +## License + +MIT License - see LICENSE file for details. + +## Contributing + +Contributions are welcome! Please see CONTRIBUTING.md for guidelines. + +## Philosophy + +doc-forge is built on these core principles: + +1. **Single Source of Truth**: Python source code and docstrings are the only authoritative input +2. **Renderer Agnosticism**: The core model contains no renderer-specific logic +3. **Deterministic Output**: Same input always produces same output +4. **AI-Native Documentation**: Documentation must be structured, queryable, and machine-consumable +5. **Library-First**: All functionality must be accessible as a Python API + +--- + +*doc-forge turns Python code into structured knowledge and emits it through multiple human and machine interfaces.* \ No newline at end of file diff --git a/docforge/__init__.py b/docforge/__init__.py new file mode 100644 index 0000000..a662454 --- /dev/null +++ b/docforge/__init__.py @@ -0,0 +1,28 @@ +"""doc-forge — A renderer-agnostic Python documentation compiler. + +doc-forge converts Python source code and docstrings into a structured, +semantic documentation model and emits multiple downstream representations. + +Core features: +- Single source of truth: Python source code and docstrings +- Renderer agnosticism: MkDocs, Sphinx, MCP, or future renderers +- Deterministic output: Reproducible results +- AI-native documentation: Structured, queryable, machine-consumable +- Library-first design: All functionality accessible via Python API +""" + +__version__ = "0.1.0" +__author__ = "doc-forge team" + +from docforge.model.project import Project +from docforge.model.module import Module +from docforge.model.object import DocObject +from docforge.model.nav import Navigation, NavEntry + +__all__ = [ + "Project", + "Module", + "DocObject", + "Navigation", + "NavEntry", +] \ No newline at end of file diff --git a/docforge/cli/__init__.py b/docforge/cli/__init__.py new file mode 100644 index 0000000..175389f --- /dev/null +++ b/docforge/cli/__init__.py @@ -0,0 +1,5 @@ +"""CLI package for doc-forge command-line interface.""" + +from .main import main + +__all__ = ["main"] \ No newline at end of file diff --git a/docforge/cli/__init__.pyi b/docforge/cli/__init__.pyi new file mode 100644 index 0000000..4522f0f --- /dev/null +++ b/docforge/cli/__init__.pyi @@ -0,0 +1,33 @@ +"""Type stubs for doc-forge CLI package.""" + +from typing import Any, Dict, List, Optional, Union +from pathlib import Path +import click + +@click.group() +def cli() -> None: ... + +@cli.command() +@click.option('--renderer', type=click.Choice(['mkdocs', 'sphinx']), required=True) +@click.option('--out-dir', type=click.Path(), default='docs') +@click.argument('modules', nargs=-1) +def generate(renderer: str, out_dir: Path, modules: List[str]) -> None: ... + +@cli.command() +@click.option('--renderer', type=click.Choice(['mkdocs', 'sphinx']), required=True) +@click.option('--out-dir', type=click.Path(), default='docs') +def build(renderer: str, out_dir: Path) -> None: ... + +@cli.command() +@click.option('--renderer', type=click.Choice(['mkdocs', 'sphinx']), required=True) +@click.option('--out-dir', type=click.Path(), default='docs') +@click.option('--host', default='localhost') +@click.option('--port', type=int, default=8000) +def serve(renderer: str, out_dir: Path, host: str, port: int) -> None: ... + +@cli.command() +@click.option('--out-dir', type=click.Path(), default='mcp') +@click.argument('modules', nargs=-1) +def export(out_dir: Path, modules: List[str]) -> None: ... + +def main() -> None: ... \ No newline at end of file diff --git a/docforge/cli/main.py b/docforge/cli/main.py new file mode 100644 index 0000000..7dd826b --- /dev/null +++ b/docforge/cli/main.py @@ -0,0 +1,333 @@ +"""Command-line interface for doc-forge. + +The CLI provides a thin orchestration layer over the core doc-forge +functionality. It follows the ADS specification by being library-first +with the CLI as a secondary interface. + +Available commands: +- generate: Generate source files for a renderer +- build: Build final documentation artifacts +- serve: Serve documentation locally +- export: Export to MCP format +""" + +from __future__ import annotations + +import logging +import sys +from pathlib import Path +from typing import List, Optional + +try: + import click +except ImportError as e: + raise ImportError( + "click is required for doc-forge CLI. " + "Install with: pip install click" + ) from e + +from docforge.loader import GriffeLoader +from docforge.renderers import MkDocsRenderer, SphinxRenderer +from docforge.exporters import MCPExporter +from docforge.server import MCPServer + + +logger = logging.getLogger(__name__) + + +@click.group() +@click.option("--verbose", "-v", is_flag=True, help="Enable verbose logging") +@click.pass_context +def cli(ctx: click.Context, verbose: bool) -> None: + """doc-forge — A renderer-agnostic Python documentation compiler. + + doc-forge converts Python source code and docstrings into a structured, + semantic documentation model and emits multiple downstream representations. + """ + if verbose: + logging.basicConfig(level=logging.DEBUG, format="%(levelname)s: %(message)s") + else: + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + + # Ensure context object exists + ctx.ensure_object(dict) + + +@cli.command() +@click.option("--renderer", type=click.Choice(["mkdocs", "sphinx"]), required=True, help="Renderer to use") +@click.option("--out-dir", type=click.Path(path_type=Path), default=Path("docs"), help="Output directory") +@click.option("--project-name", help="Project name (defaults to first module)") +@click.argument("modules", nargs=-1, required=True, help="Python modules to document") +@click.pass_context +def generate( + ctx: click.Context, + renderer: str, + out_dir: Path, + project_name: Optional[str], + modules: List[str], +) -> None: + """Generate documentation source files. + + Generate renderer-specific source files from Python modules. + This creates the source files but does not build the final documentation. + + Examples: + doc-forge generate --renderer mkdocs mypackage + doc-forge generate --renderer sphinx mypackage.submodule mypackage.utils + """ + try: + # Load project + loader = GriffeLoader() + project = loader.load_project(list(modules), project_name) + + # Get renderer + renderer_instance = _get_renderer(renderer) + + # Generate sources + renderer_instance.generate_sources(project, out_dir) + + click.echo(f"Generated {renderer} sources in {out_dir}") + click.echo(f"Modules: {', '.join(modules)}") + + except Exception as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + + +@cli.command() +@click.option("--renderer", type=click.Choice(["mkdocs", "sphinx"]), required=True, help="Renderer to use") +@click.option("--out-dir", type=click.Path(path_type=Path), default=Path("docs"), help="Output directory") +@click.option("--project-name", help="Project name (defaults to first module)") +@click.argument("modules", nargs=-1, required=True, help="Python modules to document") +@click.pass_context +def build( + ctx: click.Context, + renderer: str, + out_dir: Path, + project_name: Optional[str], + modules: List[str], +) -> None: + """Build final documentation artifacts. + + Build the final documentation from Python modules. This both generates + source files and builds the final artifacts (HTML, etc.). + + Examples: + doc-forge build --renderer mkdocs mypackage + doc-forge build --renderer sphinx mypackage.submodule + """ + try: + # Load project + loader = GriffeLoader() + project = loader.load_project(list(modules), project_name) + + # Get renderer + renderer_instance = _get_renderer(renderer) + + # Generate sources first + renderer_instance.generate_sources(project, out_dir) + + # Build final artifacts + from docforge.renderers.base import RendererConfig + config = RendererConfig(out_dir, project) + renderer_instance.build(config) + + click.echo(f"Built {renderer} documentation in {out_dir}") + + # Show output location + if renderer == "mkdocs": + build_dir = out_dir / "_site" + else: # sphinx + build_dir = out_dir / "build" / "html" + + if build_dir.exists(): + click.echo(f"Output: {build_dir}") + + except Exception as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + + +@cli.command() +@click.option("--renderer", type=click.Choice(["mkdocs", "sphinx"]), required=True, help="Renderer to use") +@click.option("--out-dir", type=click.Path(path_type=Path), default=Path("docs"), help="Output directory") +@click.option("--host", default="127.0.0.1", help="Host to serve on") +@click.option("--port", type=int, default=8000, help="Port to serve on") +@click.option("--project-name", help="Project name (defaults to first module)") +@click.argument("modules", nargs=-1, required=True, help="Python modules to document") +@click.pass_context +def serve( + ctx: click.Context, + renderer: str, + out_dir: Path, + host: str, + port: int, + project_name: Optional[str], + modules: List[str], +) -> None: + """Serve documentation locally. + + Start a local development server to serve documentation. + This generates sources, builds artifacts, and starts a server. + + Examples: + doc-forge serve --renderer mkdocs mypackage + doc-forge serve --renderer sphinx --port 9000 mypackage + """ + try: + # Load project + loader = GriffeLoader() + project = loader.load_project(list(modules), project_name) + + # Get renderer + renderer_instance = _get_renderer(renderer) + + # Generate sources first + renderer_instance.generate_sources(project, out_dir) + + # Build artifacts + from docforge.renderers.base import RendererConfig + config = RendererConfig(out_dir, project, {"host": host, "port": port}) + + # Start serving + click.echo(f"Serving {renderer} documentation at http://{host}:{port}") + click.echo("Press Ctrl+C to stop serving") + + renderer_instance.serve(config) + + except KeyboardInterrupt: + click.echo("\nStopped serving") + except Exception as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + + +@cli.command() +@click.option("--out-dir", type=click.Path(path_type=Path), default=Path("mcp"), help="Output directory") +@click.option("--project-name", help="Project name (defaults to first module)") +@click.argument("modules", nargs=-1, required=True, help="Python modules to document") +@click.pass_context +def export( + ctx: click.Context, + out_dir: Path, + project_name: Optional[str], + modules: List[str], +) -> None: + """Export documentation to MCP format. + + Export documentation as a static MCP JSON bundle. This creates + machine-consumable documentation that can be loaded by MCP clients. + + Examples: + doc-forge export mypackage + doc-forge export --out-dir ./docs/mcp mypackage.submodule + """ + try: + # Load project + loader = GriffeLoader() + project = loader.load_project(list(modules), project_name) + + # Export to MCP + exporter = MCPExporter() + exporter.export(project, out_dir) + + click.echo(f"Exported MCP bundle to {out_dir}") + click.echo(f"Modules: {', '.join(modules)}") + + # Show bundle structure + index_path = out_dir / "index.json" + nav_path = out_dir / "nav.json" + modules_dir = out_dir / "modules" + + click.echo(f"Bundle structure:") + click.echo(f" {index_path}") + click.echo(f" {nav_path}") + click.echo(f" {modules_dir}/") + + except Exception as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + + +@cli.command() +@click.option("--host", default="127.0.0.1", help="Host to serve on") +@click.option("--port", type=int, default=8080, help="Port to serve on") +@click.option("--project-name", help="Project name (defaults to first module)") +@click.argument("modules", nargs=-1, required=True, help="Python modules to document") +@click.pass_context +def server( + ctx: click.Context, + host: str, + port: int, + project_name: Optional[str], + modules: List[str], +) -> None: + """Start MCP server for live documentation access. + + Start a live MCP server that provides real-time access to documentation + through the Model Context Protocol. This serves documentation directly + from the Python model without generating static files. + + Examples: + doc-forge server mypackage + doc-forge server --port 9000 mypackage.submodule + """ + try: + # Load project + loader = GriffeLoader() + project = loader.load_project(list(modules), project_name) + + # Start MCP server + server = MCPServer(project) + + click.echo(f"Starting MCP server at http://{host}:{port}") + click.echo("Available resources:") + click.echo(f" docs://index - Project metadata") + click.echo(f" docs://nav - Navigation structure") + for module_path in project.get_module_list(): + click.echo(f" docs://module/{module_path} - Module documentation") + click.echo("Press Ctrl+C to stop server") + + server.start(host, port) + + # Keep server running + try: + while server.is_running(): + import time + time.sleep(1) + except KeyboardInterrupt: + click.echo("\nStopping server...") + server.stop() + + except Exception as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + + +def _get_renderer(renderer_name: str): + """Get renderer instance by name. + + Args: + renderer_name: Name of the renderer + + Returns: + Renderer instance + + Raises: + ValueError: If renderer name is unknown + """ + if renderer_name == "mkdocs": + return MkDocsRenderer() + elif renderer_name == "sphinx": + return SphinxRenderer() + else: + raise ValueError(f"Unknown renderer: {renderer_name}") + + +def main() -> None: + """Main entry point for the CLI.""" + cli() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/docforge/exporters/__init__.py b/docforge/exporters/__init__.py new file mode 100644 index 0000000..891ae5b --- /dev/null +++ b/docforge/exporters/__init__.py @@ -0,0 +1,5 @@ +"""Exporters package for doc-forge output formats.""" + +from .mcp import MCPExporter + +__all__ = ["MCPExporter"] \ No newline at end of file diff --git a/docforge/exporters/__init__.pyi b/docforge/exporters/__init__.pyi new file mode 100644 index 0000000..9b056e8 --- /dev/null +++ b/docforge/exporters/__init__.pyi @@ -0,0 +1,18 @@ +"""Type stubs for doc-forge exporters package.""" + +from typing import Any, Dict, List, Optional, Union +from pathlib import Path +from docforge.model import Project + +class MCPExporter: + """Exports documentation model to MCP JSON format.""" + + def __init__(self) -> None: ... + + def export(self, project: Project, out_dir: Path) -> None: ... + + def export_index(self, project: Project, out_dir: Path) -> None: ... + + def export_nav(self, project: Project, out_dir: Path) -> None: ... + + def export_modules(self, project: Project, out_dir: Path) -> None: ... \ No newline at end of file diff --git a/docforge/exporters/mcp.py b/docforge/exporters/mcp.py new file mode 100644 index 0000000..128fa90 --- /dev/null +++ b/docforge/exporters/mcp.py @@ -0,0 +1,281 @@ +"""MCP exporter for doc-forge static documentation bundles. + +The MCP exporter creates static JSON bundles that can be consumed by +MCP (Model Context Protocol) clients. This follows the ADS specification +by providing a machine-consumable representation of the documentation +model. + +The exporter bypasses renderers entirely and works directly with the +documentation model to create alias-safe, deterministic output. +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Any, Dict, List, Optional + +from docforge.model import Project, Module, DocObject + + +logger = logging.getLogger(__name__) + + +class MCPExporter: + """Exports documentation model to MCP JSON format. + + The MCPExporter creates static JSON bundles that represent the + documentation model in a machine-consumable format. These bundles + can be loaded by MCP clients to provide structured access to + documentation. + + Output structure: + mcp/ + ├── index.json # Project metadata + ├── nav.json # Navigation structure + └── modules/ + ├── package.module.json # Individual module data + └── ... + + The exported data is: + - Alias-safe: Uses canonical paths for all references + - Deterministic: Same input always produces same output + - Self-contained: No external dependencies + - Queryable: Structured for easy machine consumption + """ + + def __init__(self) -> None: + """Initialize the MCP exporter.""" + self._version = "1.0.0" + + def export(self, project: Project, out_dir: Path) -> None: + """Export the complete project to MCP format. + + This is the main entry point for exporting. It creates the full + MCP bundle structure including index, navigation, and all module + files. + + Args: + project: The documentation project to export + out_dir: Directory where MCP bundle should be written + """ + if project.is_empty(): + raise ValueError("Cannot export empty project") + + # Create output directory structure + modules_dir = out_dir / "modules" + modules_dir.mkdir(parents=True, exist_ok=True) + + logger.info(f"Exporting MCP bundle to {out_dir}") + + # Export components + self.export_index(project, out_dir) + self.export_nav(project, out_dir) + self.export_modules(project, modules_dir) + + logger.info(f"MCP export completed: {len(project.get_all_modules())} modules") + + def export_index(self, project: Project, out_dir: Path) -> None: + """Export the project index metadata. + + The index.json file contains project-level metadata and + serves as the entry point for the MCP bundle. + + Args: + project: The documentation project + out_dir: Output directory + """ + index_path = out_dir / "index.json" + + index_data = { + "name": project.name, + "version": project.version, + "docforge_version": self._version, + "export_format": "mcp", + "export_timestamp": self._get_timestamp(), + "module_count": len(project.get_all_modules()), + "total_objects": project.get_total_object_count(), + } + + with open(index_path, "w", encoding="utf-8") as f: + json.dump(index_data, f, indent=2, ensure_ascii=False) + + logger.debug(f"Exported index: {index_path}") + + def export_nav(self, project: Project, out_dir: Path) -> None: + """Export the navigation structure. + + The nav.json file contains the hierarchical navigation structure + for browsing the documentation. + + Args: + project: The documentation project + out_dir: Output directory + """ + nav_path = out_dir / "nav.json" + + nav_data = { + "entries": [ + { + "title": entry.title, + "module": entry.module, + "path": f"modules/{entry.module.replace('.', '/')}.json", + } + for entry in project.nav.entries + ] + } + + with open(nav_path, "w", encoding="utf-8") as f: + json.dump(nav_data, f, indent=2, ensure_ascii=False) + + logger.debug(f"Exported navigation: {nav_path}") + + def export_modules(self, project: Project, modules_dir: Path) -> None: + """Export all modules to individual JSON files. + + Each module is exported to its own JSON file in the modules/ + directory. The filename follows the pattern: package.module.json + + Args: + project: The documentation project + modules_dir: Directory for module files + """ + for module in project.get_all_modules(): + self._export_module(module, modules_dir) + + def _export_module(self, module: Module, modules_dir: Path) -> None: + """Export a single module to JSON. + + Args: + module: The module to export + modules_dir: Directory for module files + """ + # Create subdirectories for nested packages + module_file_path = modules_dir / f"{module.path}.json" + module_file_path.parent.mkdir(parents=True, exist_ok=True) + + module_data = { + "path": module.path, + "docstring": module.docstring, + "objects": [self._serialize_object(obj) for obj in module.get_public_objects()], + } + + with open(module_file_path, "w", encoding="utf-8") as f: + json.dump(module_data, f, indent=2, ensure_ascii=False) + + logger.debug(f"Exported module: {module_file_path}") + + def _serialize_object(self, obj: DocObject) -> Dict[str, Any]: + """Serialize a DocObject to MCP format. + + Args: + obj: The DocObject to serialize + + Returns: + Dictionary representation of the object + """ + data = { + "name": obj.name, + "kind": obj.kind, + "path": obj.path, + "docstring": obj.docstring, + } + + if obj.signature: + data["signature"] = obj.signature + + if obj.members: + data["members"] = [ + self._serialize_object(member) + for member in obj.get_public_members() + ] + + return data + + def export_module(self, module: Module, out_dir: Path) -> None: + """Export a single module (convenience method). + + Args: + module: The module to export + out_dir: Output directory + """ + modules_dir = out_dir / "modules" + modules_dir.mkdir(parents=True, exist_ok=True) + + self._export_module(module, modules_dir) + + def validate_export(self, out_dir: Path) -> bool: + """Validate that the MCP export is complete and valid. + + Args: + out_dir: Directory containing the exported bundle + + Returns: + True if export is valid, False otherwise + """ + try: + # Check required files exist + index_path = out_dir / "index.json" + nav_path = out_dir / "nav.json" + modules_dir = out_dir / "modules" + + if not (index_path.exists() and nav_path.exists() and modules_dir.exists()): + return False + + # Load and validate index + with open(index_path, "r", encoding="utf-8") as f: + index = json.load(f) + + required_fields = ["name", "docforge_version", "export_format"] + if not all(field in index for field in required_fields): + return False + + # Load and validate nav + with open(nav_path, "r", encoding="utf-8") as f: + nav = json.load(f) + + if "entries" not in nav: + return False + + # Check that all nav entries have corresponding module files + for entry in nav["entries"]: + module_path = modules_dir / f"{entry['module']}.json" + if not module_path.exists(): + return False + + return True + + except Exception as e: + logger.error(f"Export validation failed: {e}") + return False + + def _get_timestamp(self) -> str: + """Get current timestamp in ISO format. + + Returns: + ISO format timestamp string + """ + from datetime import datetime, timezone + + return datetime.now(timezone.utc).isoformat() + + def get_export_info(self, out_dir: Path) -> Optional[Dict[str, Any]]: + """Get information about an existing export. + + Args: + out_dir: Directory containing the exported bundle + + Returns: + Export information dictionary or None if invalid + """ + index_path = out_dir / "index.json" + + if not index_path.exists(): + return None + + try: + with open(index_path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return None \ No newline at end of file diff --git a/docforge/loader/__init__.py b/docforge/loader/__init__.py new file mode 100644 index 0000000..1bc0e63 --- /dev/null +++ b/docforge/loader/__init__.py @@ -0,0 +1,5 @@ +"""Loader package for doc-forge introspection layer.""" + +from .griffe_loader import GriffeLoader + +__all__ = ["GriffeLoader"] \ No newline at end of file diff --git a/docforge/loader/__init__.pyi b/docforge/loader/__init__.pyi new file mode 100644 index 0000000..42efa8d --- /dev/null +++ b/docforge/loader/__init__.pyi @@ -0,0 +1,16 @@ +"""Type stubs for doc-forge loader package.""" + +from typing import Any, Dict, List, Optional, Union +from pathlib import Path +from docforge.model import Project, Module + +class GriffeLoader: + """Loads Python modules using Griffe introspection.""" + + def __init__(self) -> None: ... + + def load_project(self, module_paths: List[str]) -> Project: ... + + def load_module(self, path: str) -> Module: ... + + def resolve_aliases(self, project: Project) -> None: ... \ No newline at end of file diff --git a/docforge/loader/griffe_loader.py b/docforge/loader/griffe_loader.py new file mode 100644 index 0000000..2216027 --- /dev/null +++ b/docforge/loader/griffe_loader.py @@ -0,0 +1,289 @@ +"""Griffe-based loader for doc-forge introspection layer. + +The GriffeLoader uses the Griffe library to introspect Python source code +and extract documentation information. It converts Griffe's internal +representation into doc-forge's renderer-agnostic documentation model. + +Griffe is the only supported introspection backend in doc-forge, ensuring +consistent and reliable extraction of documentation information from +Python source code. +""" + +from __future__ import annotations + +import logging +from typing import Dict, List, Optional + +try: + import griffe + from griffe import Docstring, ObjectNode +except ImportError as e: + raise ImportError( + "griffe is required for doc-forge. Install with: pip install griffe" + ) from e + +from docforge.model import Module, Project, DocObject + + +logger = logging.getLogger(__name__) + + +class GriffeLoader: + """Loads Python modules using Griffe introspection. + + GriffeLoader is the bridge between Python source code and doc-forge's + documentation model. It uses Griffe to parse Python modules, extract + docstrings, signatures, and structural information, then converts + this data into doc-forge's renderer-agnostic model. + + The loader handles: + - Module discovery and loading + - Docstring extraction + - Signature parsing + - Member resolution + - Alias handling (with graceful failure) + + Attributes: + griffe_agent: The Griffe agent used for introspection + """ + + def __init__(self) -> None: + """Initialize the GriffeLoader. + + Creates a Griffe agent with default configuration for + documentation extraction. + """ + self.griffe_agent = griffe.Agent( + extensions=(), + resolve_aliases=True, + resolve_imports=True, + ) + + def load_project(self, module_paths: List[str], project_name: Optional[str] = None) -> Project: + """Load a complete project from multiple module paths. + + This is the primary entry point for loading documentation. It takes + a list of module paths, loads each one, and assembles them into a + complete Project with navigation. + + Args: + module_paths: List of import paths to load + project_name: Optional name for the project (defaults to first module) + + Returns: + Project containing all loaded modules + + Raises: + ValueError: If no module paths provided + ImportError: If any module cannot be loaded + """ + if not module_paths: + raise ValueError("At least one module path must be provided") + + if project_name is None: + project_name = module_paths[0].split('.')[0] + + project = Project(name=project_name) + + for module_path in module_paths: + try: + module = self.load_module(module_path) + project.add_module(module) + logger.info(f"Loaded module: {module_path}") + except Exception as e: + logger.error(f"Failed to load module {module_path}: {e}") + # Continue loading other modules rather than failing completely + continue + + # Resolve any cross-module aliases + self.resolve_aliases(project) + + return project + + def load_module(self, path: str) -> Module: + """Load a single module from its import path. + + Args: + path: The import path of the module to load + + Returns: + Module containing all documented objects + + Raises: + ImportError: If the module cannot be loaded or found + """ + try: + griffe_obj = self.griffe_agent.load_module(path) + except Exception as e: + raise ImportError(f"Failed to load module '{path}': {e}") from e + + return self._convert_griffe_object_to_module(griffe_obj) + + def _convert_griffe_object_to_module(self, griffe_obj: ObjectNode) -> Module: + """Convert a Griffe ObjectNode to a doc-forge Module. + + Args: + griffe_obj: The Griffe object to convert + + Returns: + Module containing converted documentation objects + """ + module = Module( + path=griffe_obj.canonical_path, + docstring=self._extract_docstring(griffe_obj.docstring), + ) + + # Convert all members + for name, member in griffe_obj.members.items(): + if not name.startswith('_'): # Skip private members + try: + doc_obj = self._convert_griffe_object_to_docobject(member) + module.add_object(doc_obj) + except Exception as e: + logger.warning(f"Failed to convert member {name}: {e}") + continue + + return module + + def _convert_griffe_object_to_docobject(self, griffe_obj: ObjectNode) -> DocObject: + """Convert a Griffe ObjectNode to a doc-forge DocObject. + + Args: + griffe_obj: The Griffe object to convert + + Returns: + DocObject with converted information + """ + # Determine the kind of object + kind = self._determine_object_kind(griffe_obj) + + # Extract signature for callable objects + signature = self._extract_signature(griffe_obj, kind) + + doc_obj = DocObject( + name=griffe_obj.name, + kind=kind, + path=griffe_obj.canonical_path, + signature=signature, + docstring=self._extract_docstring(griffe_obj.docstring), + ) + + # Convert nested members (for classes) + if kind == "class": + for name, member in griffe_obj.members.items(): + if not name.startswith('_'): # Skip private members + try: + nested_obj = self._convert_griffe_object_to_docobject(member) + doc_obj.add_member(nested_obj) + except Exception as e: + logger.warning(f"Failed to convert nested member {name}: {e}") + continue + + return doc_obj + + def _determine_object_kind(self, griffe_obj: ObjectNode) -> str: + """Determine the kind of documentation object. + + Args: + griffe_obj: The Griffe object to classify + + Returns: + String representing the object kind + """ + if griffe_obj.is_class: + return "class" + elif griffe_obj.is_function: + return "function" + elif griffe_obj.is_method: + return "method" + elif griffe_obj.is_property: + return "property" + elif griffe_obj.is_attribute: + return "attribute" + elif griffe_obj.is_module: + return "module" + else: + return "object" + + def _extract_signature(self, griffe_obj: ObjectNode, kind: str) -> Optional[str]: + """Extract signature string from a Griffe object. + + Args: + griffe_obj: The Griffe object to extract signature from + kind: The kind of object + + Returns: + Signature string or None if not applicable + """ + if kind not in ("function", "method"): + return None + + try: + if hasattr(griffe_obj, 'parameters') and griffe_obj.parameters: + params = [] + for param in griffe_obj.parameters.values(): + param_str = param.name + if param.annotation: + param_str += f": {param.annotation}" + if param.default and param.default != "None": + param_str += f" = {param.default}" + params.append(param_str) + + signature = f"({', '.join(params)})" + if hasattr(griffe_obj, 'returns') and griffe_obj.returns: + signature += f" -> {griffe_obj.returns}" + + return signature + except Exception as e: + logger.warning(f"Failed to extract signature for {griffe_obj.name}: {e}") + + return None + + def _extract_docstring(self, docstring: Optional[Docstring]) -> Optional[str]: + """Extract docstring content from a Griffe Docstring object. + + Args: + docstring: The Griffe docstring object + + Returns: + Plain text docstring or None + """ + if docstring is None: + return None + + try: + return str(docstring.value).strip() + except Exception as e: + logger.warning(f"Failed to extract docstring: {e}") + return None + + def resolve_aliases(self, project: Project) -> None: + """Resolve cross-module aliases in the project. + + This method attempts to resolve aliases that point to objects in + other modules. It updates the documentation model to reflect the + actual locations of objects rather than their aliases. + + Args: + project: The project to resolve aliases in + """ + logger.info("Resolving cross-module aliases...") + + # This is a placeholder for alias resolution + # In a full implementation, this would: + # 1. Identify all aliases in the project + # 2. Resolve them to their canonical targets + # 3. Update the documentation model accordingly + # 4. Handle circular references gracefully + + # For now, we'll just log that alias resolution was attempted + alias_count = 0 + for module in project.get_all_modules(): + for obj in module.members.values(): + if hasattr(obj, 'is_alias') and obj.is_alias: + alias_count += 1 + + if alias_count > 0: + logger.info(f"Found {alias_count} aliases (resolution not yet implemented)") + else: + logger.info("No aliases found") \ No newline at end of file diff --git a/docforge/model/__init__.py b/docforge/model/__init__.py new file mode 100644 index 0000000..d9eb121 --- /dev/null +++ b/docforge/model/__init__.py @@ -0,0 +1,14 @@ +"""Model package for doc-forge documentation objects.""" + +from .project import Project +from .module import Module +from .object import DocObject +from .nav import Navigation, NavEntry + +__all__ = [ + "Project", + "Module", + "DocObject", + "Navigation", + "NavEntry", +] \ No newline at end of file diff --git a/docforge/model/__init__.pyi b/docforge/model/__init__.pyi new file mode 100644 index 0000000..560f7dd --- /dev/null +++ b/docforge/model/__init__.pyi @@ -0,0 +1,72 @@ +"""Type stubs for doc-forge model package.""" + +from typing import Any, Dict, List, Optional, Union +from pathlib import Path + +class DocObject: + """Represents a Python documentation object (class, function, variable, etc.).""" + + name: str + kind: str + path: str + signature: Optional[str] + docstring: Optional[str] + members: Dict[str, 'DocObject'] + + def __init__(self, name: str, kind: str, path: str, signature: Optional[str] = None, docstring: Optional[str] = None) -> None: ... + + def add_member(self, member: 'DocObject') -> None: ... + + def get_member(self, name: str) -> Optional['DocObject']: ... + + def is_private(self) -> bool: ... + +class Module: + """Represents a Python module in the documentation model.""" + + path: str + docstring: Optional[str] + members: Dict[str, DocObject] + + def __init__(self, path: str, docstring: Optional[str] = None) -> None: ... + + def add_object(self, obj: DocObject) -> None: ... + + def get_object(self, name: str) -> Optional[DocObject]: ... + + def get_public_objects(self) -> List[DocObject]: ... + +class Project: + """Root container for all documentation in a project.""" + + name: str + version: Optional[str] + modules: Dict[str, Module] + nav: 'Navigation' + + def __init__(self, name: str, version: Optional[str] = None) -> None: ... + + def add_module(self, module: Module) -> None: ... + + def get_module(self, path: str) -> Optional[Module]: ... + + def get_all_modules(self) -> List[Module]: ... + +class Navigation: + """Navigation structure derived from project modules.""" + + entries: List['NavEntry'] + + def __init__(self) -> None: ... + + def add_entry(self, entry: 'NavEntry') -> None: ... + + def get_entry(self, title: str) -> Optional['NavEntry']: ... + +class NavEntry: + """Single navigation entry linking to a module.""" + + title: str + module: str + + def __init__(self, title: str, module: str) -> None: ... \ No newline at end of file diff --git a/docforge/model/module.py b/docforge/model/module.py new file mode 100644 index 0000000..fc400f1 --- /dev/null +++ b/docforge/model/module.py @@ -0,0 +1,129 @@ +"""Module representation in the doc-forge documentation model. + +Module represents a Python module that can be documented. It serves as +a container for all the documentation objects within that module, +including classes, functions, attributes, and constants. + +Each Module corresponds to a Python import path and contains the +module-level docstring along with all documented members. +""" + +from __future__ import annotations + +from typing import Dict, List, Optional + +from .object import DocObject + + +class Module: + """Represents a Python module in the documentation model. + + A Module is the primary organizational unit in doc-forge. It corresponds + to a Python module (identified by its import path) and contains all + the documentation objects within that module. + + Attributes: + path: The import path of the module (e.g., "package.submodule") + docstring: Optional module-level docstring + members: Dictionary of all documented objects in the module + """ + + def __init__(self, path: str, docstring: Optional[str] = None) -> None: + """Initialize a Module. + + Args: + path: The import path of the module + docstring: Optional module-level docstring + + Raises: + ValueError: If path is empty + """ + if not path: + raise ValueError("Module path cannot be empty") + + self.path: str = path + self.docstring: Optional[str] = docstring + self.members: Dict[str, DocObject] = {} + + def add_object(self, obj: DocObject) -> None: + """Add a documentation object to this module. + + Args: + obj: The documentation object to add + + Raises: + ValueError: If object name conflicts with existing object + """ + if obj.name in self.members: + raise ValueError(f"Object '{obj.name}' already exists in module '{self.path}'") + self.members[obj.name] = obj + + def get_object(self, name: str) -> Optional[DocObject]: + """Get a documentation object by name. + + Args: + name: The name of the object to retrieve + + Returns: + The DocObject if found, None otherwise + """ + return self.members.get(name) + + def get_public_objects(self) -> List[DocObject]: + """Get all public (non-private) documentation objects. + + Returns: + List of DocObjects that are not private + """ + return [obj for obj in self.members.values() if not obj.is_private()] + + def get_objects_by_kind(self, kind: str) -> List[DocObject]: + """Get all documentation objects of a specific kind. + + Args: + kind: The kind of objects to retrieve (e.g., "class", "function") + + Returns: + List of DocObjects matching the specified kind + """ + return [obj for obj in self.members.values() if obj.kind == kind] + + def get_classes(self) -> List[DocObject]: + """Get all class objects in this module. + + Returns: + List of DocObjects with kind "class" + """ + return self.get_objects_by_kind("class") + + def get_functions(self) -> List[DocObject]: + """Get all function objects in this module. + + Returns: + List of DocObjects with kind "function" + """ + return self.get_objects_by_kind("function") + + def has_docstring(self) -> bool: + """Check if this module has a docstring. + + Returns: + True if docstring is not None and not empty, False otherwise + """ + return bool(self.docstring and self.docstring.strip()) + + def is_empty(self) -> bool: + """Check if this module contains any documented objects. + + Returns: + True if the module has no members, False otherwise + """ + return len(self.members) == 0 + + def __repr__(self) -> str: + """Return a string representation of the Module. + + Returns: + String representation showing path and member count + """ + return f"Module(path='{self.path}', members={len(self.members)})" \ No newline at end of file diff --git a/docforge/model/nav.py b/docforge/model/nav.py new file mode 100644 index 0000000..72e8296 --- /dev/null +++ b/docforge/model/nav.py @@ -0,0 +1,210 @@ +"""Navigation structure for doc-forge documentation. + +Navigation provides a hierarchical structure for organizing documentation +modules. It is derived automatically from the project structure rather +than being manually authored, ensuring consistency between the +documentation model and the navigation. + +The navigation is used by renderers to generate table of contents, +sidebars, and other navigation elements. +""" + +from __future__ import annotations + +from typing import Dict, List, Optional + + +class NavEntry: + """Single navigation entry linking to a module. + + A NavEntry represents one item in the documentation navigation, + typically corresponding to a module. It contains a display title + and the module path it links to. + + Attributes: + title: The display title for this navigation entry + module: The import path of the module this entry links to + """ + + def __init__(self, title: str, module: str) -> None: + """Initialize a NavEntry. + + Args: + title: The display title for this entry + module: The import path of the linked module + + Raises: + ValueError: If title or module is empty + """ + if not title: + raise ValueError("NavEntry title cannot be empty") + if not module: + raise ValueError("NavEntry module cannot be empty") + + self.title: str = title + self.module: str = module + + def __repr__(self) -> str: + """Return a string representation of the NavEntry. + + Returns: + String representation showing title and module + """ + return f"NavEntry(title='{self.title}', module='{self.module}')" + + +class Navigation: + """Navigation structure derived from project modules. + + Navigation provides an organized hierarchy for browsing documentation. + It is automatically generated from the project's module structure, + ensuring that the navigation always reflects the actual available + documentation. + + The navigation can be customized by: + - Changing the order of entries + - Grouping related modules + - Providing custom titles + + Attributes: + entries: List of navigation entries in order + """ + + def __init__(self) -> None: + """Initialize an empty Navigation.""" + self.entries: List[NavEntry] = [] + + def add_entry(self, entry: NavEntry) -> None: + """Add a navigation entry. + + Args: + entry: The navigation entry to add + """ + self.entries.append(entry) + + def add_entry_by_module(self, module: str, title: Optional[str] = None) -> None: + """Add a navigation entry for a module. + + This is a convenience method that creates a NavEntry from a module + path. If no title is provided, the module name is used as the title. + + Args: + module: The import path of the module + title: Optional custom title (defaults to module name) + """ + if title is None: + # Use the last part of the module path as the title + title = module.split('.')[-1].replace('_', ' ').title() + + entry = NavEntry(title, module) + self.add_entry(entry) + + def get_entry(self, title: str) -> Optional[NavEntry]: + """Get a navigation entry by title. + + Args: + title: The title of the entry to find + + Returns: + The NavEntry if found, None otherwise + """ + for entry in self.entries: + if entry.title == title: + return entry + return None + + def get_entry_by_module(self, module: str) -> Optional[NavEntry]: + """Get a navigation entry by module path. + + Args: + module: The module path to search for + + Returns: + The NavEntry if found, None otherwise + """ + for entry in self.entries: + if entry.module == module: + return entry + return None + + def remove_entry(self, title: str) -> bool: + """Remove a navigation entry by title. + + Args: + title: The title of the entry to remove + + Returns: + True if entry was removed, False if not found + """ + for i, entry in enumerate(self.entries): + if entry.title == title: + del self.entries[i] + return True + return False + + def remove_entry_by_module(self, module: str) -> bool: + """Remove a navigation entry by module path. + + Args: + module: The module path of the entry to remove + + Returns: + True if entry was removed, False if not found + """ + for i, entry in enumerate(self.entries): + if entry.module == module: + del self.entries[i] + return True + return False + + def reorder_entries(self, titles: List[str]) -> None: + """Reorder entries based on provided title order. + + Entries not mentioned in the titles list will maintain their + relative order and be placed after the specified entries. + + Args: + titles: List of titles in the desired order + """ + # Create a mapping of title to entry for quick lookup + entry_map = {entry.title: entry for entry in self.entries} + + # Build new ordered list + ordered_entries = [] + remaining_entries = list(self.entries) + + # Add entries in specified order + for title in titles: + if title in entry_map: + ordered_entries.append(entry_map[title]) + # Remove from remaining entries + remaining_entries = [e for e in remaining_entries if e.title != title] + + # Add remaining entries in their original order + ordered_entries.extend(remaining_entries) + + self.entries = ordered_entries + + def is_empty(self) -> bool: + """Check if navigation has no entries. + + Returns: + True if navigation has no entries, False otherwise + """ + return len(self.entries) == 0 + + def get_module_list(self) -> List[str]: + """Get list of all module paths in navigation. + + Returns: + List of module paths in navigation order + """ + return [entry.module for entry in self.entries] + + def __repr__(self) -> str: + """Return a string representation of the Navigation. + + Returns: + String representation showing entry count + """ + return f"Navigation(entries={len(self.entries)})" \ No newline at end of file diff --git a/docforge/model/object.py b/docforge/model/object.py new file mode 100644 index 0000000..e91d606 --- /dev/null +++ b/docforge/model/object.py @@ -0,0 +1,131 @@ +"""Core documentation object representing a Python entity. + +DocObject is the atomic unit of documentation in doc-forge. It represents +any Python entity that can be documented: classes, functions, methods, +attributes, constants, etc. + +Each DocObject contains: +- Basic metadata (name, kind, path) +- Optional signature information +- Optional docstring +- Nested members (for classes and modules) +""" + +from __future__ import annotations + +from typing import Dict, Optional, Set + + +class DocObject: + """Represents a Python documentation object (class, function, variable, etc.). + + DocObject is the fundamental building block of the documentation model. + It captures all essential information about a Python entity in a + renderer-agnostic format. + + Attributes: + name: The name of the object (e.g., "MyClass", "my_function") + kind: The type of object ("class", "function", "method", "attribute", etc.) + path: The full import path (e.g., "package.module.MyClass.my_method") + signature: Optional function/method signature string + docstring: Optional docstring content + members: Dictionary of nested member objects + """ + + def __init__( + self, + name: str, + kind: str, + path: str, + signature: Optional[str] = None, + docstring: Optional[str] = None, + ) -> None: + """Initialize a DocObject. + + Args: + name: The name of the object + kind: The type/kind of object + path: Full import path to the object + signature: Optional signature for callable objects + docstring: Optional docstring content + + Raises: + ValueError: If name, kind, or path are empty + """ + if not name: + raise ValueError("DocObject name cannot be empty") + if not kind: + raise ValueError("DocObject kind cannot be empty") + if not path: + raise ValueError("DocObject path cannot be empty") + + self.name: str = name + self.kind: str = kind + self.path: str = path + self.signature: Optional[str] = signature + self.docstring: Optional[str] = docstring + self.members: Dict[str, DocObject] = {} + + def add_member(self, member: DocObject) -> None: + """Add a nested member object. + + This is used for objects that contain other objects, such as + classes containing methods and attributes, or modules containing + functions and classes. + + Args: + member: The member object to add + + Raises: + ValueError: If member name conflicts with existing member + """ + if member.name in self.members: + raise ValueError(f"Member '{member.name}' already exists in '{self.name}'") + self.members[member.name] = member + + def get_member(self, name: str) -> Optional[DocObject]: + """Get a nested member by name. + + Args: + name: The name of the member to retrieve + + Returns: + The member object if found, None otherwise + """ + return self.members.get(name) + + def is_private(self) -> bool: + """Check if this object is considered private. + + Private objects are those whose names start with an underscore. + This convention is used to filter out internal implementation + details from public documentation. + + Returns: + True if the object name starts with underscore, False otherwise + """ + return self.name.startswith('_') + + def get_public_members(self) -> list[DocObject]: + """Get all public (non-private) member objects. + + Returns: + List of member objects that are not private + """ + return [member for member in self.members.values() if not member.is_private()] + + def has_docstring(self) -> bool: + """Check if this object has a docstring. + + Returns: + True if docstring is not None and not empty, False otherwise + """ + return bool(self.docstring and self.docstring.strip()) + + def __repr__(self) -> str: + """Return a string representation of the DocObject. + + Returns: + String representation showing name, kind, and path + """ + return f"DocObject(name='{self.name}', kind='{self.kind}', path='{self.path}')" \ No newline at end of file diff --git a/docforge/model/project.py b/docforge/model/project.py new file mode 100644 index 0000000..7a01b34 --- /dev/null +++ b/docforge/model/project.py @@ -0,0 +1,194 @@ +"""Project representation in the doc-forge documentation model. + +Project is the root container for all documentation in a doc-forge +project. It represents the entire codebase being documented and serves +as the entry point for all documentation operations. + +A Project contains modules, navigation, and metadata about the +codebase being documented. +""" + +from __future__ import annotations + +from typing import Dict, List, Optional + +from .module import Module +from .nav import Navigation + + +class Project: + """Root container for all documentation in a project. + + Project is the top-level object in the doc-forge documentation model. + It represents the entire codebase being documented and serves as the + central hub for all documentation operations. + + Each Project contains: + - Basic metadata (name, version) + - All documented modules + - Navigation structure for browsing + + The Project is the single source of truth that all renderers and + exporters work with, ensuring consistency across all output formats. + + Attributes: + name: The name of the project + version: Optional version string + modules: Dictionary of all modules in the project + nav: Navigation structure for the project + """ + + def __init__(self, name: str, version: Optional[str] = None) -> None: + """Initialize a Project. + + Args: + name: The name of the project + version: Optional version string + + Raises: + ValueError: If name is empty + """ + if not name: + raise ValueError("Project name cannot be empty") + + self.name: str = name + self.version: Optional[str] = version + self.modules: Dict[str, Module] = {} + self.nav: Navigation = Navigation() + + def add_module(self, module: Module) -> None: + """Add a module to the project. + + When a module is added, it's also automatically added to the + navigation structure if not already present. + + Args: + module: The module to add + + Raises: + ValueError: If module path conflicts with existing module + """ + if module.path in self.modules: + raise ValueError(f"Module '{module.path}' already exists in project") + + self.modules[module.path] = module + + # Add to navigation if not already present + if not self.nav.get_entry_by_module(module.path): + self.nav.add_entry_by_module(module.path) + + def get_module(self, path: str) -> Optional[Module]: + """Get a module by its import path. + + Args: + path: The import path of the module to retrieve + + Returns: + The Module if found, None otherwise + """ + return self.modules.get(path) + + def get_all_modules(self) -> List[Module]: + """Get all modules in the project. + + Returns: + List of all Module objects in the project + """ + return list(self.modules.values()) + + def get_public_modules(self) -> List[Module]: + """Get all modules with public (non-empty) content. + + Returns: + List of modules that have at least one public object + """ + return [ + module for module in self.modules.values() + if module.get_public_objects() + ] + + def remove_module(self, path: str) -> bool: + """Remove a module from the project. + + Args: + path: The import path of the module to remove + + Returns: + True if module was removed, False if not found + """ + if path in self.modules: + del self.modules[path] + # Also remove from navigation + self.nav.remove_entry_by_module(path) + return True + return False + + def has_module(self, path: str) -> bool: + """Check if a module exists in the project. + + Args: + path: The import path to check + + Returns: + True if module exists, False otherwise + """ + return path in self.modules + + def get_module_count(self) -> int: + """Get the total number of modules in the project. + + Returns: + Number of modules in the project + """ + return len(self.modules) + + def is_empty(self) -> bool: + """Check if the project has no modules. + + Returns: + True if project has no modules, False otherwise + """ + return len(self.modules) == 0 + + def get_total_object_count(self) -> int: + """Get the total count of all documentation objects. + + Returns: + Total number of DocObjects across all modules + """ + return sum(len(module.members) for module in self.modules.values()) + + def get_modules_by_pattern(self, pattern: str) -> List[Module]: + """Get modules matching a pattern. + + Args: + pattern: Pattern to match against module paths (supports wildcards) + + Returns: + List of modules whose paths match the pattern + """ + import fnmatch + + return [ + module for module in self.modules.values() + if fnmatch.fnmatch(module.path, pattern) + ] + + def rebuild_navigation(self) -> None: + """Rebuild navigation from current modules. + + This clears the existing navigation and rebuilds it from the + current set of modules, ensuring navigation is in sync with + the actual project structure. + """ + self.nav = Navigation() + for module_path in sorted(self.modules.keys()): + self.nav.add_entry_by_module(module_path) + + def __repr__(self) -> str: + """Return a string representation of the Project. + + Returns: + String representation showing name and module count + """ + return f"Project(name='{self.name}', modules={len(self.modules)})" \ No newline at end of file diff --git a/docforge/renderers/__init__.py b/docforge/renderers/__init__.py new file mode 100644 index 0000000..2ab9f01 --- /dev/null +++ b/docforge/renderers/__init__.py @@ -0,0 +1,12 @@ +"""Renderers package for doc-forge output generation.""" + +from .base import DocRenderer, RendererConfig +from .mkdocs import MkDocsRenderer +from .sphinx import SphinxRenderer + +__all__ = [ + "DocRenderer", + "RendererConfig", + "MkDocsRenderer", + "SphinxRenderer", +] \ No newline at end of file diff --git a/docforge/renderers/__init__.pyi b/docforge/renderers/__init__.pyi new file mode 100644 index 0000000..e9902b7 --- /dev/null +++ b/docforge/renderers/__init__.pyi @@ -0,0 +1,51 @@ +"""Type stubs for doc-forge renderers package.""" + +from typing import Any, Dict, List, Optional, Protocol, Union +from pathlib import Path +from docforge.model import Project + +class DocRenderer(Protocol): + """Protocol for documentation renderers.""" + + name: str + + def generate_sources(self, project: Project, out_dir: Path) -> None: ... + + def build(self, config: 'RendererConfig') -> None: ... + + def serve(self, config: 'RendererConfig') -> None: ... + +class RendererConfig: + """Base configuration for renderers.""" + + out_dir: Path + project: Project + extra: Dict[str, Any] + + def __init__(self, out_dir: Path, project: Project, extra: Optional[Dict[str, Any]] = None) -> None: ... + +class MkDocsRenderer: + """MkDocs documentation renderer.""" + + name: str = "mkdocs" + + def __init__(self) -> None: ... + + def generate_sources(self, project: Project, out_dir: Path) -> None: ... + + def build(self, config: RendererConfig) -> None: ... + + def serve(self, config: RendererConfig) -> None: ... + +class SphinxRenderer: + """Sphinx documentation renderer.""" + + name: str = "sphinx" + + def __init__(self) -> None: ... + + def generate_sources(self, project: Project, out_dir: Path) -> None: ... + + def build(self, config: RendererConfig) -> None: ... + + def serve(self, config: RendererConfig) -> None: ... \ No newline at end of file diff --git a/docforge/renderers/base.py b/docforge/renderers/base.py new file mode 100644 index 0000000..80bd100 --- /dev/null +++ b/docforge/renderers/base.py @@ -0,0 +1,217 @@ +"""Base renderer interface for doc-forge output generation. + +The renderer system provides a pluggable architecture for generating +different output formats from the same documentation model. All renderers +implement the DocRenderer protocol, ensuring consistent behavior across +different output formats. + +This module defines the base interface and configuration that all +renderers must follow. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Any, Dict, Protocol, runtime_checkable + +from docforge.model import Project + + +@runtime_checkable +class DocRenderer(Protocol): + """Protocol for documentation renderers. + + DocRenderer defines the interface that all documentation renderers + must implement. This protocol ensures that renderers can be used + interchangeably while providing consistent behavior. + + All renderers must: + 1. Have a unique name identifier + 2. Generate source files from the documentation model + 3. Support building final artifacts + 4. Optionally support serving documentation locally + + Attributes: + name: Unique identifier for the renderer + """ + + name: str + + def generate_sources(self, project: Project, out_dir: Path) -> None: + """Generate renderer-specific source files. + + This method converts the documentation model into renderer-specific + source files (e.g., Markdown for MkDocs, reStructuredText for Sphinx). + The generated files are written to the specified output directory. + + Args: + project: The documentation project to render + out_dir: Directory where source files should be written + """ + ... + + def build(self, config: 'RendererConfig') -> None: + """Build final documentation artifacts. + + This method takes the generated source files and builds the final + documentation artifacts (e.g., HTML site, PDF, etc.). The specific + output depends on the renderer type. + + Args: + config: Configuration for the build process + """ + ... + + def serve(self, config: 'RendererConfig') -> None: + """Serve documentation locally (optional). + + This method starts a local development server to serve the + documentation. This is optional and not all renderers support + serving functionality. + + Args: + config: Configuration for the serve process + + Raises: + NotImplementedError: If serving is not supported + """ + ... + + +class RendererConfig: + """Base configuration for renderers. + + RendererConfig provides common configuration options that are + applicable to all renderers. Each renderer can extend this class + to add renderer-specific configuration options. + + Attributes: + out_dir: Output directory for generated files + project: The documentation project being rendered + extra: Additional renderer-specific configuration + """ + + def __init__( + self, + out_dir: Path, + project: Project, + extra: Optional[Dict[str, Any]] = None, + ) -> None: + """Initialize renderer configuration. + + Args: + out_dir: Output directory for generated files + project: The documentation project being rendered + extra: Additional renderer-specific configuration options + """ + self.out_dir: Path = out_dir + self.project: Project = project + self.extra: Dict[str, Any] = extra or {} + + def get_extra(self, key: str, default: Any = None) -> Any: + """Get an extra configuration value. + + Args: + key: The configuration key to retrieve + default: Default value if key is not found + + Returns: + The configuration value or default + """ + return self.extra.get(key, default) + + def set_extra(self, key: str, value: Any) -> None: + """Set an extra configuration value. + + Args: + key: The configuration key to set + value: The value to set + """ + self.extra[key] = value + + +class BaseRenderer(ABC): + """Abstract base class for renderers. + + BaseRenderer provides a common foundation for all renderer implementations. + It implements shared functionality and defines the abstract methods that + concrete renderers must implement. + + This class helps ensure consistent behavior across different renderer + implementations while reducing code duplication. + + Attributes: + name: Unique identifier for the renderer + """ + + name: str + + def __init__(self, name: str) -> None: + """Initialize the base renderer. + + Args: + name: Unique identifier for the renderer + """ + self.name = name + + @abstractmethod + def generate_sources(self, project: Project, out_dir: Path) -> None: + """Generate renderer-specific source files. + + Args: + project: The documentation project to render + out_dir: Directory where source files should be written + """ + pass + + @abstractmethod + def build(self, config: RendererConfig) -> None: + """Build final documentation artifacts. + + Args: + config: Configuration for the build process + """ + pass + + def serve(self, config: RendererConfig) -> None: + """Serve documentation locally. + + Default implementation raises NotImplementedError. Renderers that + support serving should override this method. + + Args: + config: Configuration for the serve process + + Raises: + NotImplementedError: If serving is not supported + """ + raise NotImplementedError(f"Serving is not supported by {self.name} renderer") + + def ensure_output_dir(self, out_dir: Path) -> None: + """Ensure the output directory exists. + + Args: + out_dir: Directory to ensure exists + """ + out_dir.mkdir(parents=True, exist_ok=True) + + def validate_project(self, project: Project) -> None: + """Validate that the project is suitable for rendering. + + Args: + project: The project to validate + + Raises: + ValueError: If the project is not valid for rendering + """ + if project.is_empty(): + raise ValueError("Project contains no modules to render") + + def __repr__(self) -> str: + """Return a string representation of the renderer. + + Returns: + String representation showing the renderer name + """ + return f"{self.__class__.__name__}(name='{self.name}')" \ No newline at end of file diff --git a/docforge/renderers/mkdocs.py b/docforge/renderers/mkdocs.py new file mode 100644 index 0000000..130e5fc --- /dev/null +++ b/docforge/renderers/mkdocs.py @@ -0,0 +1,268 @@ +"""MkDocs renderer for doc-forge. + +The MkDocs renderer generates MkDocs-compatible documentation from the +doc-forge documentation model. It creates Markdown files with mkdocstrings +directives and generates the necessary MkDocs configuration. + +This renderer follows the ADS specification by: +- Emitting .md files with mkdocstrings directives +- Using one file per module +- Supporting build and serve operations via MkDocs APIs +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any, Dict, List, Optional + +try: + import mkdocs + import mkdocs.commands.build + import mkdocs.commands.serve + import mkdocs.config + import yaml +except ImportError as e: + raise ImportError( + "mkdocs and mkdocstrings are required for MkDocs renderer. " + "Install with: pip install doc-forge[mkdocs]" + ) from e + +from docforge.model import Project, Module +from .base import BaseRenderer, RendererConfig + + +logger = logging.getLogger(__name__) + + +class MkDocsRenderer(BaseRenderer): + """MkDocs documentation renderer. + + The MkDocsRenderer converts the doc-forge documentation model into + MkDocs-compatible source files. It generates Markdown files with + mkdocstrings directives and creates the necessary MkDocs configuration. + + Generated output structure: + docs/ + ├── index.md + ├── module1.md + ├── module2.md + └── mkdocs.yml + + Attributes: + name: Renderer identifier ("mkdocs") + """ + + def __init__(self) -> None: + """Initialize the MkDocs renderer.""" + super().__init__("mkdocs") + + def generate_sources(self, project: Project, out_dir: Path) -> None: + """Generate MkDocs source files. + + Creates Markdown files for each module and generates the + mkdocs.yml configuration file. + + 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) + + logger.info(f"Generating MkDocs sources in {out_dir}") + + # Generate index.md + self._generate_index(project, out_dir) + + # Generate module files + for module in project.get_all_modules(): + self._generate_module_file(module, out_dir) + + # Generate mkdocs.yml + self._generate_mkdocs_config(project, out_dir) + + logger.info(f"Generated {len(project.get_all_modules())} module files") + + def build(self, config: RendererConfig) -> None: + """Build MkDocs documentation. + + Uses MkDocs build command to generate the final HTML documentation. + + Args: + config: Configuration for the build process + """ + self.validate_project(config.project) + + mkdocs_yml = config.out_dir / "mkdocs.yml" + if not mkdocs_yml.exists(): + raise ValueError(f"mkdocs.yml not found in {config.out_dir}") + + logger.info(f"Building MkDocs documentation from {config.out_dir}") + + # Load MkDocs configuration + mkdocs_config = mkdocs.config.load_config(str(mkdocs_yml)) + + # Run build + mkdocs.commands.build.build(mkdocs_config) + + logger.info("MkDocs build completed successfully") + + def serve(self, config: RendererConfig) -> None: + """Serve MkDocs documentation locally. + + Starts the MkDocs development server for local documentation + preview and testing. + + Args: + config: Configuration for the serve process + """ + self.validate_project(config.project) + + mkdocs_yml = config.out_dir / "mkdocs.yml" + if not mkdocs_yml.exists(): + raise ValueError(f"mkdocs.yml not found in {config.out_dir}") + + # Get serve options from config + host = config.get_extra("host", "127.0.0.1") + port = config.get_extra("port", 8000) + + logger.info(f"Serving MkDocs documentation at http://{host}:{port}") + + # Load MkDocs configuration + mkdocs_config = mkdocs.config.load_config(str(mkdocs_yml)) + + # Run serve + mkdocs.commands.serve.serve( + mkdocs_config, + dev_addr=f"{host}:{port}", + livereload="livereload" in config.extra, + ) + + def _generate_index(self, project: Project, out_dir: Path) -> None: + """Generate the index.md file. + + Args: + project: The documentation project + out_dir: Output directory + """ + index_path = out_dir / "index.md" + + content = [f"# {project.name}"] + + if project.version: + content.append(f"\n**Version:** {project.version}") + + content.append("\n## Modules") + content.append("") + + for entry in project.nav.entries: + content.append(f"- [{entry.title}]({entry.path}.md)") + + index_path.write_text("\n".join(content), encoding="utf-8") + logger.debug(f"Generated {index_path}") + + def _generate_module_file(self, module: Module, out_dir: Path) -> None: + """Generate a Markdown file for a module. + + Args: + module: The module to generate documentation for + out_dir: Output directory + """ + module_path = out_dir / f"{module.path}.md" + + content = [f"# {module.path}"] + + if module.has_docstring(): + content.append(f"\n{module.docstring}") + + content.append(f"\n::: {module.path}") + content.append(" options:") + content.append(" show_source: true") + content.append(" show_root_heading: true") + + module_path.write_text("\n".join(content), encoding="utf-8") + logger.debug(f"Generated {module_path}") + + def _generate_mkdocs_config(self, project: Project, out_dir: Path) -> None: + """Generate the mkdocs.yml configuration file. + + Args: + project: The documentation project + out_dir: Output directory + """ + config_path = out_dir / "mkdocs.yml" + + # Build navigation structure + nav = [] + for entry in project.nav.entries: + nav.append({entry.title: f"{entry.path}.md"}) + + # MkDocs configuration + config = { + "site_name": project.name, + "site_description": f"Documentation for {project.name}", + "nav": nav, + "plugins": ["mkdocstrings"], + "theme": { + "name": "material", + "features": ["navigation.instant", "navigation.tracking"], + }, + "markdown_extensions": [ + "codehilite", + "admonition", + "toc", + ], + "docs_dir": ".", + "site_dir": "_site", + } + + if project.version: + config["site_version"] = project.version + + # Write configuration as YAML + with open(config_path, "w", encoding="utf-8") as f: + yaml.dump(config, f, default_flow_style=False, sort_keys=False) + + logger.debug(f"Generated {config_path}") + + +class MkDocsConfig(RendererConfig): + """MkDocs-specific renderer configuration. + + Extends the base RendererConfig with MkDocs-specific options. + + Attributes: + theme: MkDocs theme to use + extra_css: Additional CSS files + extra_js: Additional JavaScript files + plugins: MkDocs plugins to enable + """ + + def __init__( + self, + out_dir: Path, + project: Project, + theme: str = "material", + extra_css: Optional[List[str]] = None, + extra_js: Optional[List[str]] = None, + plugins: Optional[List[str]] = None, + **extra, + ) -> None: + """Initialize MkDocs configuration. + + Args: + out_dir: Output directory for generated files + project: The documentation project being rendered + theme: MkDocs theme to use + extra_css: Additional CSS files + extra_js: Additional JavaScript files + plugins: MkDocs plugins to enable + **extra: Additional configuration options + """ + super().__init__(out_dir, project, extra) + + self.theme = theme + self.extra_css = extra_css or [] + self.extra_js = extra_js or [] + self.plugins = plugins or ["mkdocstrings"] \ No newline at end of file diff --git a/docforge/renderers/sphinx.py b/docforge/renderers/sphinx.py new file mode 100644 index 0000000..9da1988 --- /dev/null +++ b/docforge/renderers/sphinx.py @@ -0,0 +1,317 @@ +"""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", + ] \ No newline at end of file diff --git a/docforge/server/__init__.py b/docforge/server/__init__.py new file mode 100644 index 0000000..03f36bf --- /dev/null +++ b/docforge/server/__init__.py @@ -0,0 +1,5 @@ +"""Server package for doc-forge live documentation APIs.""" + +from .mcp_server import MCPServer + +__all__ = ["MCPServer"] \ No newline at end of file diff --git a/docforge/server/__init__.pyi b/docforge/server/__init__.pyi new file mode 100644 index 0000000..fc3e277 --- /dev/null +++ b/docforge/server/__init__.pyi @@ -0,0 +1,18 @@ +"""Type stubs for doc-forge server package.""" + +from typing import Any, Dict, List, Optional, Union +from pathlib import Path +from docforge.model import Project + +class MCPServer: + """Live MCP server for documentation queries.""" + + def __init__(self, project: Project) -> None: ... + + def start(self, host: str = "localhost", port: int = 8080) -> None: ... + + def stop(self) -> None: ... + + def handle_request(self, request: Dict[str, Any]) -> Dict[str, Any]: ... + + def get_resource(self, uri: str) -> Optional[Dict[str, Any]]: ... \ No newline at end of file diff --git a/docforge/server/mcp_server.py b/docforge/server/mcp_server.py new file mode 100644 index 0000000..54042e7 --- /dev/null +++ b/docforge/server/mcp_server.py @@ -0,0 +1,389 @@ +"""MCP server for doc-forge live documentation APIs. + +The MCP server exposes documentation as queryable resources via the +Model Context Protocol. This provides a live, stateless API for accessing +documentation data in real-time. + +The server follows the ADS specification by: +- Being read-only and stateless +- Exposing documentation as queryable resources +- Being backed by the documentation model (not static files) +- Supporting the standard MCP resource interface +""" + +from __future__ import annotations + +import json +import logging +from typing import Any, Dict, List, Optional, Union +from urllib.parse import urlparse + +from docforge.model import Project, Module, DocObject + + +logger = logging.getLogger(__name__) + + +class MCPServer: + """Live MCP server for documentation queries. + + The MCPServer provides a real-time API for accessing documentation + through the Model Context Protocol. Unlike the static MCP exporter, + this server works directly with the live documentation model, + providing up-to-date access to documentation data. + + The server exposes these resources: + - docs://index - Project metadata + - docs://nav - Navigation structure + - docs://module/{module} - Individual module data + + The server is: + - Read-only: No modifications allowed + - Stateless: No session state maintained + - Live: Direct access to documentation model + - Queryable: Supports MCP resource queries + + Attributes: + project: The documentation project being served + """ + + def __init__(self, project: Project) -> None: + """Initialize the MCP server. + + Args: + project: The documentation project to serve + + Raises: + ValueError: If project is empty + """ + if project.is_empty(): + raise ValueError("Cannot serve empty project") + + self.project = project + self._running = False + self._server = None + + def start(self, host: str = "localhost", port: int = 8080) -> None: + """Start the MCP server. + + Args: + host: Host to bind the server to + port: Port to bind the server to + + Raises: + RuntimeError: If server is already running + """ + if self._running: + raise RuntimeError("MCP server is already running") + + logger.info(f"Starting MCP server on {host}:{port}") + + # Note: This is a simplified implementation + # In a full implementation, you would use the actual MCP server library + # For now, we'll create a basic HTTP server that handles MCP requests + + try: + self._start_http_server(host, port) + self._running = True + logger.info(f"MCP server started on http://{host}:{port}") + except Exception as e: + logger.error(f"Failed to start MCP server: {e}") + raise + + def stop(self) -> None: + """Stop the MCP server. + + Raises: + RuntimeError: If server is not running + """ + if not self._running: + raise RuntimeError("MCP server is not running") + + logger.info("Stopping MCP server") + + if self._server: + self._server.shutdown() + self._server = None + + self._running = False + logger.info("MCP server stopped") + + def handle_request(self, request: Dict[str, Any]) -> Dict[str, Any]: + """Handle an MCP request. + + This method processes incoming MCP requests and returns appropriate + responses. It supports the standard MCP resource operations. + + Args: + request: The MCP request dictionary + + Returns: + MCP response dictionary + + Raises: + ValueError: If request is invalid + """ + method = request.get("method") + + if method == "resources/list": + return self._handle_resources_list() + elif method == "resources/read": + return self._handle_resources_read(request) + else: + return { + "error": { + "code": -32601, + "message": f"Method not supported: {method}", + } + } + + def get_resource(self, uri: str) -> Optional[Dict[str, Any]]: + """Get a resource by URI. + + Args: + uri: The resource URI (e.g., "docs://index") + + Returns: + Resource data or None if not found + """ + parsed = urlparse(uri) + + if parsed.scheme != "docs": + return None + + path = parsed.path.lstrip("/") + + if path == "index": + return self._get_index_resource() + elif path == "nav": + return self._get_nav_resource() + elif path.startswith("module/"): + module_path = path[7:] # Remove "module/" prefix + return self._get_module_resource(module_path) + else: + return None + + def _handle_resources_list(self) -> Dict[str, Any]: + """Handle resources/list request. + + Returns: + List of available resources + """ + resources = [ + { + "uri": "docs://index", + "name": "Project Index", + "description": "Project metadata and information", + "mimeType": "application/json", + }, + { + "uri": "docs://nav", + "name": "Navigation", + "description": "Documentation navigation structure", + "mimeType": "application/json", + }, + ] + + # Add module resources + for module in self.project.get_all_modules(): + resources.append({ + "uri": f"docs://module/{module.path}", + "name": module.path, + "description": f"Documentation for {module.path}", + "mimeType": "application/json", + }) + + return { + "resources": resources, + } + + def _handle_resources_read(self, request: Dict[str, Any]) -> Dict[str, Any]: + """Handle resources/read request. + + Args: + request: The read request + + Returns: + Resource content or error + """ + uri = request.get("params", {}).get("uri") + + if not uri: + return { + "error": { + "code": -32602, + "message": "Missing URI parameter", + } + } + + resource = self.get_resource(uri) + + if resource is None: + return { + "error": { + "code": -32602, + "message": f"Resource not found: {uri}", + } + } + + return { + "contents": [ + { + "uri": uri, + "mimeType": "application/json", + "text": json.dumps(resource, indent=2, ensure_ascii=False), + } + ], + } + + def _get_index_resource(self) -> Dict[str, Any]: + """Get the index resource. + + Returns: + Index resource data + """ + return { + "name": self.project.name, + "version": self.project.version, + "module_count": len(self.project.get_all_modules()), + "total_objects": self.project.get_total_object_count(), + "server": "doc-forge MCP server", + } + + def _get_nav_resource(self) -> Dict[str, Any]: + """Get the navigation resource. + + Returns: + Navigation resource data + """ + return { + "entries": [ + { + "title": entry.title, + "module": entry.module, + "uri": f"docs://module/{entry.module}", + } + for entry in self.project.nav.entries + ] + } + + def _get_module_resource(self, module_path: str) -> Optional[Dict[str, Any]]: + """Get a module resource. + + Args: + module_path: The module path + + Returns: + Module resource data or None if not found + """ + module = self.project.get_module(module_path) + + if module is None: + return None + + return { + "path": module.path, + "docstring": module.docstring, + "objects": [self._serialize_object(obj) for obj in module.get_public_objects()], + } + + def _serialize_object(self, obj: DocObject) -> Dict[str, Any]: + """Serialize a DocObject for MCP response. + + Args: + obj: The DocObject to serialize + + Returns: + Serialized object data + """ + data = { + "name": obj.name, + "kind": obj.kind, + "path": obj.path, + "docstring": obj.docstring, + } + + if obj.signature: + data["signature"] = obj.signature + + if obj.members: + data["members"] = [ + self._serialize_object(member) + for member in obj.get_public_members() + ] + + return data + + def _start_http_server(self, host: str, port: int) -> None: + """Start a simple HTTP server for MCP requests. + + Args: + host: Host to bind to + port: Port to bind to + """ + import http.server + import socketserver + from threading import Thread + + class MCPRequestHandler(http.server.SimpleHTTPRequestHandler): + def __init__(self, project: Project, *args, **kwargs): + self.project = project + self.server_instance = MCPServer(project) + super().__init__(*args, **kwargs) + + def do_POST(self): + """Handle POST requests (MCP JSON-RPC).""" + content_length = int(self.headers.get('Content-Length', 0)) + + if content_length == 0: + self.send_error(400, "Empty request body") + return + + try: + # Read request body + body = self.rfile.read(content_length) + request = json.loads(body.decode('utf-8')) + + # Handle request + response = self.server_instance.handle_request(request) + + # Send response + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(json.dumps(response).encode('utf-8')) + + except json.JSONDecodeError: + self.send_error(400, "Invalid JSON") + except Exception as e: + logger.error(f"Request handling error: {e}") + self.send_error(500, "Internal server error") + + def do_GET(self): + """Handle GET requests (health check).""" + if self.path == '/health': + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(json.dumps({"status": "ok"}).encode('utf-8')) + else: + self.send_error(404, "Not found") + + # Create handler factory + def handler_factory(*args, **kwargs): + return MCPRequestHandler(self.project, *args, **kwargs) + + # Start server + self._server = socketserver.TCPServer((host, port), handler_factory) + + # Run server in separate thread + server_thread = Thread(target=self._server.serve_forever, daemon=True) + server_thread.start() + + def is_running(self) -> bool: + """Check if the server is currently running. + + Returns: + True if server is running, False otherwise + """ + return self._running \ No newline at end of file diff --git a/docforge/stubs.pyi b/docforge/stubs.pyi new file mode 100644 index 0000000..a81cc27 --- /dev/null +++ b/docforge/stubs.pyi @@ -0,0 +1,116 @@ +"""Type stubs for doc-forge core model objects.""" + +from typing import Any, Dict, List, Optional, Protocol +from pathlib import Path + +class DocObject: + """Represents a Python documentation object (class, function, variable, etc.).""" + + name: str + kind: str + path: str + signature: Optional[str] + docstring: Optional[str] + members: Dict[str, 'DocObject'] + + def __init__(self, name: str, kind: str, path: str, signature: Optional[str] = None, docstring: Optional[str] = None) -> None: ... + + def add_member(self, member: 'DocObject') -> None: ... + + def get_member(self, name: str) -> Optional['DocObject']: ... + + def is_private(self) -> bool: ... + +class Module: + """Represents a Python module in the documentation model.""" + + path: str + docstring: Optional[str] + members: Dict[str, DocObject] + + def __init__(self, path: str, docstring: Optional[str] = None) -> None: ... + + def add_object(self, obj: DocObject) -> None: ... + + def get_object(self, name: str) -> Optional[DocObject]: ... + + def get_public_objects(self) -> List[DocObject]: ... + +class Project: + """Root container for all documentation in a project.""" + + name: str + version: Optional[str] + modules: Dict[str, Module] + nav: 'Navigation' + + def __init__(self, name: str, version: Optional[str] = None) -> None: ... + + def add_module(self, module: Module) -> None: ... + + def get_module(self, path: str) -> Optional[Module]: ... + + def get_all_modules(self) -> List[Module]: ... + +class Navigation: + """Navigation structure derived from project modules.""" + + entries: List['NavEntry'] + + def __init__(self) -> None: ... + + def add_entry(self, entry: 'NavEntry') -> None: ... + + def get_entry(self, title: str) -> Optional['NavEntry']: ... + +class NavEntry: + """Single navigation entry linking to a module.""" + + title: str + module: str + + def __init__(self, title: str, module: str) -> None: ... + +class DocRenderer(Protocol): + """Protocol for documentation renderers.""" + + name: str + + def generate_sources(self, project: Project, out_dir: Path) -> None: ... + + def build(self, config: 'RendererConfig') -> None: ... + + def serve(self, config: 'RendererConfig') -> None: ... + +class RendererConfig: + """Base configuration for renderers.""" + + out_dir: Path + project: Project + + def __init__(self, out_dir: Path, project: Project) -> None: ... + +class GriffeLoader: + """Loads Python modules using Griffe introspection.""" + + def __init__(self) -> None: ... + + def load_project(self, module_paths: List[str]) -> Project: ... + + def load_module(self, path: str) -> Module: ... + +class MCPExporter: + """Exports documentation model to MCP JSON format.""" + + def __init__(self) -> None: ... + + def export(self, project: Project, out_dir: Path) -> None: ... + +class MCPServer: + """Live MCP server for documentation queries.""" + + def __init__(self, project: Project) -> None: ... + + def start(self, host: str = "localhost", port: int = 8080) -> None: ... + + def stop(self) -> None: ... \ No newline at end of file diff --git a/docforge/utils/__init__.py b/docforge/utils/__init__.py new file mode 100644 index 0000000..485fb59 --- /dev/null +++ b/docforge/utils/__init__.py @@ -0,0 +1 @@ +"""Utility functions and helpers for doc-forge.""" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d7a7e0c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,86 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "doc-forge" +version = "0.1.0" +description = "A renderer-agnostic Python documentation compiler" +readme = "README.md" +license = {text = "MIT"} +authors = [ + {name = "doc-forge team"}, +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +requires-python = ">=3.8" +dependencies = [ + "griffe>=0.45.0", + "click>=8.0.0", + "pydantic>=2.0.0", +] + +[project.optional-dependencies] +mkdocs = [ + "mkdocs>=1.5.0", + "mkdocstrings[python]>=0.20.0", +] +sphinx = [ + "sphinx>=5.0.0", + "sphinx-autodoc-typehints>=1.19.0", +] +mcp = [ + "mcp>=1.0.0", +] +dev = [ + "pytest>=7.0.0", + "pytest-cov>=4.0.0", + "black>=23.0.0", + "ruff>=0.1.0", + "mypy>=1.0.0", +] + +[project.scripts] +doc-forge = "docforge.cli.main:main" + +[project.urls] +Homepage = "https://github.com/doc-forge/doc-forge" +Repository = "https://github.com/doc-forge/doc-forge" +Documentation = "https://doc-forge.readthedocs.io" + +[tool.hatch.build.targets.wheel] +packages = ["docforge"] + +[tool.black] +line-length = 88 +target-version = ['py38'] + +[tool.ruff] +line-length = 88 +target-version = "py38" +select = ["E", "F", "W", "I", "N", "UP", "B", "A", "C4", "DTZ", "T10", "EM", "EXE", "ISC", "ICN", "G", "PIE", "T20", "PYI", "PT", "Q", "RSE", "RET", "SIM", "TID", "TCH", "ARG", "PTH", "ERA", "PGH", "PL", "TRY", "NPY", "RUF"] +ignore = ["E501"] + +[tool.mypy] +python_version = "3.8" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +warn_unreachable = true +strict_equality = true \ No newline at end of file