init docforge lib
This commit is contained in:
289
docforge/loader/griffe_loader.py
Normal file
289
docforge/loader/griffe_loader.py
Normal file
@@ -0,0 +1,289 @@
|
||||
"""Griffe-based loader for doc-forge introspection layer.
|
||||
|
||||
The GriffeLoader uses the Griffe library to introspect Python source code
|
||||
and extract documentation information. It converts Griffe's internal
|
||||
representation into doc-forge's renderer-agnostic documentation model.
|
||||
|
||||
Griffe is the only supported introspection backend in doc-forge, ensuring
|
||||
consistent and reliable extraction of documentation information from
|
||||
Python source code.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
try:
|
||||
import griffe
|
||||
from griffe import Docstring, ObjectNode
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"griffe is required for doc-forge. Install with: pip install griffe"
|
||||
) from e
|
||||
|
||||
from docforge.model import Module, Project, DocObject
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GriffeLoader:
|
||||
"""Loads Python modules using Griffe introspection.
|
||||
|
||||
GriffeLoader is the bridge between Python source code and doc-forge's
|
||||
documentation model. It uses Griffe to parse Python modules, extract
|
||||
docstrings, signatures, and structural information, then converts
|
||||
this data into doc-forge's renderer-agnostic model.
|
||||
|
||||
The loader handles:
|
||||
- Module discovery and loading
|
||||
- Docstring extraction
|
||||
- Signature parsing
|
||||
- Member resolution
|
||||
- Alias handling (with graceful failure)
|
||||
|
||||
Attributes:
|
||||
griffe_agent: The Griffe agent used for introspection
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the GriffeLoader.
|
||||
|
||||
Creates a Griffe agent with default configuration for
|
||||
documentation extraction.
|
||||
"""
|
||||
self.griffe_agent = griffe.Agent(
|
||||
extensions=(),
|
||||
resolve_aliases=True,
|
||||
resolve_imports=True,
|
||||
)
|
||||
|
||||
def load_project(self, module_paths: List[str], project_name: Optional[str] = None) -> Project:
|
||||
"""Load a complete project from multiple module paths.
|
||||
|
||||
This is the primary entry point for loading documentation. It takes
|
||||
a list of module paths, loads each one, and assembles them into a
|
||||
complete Project with navigation.
|
||||
|
||||
Args:
|
||||
module_paths: List of import paths to load
|
||||
project_name: Optional name for the project (defaults to first module)
|
||||
|
||||
Returns:
|
||||
Project containing all loaded modules
|
||||
|
||||
Raises:
|
||||
ValueError: If no module paths provided
|
||||
ImportError: If any module cannot be loaded
|
||||
"""
|
||||
if not module_paths:
|
||||
raise ValueError("At least one module path must be provided")
|
||||
|
||||
if project_name is None:
|
||||
project_name = module_paths[0].split('.')[0]
|
||||
|
||||
project = Project(name=project_name)
|
||||
|
||||
for module_path in module_paths:
|
||||
try:
|
||||
module = self.load_module(module_path)
|
||||
project.add_module(module)
|
||||
logger.info(f"Loaded module: {module_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load module {module_path}: {e}")
|
||||
# Continue loading other modules rather than failing completely
|
||||
continue
|
||||
|
||||
# Resolve any cross-module aliases
|
||||
self.resolve_aliases(project)
|
||||
|
||||
return project
|
||||
|
||||
def load_module(self, path: str) -> Module:
|
||||
"""Load a single module from its import path.
|
||||
|
||||
Args:
|
||||
path: The import path of the module to load
|
||||
|
||||
Returns:
|
||||
Module containing all documented objects
|
||||
|
||||
Raises:
|
||||
ImportError: If the module cannot be loaded or found
|
||||
"""
|
||||
try:
|
||||
griffe_obj = self.griffe_agent.load_module(path)
|
||||
except Exception as e:
|
||||
raise ImportError(f"Failed to load module '{path}': {e}") from e
|
||||
|
||||
return self._convert_griffe_object_to_module(griffe_obj)
|
||||
|
||||
def _convert_griffe_object_to_module(self, griffe_obj: ObjectNode) -> Module:
|
||||
"""Convert a Griffe ObjectNode to a doc-forge Module.
|
||||
|
||||
Args:
|
||||
griffe_obj: The Griffe object to convert
|
||||
|
||||
Returns:
|
||||
Module containing converted documentation objects
|
||||
"""
|
||||
module = Module(
|
||||
path=griffe_obj.canonical_path,
|
||||
docstring=self._extract_docstring(griffe_obj.docstring),
|
||||
)
|
||||
|
||||
# Convert all members
|
||||
for name, member in griffe_obj.members.items():
|
||||
if not name.startswith('_'): # Skip private members
|
||||
try:
|
||||
doc_obj = self._convert_griffe_object_to_docobject(member)
|
||||
module.add_object(doc_obj)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to convert member {name}: {e}")
|
||||
continue
|
||||
|
||||
return module
|
||||
|
||||
def _convert_griffe_object_to_docobject(self, griffe_obj: ObjectNode) -> DocObject:
|
||||
"""Convert a Griffe ObjectNode to a doc-forge DocObject.
|
||||
|
||||
Args:
|
||||
griffe_obj: The Griffe object to convert
|
||||
|
||||
Returns:
|
||||
DocObject with converted information
|
||||
"""
|
||||
# Determine the kind of object
|
||||
kind = self._determine_object_kind(griffe_obj)
|
||||
|
||||
# Extract signature for callable objects
|
||||
signature = self._extract_signature(griffe_obj, kind)
|
||||
|
||||
doc_obj = DocObject(
|
||||
name=griffe_obj.name,
|
||||
kind=kind,
|
||||
path=griffe_obj.canonical_path,
|
||||
signature=signature,
|
||||
docstring=self._extract_docstring(griffe_obj.docstring),
|
||||
)
|
||||
|
||||
# Convert nested members (for classes)
|
||||
if kind == "class":
|
||||
for name, member in griffe_obj.members.items():
|
||||
if not name.startswith('_'): # Skip private members
|
||||
try:
|
||||
nested_obj = self._convert_griffe_object_to_docobject(member)
|
||||
doc_obj.add_member(nested_obj)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to convert nested member {name}: {e}")
|
||||
continue
|
||||
|
||||
return doc_obj
|
||||
|
||||
def _determine_object_kind(self, griffe_obj: ObjectNode) -> str:
|
||||
"""Determine the kind of documentation object.
|
||||
|
||||
Args:
|
||||
griffe_obj: The Griffe object to classify
|
||||
|
||||
Returns:
|
||||
String representing the object kind
|
||||
"""
|
||||
if griffe_obj.is_class:
|
||||
return "class"
|
||||
elif griffe_obj.is_function:
|
||||
return "function"
|
||||
elif griffe_obj.is_method:
|
||||
return "method"
|
||||
elif griffe_obj.is_property:
|
||||
return "property"
|
||||
elif griffe_obj.is_attribute:
|
||||
return "attribute"
|
||||
elif griffe_obj.is_module:
|
||||
return "module"
|
||||
else:
|
||||
return "object"
|
||||
|
||||
def _extract_signature(self, griffe_obj: ObjectNode, kind: str) -> Optional[str]:
|
||||
"""Extract signature string from a Griffe object.
|
||||
|
||||
Args:
|
||||
griffe_obj: The Griffe object to extract signature from
|
||||
kind: The kind of object
|
||||
|
||||
Returns:
|
||||
Signature string or None if not applicable
|
||||
"""
|
||||
if kind not in ("function", "method"):
|
||||
return None
|
||||
|
||||
try:
|
||||
if hasattr(griffe_obj, 'parameters') and griffe_obj.parameters:
|
||||
params = []
|
||||
for param in griffe_obj.parameters.values():
|
||||
param_str = param.name
|
||||
if param.annotation:
|
||||
param_str += f": {param.annotation}"
|
||||
if param.default and param.default != "None":
|
||||
param_str += f" = {param.default}"
|
||||
params.append(param_str)
|
||||
|
||||
signature = f"({', '.join(params)})"
|
||||
if hasattr(griffe_obj, 'returns') and griffe_obj.returns:
|
||||
signature += f" -> {griffe_obj.returns}"
|
||||
|
||||
return signature
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to extract signature for {griffe_obj.name}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def _extract_docstring(self, docstring: Optional[Docstring]) -> Optional[str]:
|
||||
"""Extract docstring content from a Griffe Docstring object.
|
||||
|
||||
Args:
|
||||
docstring: The Griffe docstring object
|
||||
|
||||
Returns:
|
||||
Plain text docstring or None
|
||||
"""
|
||||
if docstring is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return str(docstring.value).strip()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to extract docstring: {e}")
|
||||
return None
|
||||
|
||||
def resolve_aliases(self, project: Project) -> None:
|
||||
"""Resolve cross-module aliases in the project.
|
||||
|
||||
This method attempts to resolve aliases that point to objects in
|
||||
other modules. It updates the documentation model to reflect the
|
||||
actual locations of objects rather than their aliases.
|
||||
|
||||
Args:
|
||||
project: The project to resolve aliases in
|
||||
"""
|
||||
logger.info("Resolving cross-module aliases...")
|
||||
|
||||
# This is a placeholder for alias resolution
|
||||
# In a full implementation, this would:
|
||||
# 1. Identify all aliases in the project
|
||||
# 2. Resolve them to their canonical targets
|
||||
# 3. Update the documentation model accordingly
|
||||
# 4. Handle circular references gracefully
|
||||
|
||||
# For now, we'll just log that alias resolution was attempted
|
||||
alias_count = 0
|
||||
for module in project.get_all_modules():
|
||||
for obj in module.members.values():
|
||||
if hasattr(obj, 'is_alias') and obj.is_alias:
|
||||
alias_count += 1
|
||||
|
||||
if alias_count > 0:
|
||||
logger.info(f"Found {alias_count} aliases (resolution not yet implemented)")
|
||||
else:
|
||||
logger.info("No aliases found")
|
||||
Reference in New Issue
Block a user