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