init docforge lib

This commit is contained in:
2026-01-20 18:22:16 +05:30
parent 86a4f8f41a
commit a45725160d
28 changed files with 3486 additions and 0 deletions

243
README.md Normal file
View File

@@ -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.*

28
docforge/__init__.py Normal file
View File

@@ -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",
]

5
docforge/cli/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""CLI package for doc-forge command-line interface."""
from .main import main
__all__ = ["main"]

33
docforge/cli/__init__.pyi Normal file
View File

@@ -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: ...

333
docforge/cli/main.py Normal file
View File

@@ -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()

View File

@@ -0,0 +1,5 @@
"""Exporters package for doc-forge output formats."""
from .mcp import MCPExporter
__all__ = ["MCPExporter"]

View File

@@ -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: ...

281
docforge/exporters/mcp.py Normal file
View File

@@ -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

View File

@@ -0,0 +1,5 @@
"""Loader package for doc-forge introspection layer."""
from .griffe_loader import GriffeLoader
__all__ = ["GriffeLoader"]

View File

@@ -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: ...

View File

@@ -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")

View File

@@ -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",
]

View File

@@ -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: ...

129
docforge/model/module.py Normal file
View File

@@ -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)})"

210
docforge/model/nav.py Normal file
View File

@@ -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)})"

131
docforge/model/object.py Normal file
View File

@@ -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}')"

194
docforge/model/project.py Normal file
View File

@@ -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)})"

View File

@@ -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",
]

View File

@@ -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: ...

217
docforge/renderers/base.py Normal file
View File

@@ -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}')"

View File

@@ -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"]

View File

@@ -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",
]

View File

@@ -0,0 +1,5 @@
"""Server package for doc-forge live documentation APIs."""
from .mcp_server import MCPServer
__all__ = ["MCPServer"]

View File

@@ -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]]: ...

View File

@@ -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

116
docforge/stubs.pyi Normal file
View File

@@ -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: ...

View File

@@ -0,0 +1 @@
"""Utility functions and helpers for doc-forge."""

86
pyproject.toml Normal file
View File

@@ -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