cleanup code
This commit is contained in:
@@ -1,28 +0,0 @@
|
||||
"""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",
|
||||
]
|
||||
@@ -1,5 +0,0 @@
|
||||
"""CLI package for doc-forge command-line interface."""
|
||||
|
||||
from .main import main
|
||||
|
||||
__all__ = ["main"]
|
||||
@@ -1,33 +0,0 @@
|
||||
"""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: ...
|
||||
@@ -1,333 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Exporters package for doc-forge output formats."""
|
||||
|
||||
from .mcp import MCPExporter
|
||||
|
||||
__all__ = ["MCPExporter"]
|
||||
@@ -1,18 +0,0 @@
|
||||
"""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: ...
|
||||
@@ -1,281 +0,0 @@
|
||||
"""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
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Loader package for doc-forge introspection layer."""
|
||||
|
||||
from .griffe_loader import GriffeLoader
|
||||
|
||||
__all__ = ["GriffeLoader"]
|
||||
@@ -1,16 +0,0 @@
|
||||
"""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: ...
|
||||
@@ -1,289 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,14 +0,0 @@
|
||||
"""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",
|
||||
]
|
||||
@@ -1,72 +0,0 @@
|
||||
"""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: ...
|
||||
@@ -1,129 +0,0 @@
|
||||
"""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)})"
|
||||
@@ -1,210 +0,0 @@
|
||||
"""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)})"
|
||||
@@ -1,131 +0,0 @@
|
||||
"""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}')"
|
||||
@@ -1,194 +0,0 @@
|
||||
"""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)})"
|
||||
@@ -1,12 +0,0 @@
|
||||
"""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",
|
||||
]
|
||||
@@ -1,51 +0,0 @@
|
||||
"""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: ...
|
||||
@@ -1,217 +0,0 @@
|
||||
"""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}')"
|
||||
@@ -1,268 +0,0 @@
|
||||
"""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"]
|
||||
@@ -1,317 +0,0 @@
|
||||
"""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",
|
||||
]
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Server package for doc-forge live documentation APIs."""
|
||||
|
||||
from .mcp_server import MCPServer
|
||||
|
||||
__all__ = ["MCPServer"]
|
||||
@@ -1,18 +0,0 @@
|
||||
"""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]]: ...
|
||||
@@ -1,389 +0,0 @@
|
||||
"""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
|
||||
@@ -1,116 +0,0 @@
|
||||
"""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 +0,0 @@
|
||||
"""Utility functions and helpers for doc-forge."""
|
||||
Reference in New Issue
Block a user