import logging from pathlib import Path from typing import List, Optional from griffe import ( GriffeLoader as _GriffeLoader, ModulesCollection, LinesCollection, Object, AliasResolutionError, ) from docforge.model 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 => package - .py file => module - Paths converted to 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 using Griffe introspection.""" def __init__(self) -> None: self._loader = _GriffeLoader( modules_collection=ModulesCollection(), lines_collection=LinesCollection(), ) def load_project( self, module_paths: List[str], project_name: Optional[str] = None, ) -> Project: 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: module = self.load_module(module_path) project.add_module(module) return project def load_module(self, path: str) -> Module: 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: 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: 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]: try: return obj.docstring.value if obj.docstring else None except AliasResolutionError: return None def _safe_signature(self, obj: Object) -> Optional[str]: try: if hasattr(obj, "signature") and obj.signature: return str(obj.signature) except AliasResolutionError: return None return None