""" This module provides the GriffeLoader, which uses the 'griffe' library to introspect Python source code and populate the doc-forge Project models. """ import logging from pathlib import Path from typing import List, Optional from griffe import ( GriffeLoader as _GriffeLoader, ModulesCollection, LinesCollection, Object, AliasResolutionError, ) from docforge.models import Module, Project, DocObject logger = logging.getLogger(__name__) def discover_module_paths( module_name: str, project_root: Path | None = None, ) -> List[str]: """ Discover all Python modules under a package via filesystem traversal. Rules: - Directory with __init__.py is treated as a package. - Any .py file is treated as a module. - All paths are converted to dotted module paths. Args: module_name: The name of the package to discover. project_root: The root directory of the project. Defaults to current working directory. Returns: A sorted list of dotted module paths. """ if project_root is None: project_root = Path.cwd() pkg_dir = project_root / module_name if not pkg_dir.exists(): raise FileNotFoundError(f"Package not found: {pkg_dir}") module_paths: List[str] = [] for path in pkg_dir.rglob("*.py"): if path.name == "__init__.py": module_path = path.parent else: module_path = path rel = module_path.relative_to(project_root) dotted = ".".join(rel.with_suffix("").parts) module_paths.append(dotted) return sorted(set(module_paths)) class GriffeLoader: """ Loads Python modules and extracts documentation using the Griffe introspection engine. """ def __init__(self) -> None: """ Initialize the GriffeLoader. """ self._loader = _GriffeLoader( modules_collection=ModulesCollection(), lines_collection=LinesCollection(), ) def load_project( self, module_paths: List[str], project_name: Optional[str] = None, skip_import_errors: bool = None, ) -> Project: """ Load multiple modules and combine them into a single Project models. Args: module_paths: A list of dotted paths to the modules to load. project_name: Optional name for the project. Defaults to the first module name. skip_import_errors: If True, modules that fail to import will be skipped. Returns: A Project instance containing the loaded modules. """ 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) except ImportError as import_error: if skip_import_errors: logger.debug("Could not load %s: %s", module_path, import_error) continue else: raise import_error project.add_module(module) return project def load_module(self, path: str) -> Module: """ Load a single module and convert its introspection data into the docforge models. Args: path: The dotted path of the module to load. Returns: A Module instance. """ self._loader.load(path) griffe_module = self._loader.modules_collection[path] return self._convert_module(griffe_module) # ------------------------- # Conversion helpers # ------------------------- def _convert_module(self, obj: Object) -> Module: """ Convert a Griffe Object (module) into a docforge Module. Args: obj: The Griffe Object representing the module. Returns: A populated Module instance. """ module = Module( path=obj.path, docstring=self._safe_docstring(obj), ) for name, member in obj.members.items(): if name.startswith("_"): continue module.add_object(self._convert_object(member)) return module def _convert_object(self, obj: Object) -> DocObject: """ Recursively convert a Griffe Object into a DocObject hierarchy. Args: obj: The Griffe Object to convert. Returns: A DocObject instance. """ kind = obj.kind.value signature = self._safe_signature(obj) doc_obj = DocObject( name=obj.name, kind=kind, path=obj.path, signature=signature, docstring=self._safe_docstring(obj), ) try: for name, member in obj.members.items(): if name.startswith("_"): continue doc_obj.add_member(self._convert_object(member)) except AliasResolutionError: pass return doc_obj # ------------------------- # Safe extractors # ------------------------- def _safe_docstring(self, obj: Object) -> Optional[str]: """ Safely retrieve the docstring value from a Griffe object. Args: obj: The Griffe Object to inspect. Returns: The raw docstring string, or None if missing or unresolvable. """ try: return obj.docstring.value if obj.docstring else None except AliasResolutionError: return None def _safe_signature(self, obj: Object) -> Optional[str]: """ Safely retrieve the signature string from a Griffe object. Args: obj: The Griffe Object to inspect. Returns: The string representation of the signature, or None. """ try: if hasattr(obj, "signature") and obj.signature: return str(obj.signature) except AliasResolutionError: return None return None