init docforge lib
This commit is contained in:
243
README.md
Normal file
243
README.md
Normal 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
28
docforge/__init__.py
Normal 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
5
docforge/cli/__init__.py
Normal 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
33
docforge/cli/__init__.pyi
Normal 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
333
docforge/cli/main.py
Normal 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()
|
||||||
5
docforge/exporters/__init__.py
Normal file
5
docforge/exporters/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Exporters package for doc-forge output formats."""
|
||||||
|
|
||||||
|
from .mcp import MCPExporter
|
||||||
|
|
||||||
|
__all__ = ["MCPExporter"]
|
||||||
18
docforge/exporters/__init__.pyi
Normal file
18
docforge/exporters/__init__.pyi
Normal 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
281
docforge/exporters/mcp.py
Normal 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
|
||||||
5
docforge/loader/__init__.py
Normal file
5
docforge/loader/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Loader package for doc-forge introspection layer."""
|
||||||
|
|
||||||
|
from .griffe_loader import GriffeLoader
|
||||||
|
|
||||||
|
__all__ = ["GriffeLoader"]
|
||||||
16
docforge/loader/__init__.pyi
Normal file
16
docforge/loader/__init__.pyi
Normal 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: ...
|
||||||
289
docforge/loader/griffe_loader.py
Normal file
289
docforge/loader/griffe_loader.py
Normal 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")
|
||||||
14
docforge/model/__init__.py
Normal file
14
docforge/model/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
72
docforge/model/__init__.pyi
Normal file
72
docforge/model/__init__.pyi
Normal 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
129
docforge/model/module.py
Normal 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
210
docforge/model/nav.py
Normal 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
131
docforge/model/object.py
Normal 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
194
docforge/model/project.py
Normal 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)})"
|
||||||
12
docforge/renderers/__init__.py
Normal file
12
docforge/renderers/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
51
docforge/renderers/__init__.pyi
Normal file
51
docforge/renderers/__init__.pyi
Normal 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
217
docforge/renderers/base.py
Normal 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}')"
|
||||||
268
docforge/renderers/mkdocs.py
Normal file
268
docforge/renderers/mkdocs.py
Normal 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"]
|
||||||
317
docforge/renderers/sphinx.py
Normal file
317
docforge/renderers/sphinx.py
Normal 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",
|
||||||
|
]
|
||||||
5
docforge/server/__init__.py
Normal file
5
docforge/server/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Server package for doc-forge live documentation APIs."""
|
||||||
|
|
||||||
|
from .mcp_server import MCPServer
|
||||||
|
|
||||||
|
__all__ = ["MCPServer"]
|
||||||
18
docforge/server/__init__.pyi
Normal file
18
docforge/server/__init__.pyi
Normal 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]]: ...
|
||||||
389
docforge/server/mcp_server.py
Normal file
389
docforge/server/mcp_server.py
Normal 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
116
docforge/stubs.pyi
Normal 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: ...
|
||||||
1
docforge/utils/__init__.py
Normal file
1
docforge/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Utility functions and helpers for doc-forge."""
|
||||||
86
pyproject.toml
Normal file
86
pyproject.toml
Normal 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
|
||||||
Reference in New Issue
Block a user