From 102ea4e2159a6e1c0465cff8a66729bd6d4dba85 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Tue, 20 Jan 2026 20:24:22 +0530 Subject: [PATCH] introspection --- .run/pytest.run.xml | 31 +++++ docforge/__init__.py | 15 +++ docforge/__init__.pyi | 4 + docforge/loader/__init__.py | 3 + docforge/loader/__init__.pyi | 3 + docforge/loader/griffe_loader.py | 119 ++++++++++++++++++ docforge/loader/griffe_loader.pyi | 22 ++++ docforge/model/__init__.py | 16 +++ docforge/model/__init__.pyi | 5 + docforge/model/module.py | 27 ++++ docforge/model/module.pyi | 23 ++++ docforge/model/object.py | 31 +++++ docforge/model/object.pyi | 27 ++++ docforge/model/project.py | 25 ++++ docforge/model/project.pyi | 20 +++ tests/__init__.py | 0 tests/introspection/__init__.py | 0 tests/introspection/conftest.py | 18 +++ tests/introspection/test_alias_safety.py | 15 +++ .../introspection/test_classes_and_methods.py | 26 ++++ .../test_functions_and_signatures.py | 20 +++ tests/introspection/test_import_failures.py | 12 ++ .../introspection/test_missing_docstrings.py | 14 +++ tests/introspection/test_multiple_modules.py | 12 ++ tests/introspection/test_private_members.py | 16 +++ tests/introspection/test_single_module.py | 21 ++++ 26 files changed, 525 insertions(+) create mode 100644 .run/pytest.run.xml create mode 100644 docforge/__init__.py create mode 100644 docforge/__init__.pyi create mode 100644 docforge/loader/__init__.py create mode 100644 docforge/loader/__init__.pyi create mode 100644 docforge/loader/griffe_loader.py create mode 100644 docforge/loader/griffe_loader.pyi create mode 100644 docforge/model/__init__.py create mode 100644 docforge/model/__init__.pyi create mode 100644 docforge/model/module.py create mode 100644 docforge/model/module.pyi create mode 100644 docforge/model/object.py create mode 100644 docforge/model/object.pyi create mode 100644 docforge/model/project.py create mode 100644 docforge/model/project.pyi create mode 100644 tests/__init__.py create mode 100644 tests/introspection/__init__.py create mode 100644 tests/introspection/conftest.py create mode 100644 tests/introspection/test_alias_safety.py create mode 100644 tests/introspection/test_classes_and_methods.py create mode 100644 tests/introspection/test_functions_and_signatures.py create mode 100644 tests/introspection/test_import_failures.py create mode 100644 tests/introspection/test_missing_docstrings.py create mode 100644 tests/introspection/test_multiple_modules.py create mode 100644 tests/introspection/test_private_members.py create mode 100644 tests/introspection/test_single_module.py diff --git a/.run/pytest.run.xml b/.run/pytest.run.xml new file mode 100644 index 0000000..b923b9b --- /dev/null +++ b/.run/pytest.run.xml @@ -0,0 +1,31 @@ + + + + + \ No newline at end of file diff --git a/docforge/__init__.py b/docforge/__init__.py new file mode 100644 index 0000000..235b37b --- /dev/null +++ b/docforge/__init__.py @@ -0,0 +1,15 @@ +""" +doc-forge — renderer-agnostic Python documentation compiler. + +At this stage, doc-forge publicly exposes only the Introspection Layer. +All the rendering, exporting, and serving APIs are intentionally private +until their contracts are finalized. +""" + +from .loader import GriffeLoader +from . import model + +__all__ = [ + "GriffeLoader", + "model", +] diff --git a/docforge/__init__.pyi b/docforge/__init__.pyi new file mode 100644 index 0000000..b7949cb --- /dev/null +++ b/docforge/__init__.pyi @@ -0,0 +1,4 @@ +from .loader import GriffeLoader +from . import model + +__all__: list[str] diff --git a/docforge/loader/__init__.py b/docforge/loader/__init__.py new file mode 100644 index 0000000..ac92d71 --- /dev/null +++ b/docforge/loader/__init__.py @@ -0,0 +1,3 @@ +from .griffe_loader import GriffeLoader + +__all__ = ["GriffeLoader"] diff --git a/docforge/loader/__init__.pyi b/docforge/loader/__init__.pyi new file mode 100644 index 0000000..ac92d71 --- /dev/null +++ b/docforge/loader/__init__.pyi @@ -0,0 +1,3 @@ +from .griffe_loader import GriffeLoader + +__all__ = ["GriffeLoader"] diff --git a/docforge/loader/griffe_loader.py b/docforge/loader/griffe_loader.py new file mode 100644 index 0000000..04d0095 --- /dev/null +++ b/docforge/loader/griffe_loader.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import logging +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__) + + +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: + try: + module = self.load_module(module_path) + project.add_module(module) + except Exception as e: + logger.error("Failed to load module %s: %s", module_path, e) + continue + + return project + + def load_module(self, path: str) -> Module: + try: + self._loader.load(path) + griffe_module = self._loader.modules_collection[path] + except Exception as e: + raise ImportError(f"Failed to load module '{path}': {e}") from e + + 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 + try: + module.add_object(self._convert_object(member)) + except Exception as e: + logger.warning("Skipping member %s: %s", name, e) + + 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), + ) + + if hasattr(obj, "members"): + for name, member in obj.members.items(): + if name.startswith("_"): + continue + try: + doc_obj.add_member(self._convert_object(member)) + except Exception: + continue + + 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 diff --git a/docforge/loader/griffe_loader.pyi b/docforge/loader/griffe_loader.pyi new file mode 100644 index 0000000..ebac3e4 --- /dev/null +++ b/docforge/loader/griffe_loader.pyi @@ -0,0 +1,22 @@ +from typing import List, Optional + +from docforge.model import Module, Project + + +class GriffeLoader: + """Griffe-based introspection loader. + + This is the only supported introspection backend in doc-forge. + """ + + def __init__(self) -> None: ... + + def load_project( + self, + module_paths: List[str], + project_name: Optional[str] = ..., + ) -> Project: + """Load a documentation project from Python modules.""" + + def load_module(self, path: str) -> Module: + """Load a single Python module by import path.""" diff --git a/docforge/model/__init__.py b/docforge/model/__init__.py new file mode 100644 index 0000000..aa81bf9 --- /dev/null +++ b/docforge/model/__init__.py @@ -0,0 +1,16 @@ +""" +Core documentation model for doc-forge. + +These classes form the renderer-agnostic, introspection-derived +representation of Python documentation. +""" + +from .project import Project +from .module import Module +from .object import DocObject + +__all__ = [ + "Project", + "Module", + "DocObject", +] diff --git a/docforge/model/__init__.pyi b/docforge/model/__init__.pyi new file mode 100644 index 0000000..00a322e --- /dev/null +++ b/docforge/model/__init__.pyi @@ -0,0 +1,5 @@ +from .project import Project +from .module import Module +from .object import DocObject + +__all__ = ["Project", "Module", "DocObject"] diff --git a/docforge/model/module.py b/docforge/model/module.py new file mode 100644 index 0000000..5e36ce4 --- /dev/null +++ b/docforge/model/module.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from typing import Dict, Iterable, Optional + +from docforge.model.object import DocObject + + +class Module: + """Represents a documented Python module.""" + + def __init__( + self, + path: str, + docstring: Optional[str] = None, + ) -> None: + self.path = path + self.docstring = docstring + self.members: Dict[str, DocObject] = {} + + def add_object(self, obj: DocObject) -> None: + self.members[obj.name] = obj + + def get_object(self, name: str) -> DocObject: + return self.members[name] + + def get_all_objects(self) -> Iterable[DocObject]: + return self.members.values() diff --git a/docforge/model/module.pyi b/docforge/model/module.pyi new file mode 100644 index 0000000..580796c --- /dev/null +++ b/docforge/model/module.pyi @@ -0,0 +1,23 @@ +from typing import Dict, Iterable, Optional + +from docforge.model.object import DocObject + + +class Module: + """Represents a documented Python module.""" + + path: str + docstring: Optional[str] + members: Dict[str, DocObject] + + def __init__( + self, + path: str, + docstring: Optional[str] = ..., + ) -> None: ... + + def add_object(self, obj: DocObject) -> None: ... + + def get_object(self, name: str) -> DocObject: ... + + def get_all_objects(self) -> Iterable[DocObject]: ... diff --git a/docforge/model/object.py b/docforge/model/object.py new file mode 100644 index 0000000..32eba5a --- /dev/null +++ b/docforge/model/object.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from typing import Dict, Iterable, Optional + + +class DocObject: + """Represents a documented Python object.""" + + def __init__( + self, + name: str, + kind: str, + path: str, + signature: Optional[str] = None, + docstring: Optional[str] = None, + ) -> None: + self.name = name + self.kind = kind + self.path = path + self.signature = signature + self.docstring = docstring + self.members: Dict[str, DocObject] = {} + + def add_member(self, obj: DocObject) -> None: + self.members[obj.name] = obj + + def get_member(self, name: str) -> DocObject: + return self.members[name] + + def get_all_members(self) -> Iterable[DocObject]: + return self.members.values() diff --git a/docforge/model/object.pyi b/docforge/model/object.pyi new file mode 100644 index 0000000..b7f589a --- /dev/null +++ b/docforge/model/object.pyi @@ -0,0 +1,27 @@ +from typing import Dict, Iterable, Optional + + +class DocObject: + """Represents a documented Python object.""" + + 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] = ..., + docstring: Optional[str] = ..., + ) -> None: ... + + def add_member(self, obj: "DocObject") -> None: ... + + def get_member(self, name: str) -> "DocObject": ... + + def get_all_members(self) -> Iterable["DocObject"]: ... diff --git a/docforge/model/project.py b/docforge/model/project.py new file mode 100644 index 0000000..d124bc1 --- /dev/null +++ b/docforge/model/project.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from typing import Dict, Iterable + +from docforge.model.module import Module + + +class Project: + """Represents a documentation project.""" + + def __init__(self, name: str) -> None: + self.name = name + self.modules: Dict[str, Module] = {} + + def add_module(self, module: Module) -> None: + self.modules[module.path] = module + + def get_module(self, path: str) -> Module: + return self.modules[path] + + def get_all_modules(self) -> Iterable[Module]: + return self.modules.values() + + def get_module_list(self) -> list[str]: + return list(self.modules.keys()) diff --git a/docforge/model/project.pyi b/docforge/model/project.pyi new file mode 100644 index 0000000..f7d1062 --- /dev/null +++ b/docforge/model/project.pyi @@ -0,0 +1,20 @@ +from typing import Dict, Iterable + +from docforge.model.module import Module + + +class Project: + """Represents a documentation project.""" + + name: str + modules: Dict[str, Module] + + def __init__(self, name: str) -> None: ... + + def add_module(self, module: Module) -> None: ... + + def get_module(self, path: str) -> Module: ... + + def get_all_modules(self) -> Iterable[Module]: ... + + def get_module_list(self) -> list[str]: ... diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/introspection/__init__.py b/tests/introspection/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/introspection/conftest.py b/tests/introspection/conftest.py new file mode 100644 index 0000000..ab2c7fb --- /dev/null +++ b/tests/introspection/conftest.py @@ -0,0 +1,18 @@ +import sys +from pathlib import Path + +import pytest + + +@pytest.fixture +def temp_package(tmp_path: Path): + """ + Creates a temporary Python package and adds it to sys.path. + """ + pkg = tmp_path / "testpkg" + pkg.mkdir() + (pkg / "__init__.py").write_text('"""Test package."""\n') + + sys.path.insert(0, str(tmp_path)) + yield pkg + sys.path.remove(str(tmp_path)) diff --git a/tests/introspection/test_alias_safety.py b/tests/introspection/test_alias_safety.py new file mode 100644 index 0000000..599a663 --- /dev/null +++ b/tests/introspection/test_alias_safety.py @@ -0,0 +1,15 @@ +from docforge import GriffeLoader + + +def test_alias_does_not_crash(temp_package): + (temp_package / "alias.py").write_text( + '''from typing import List +Alias = List[int] +''' + ) + + loader = GriffeLoader() + project = loader.load_project(["testpkg.alias"]) + + module = project.get_module("testpkg.alias") + assert "Alias" in module.members diff --git a/tests/introspection/test_classes_and_methods.py b/tests/introspection/test_classes_and_methods.py new file mode 100644 index 0000000..759652c --- /dev/null +++ b/tests/introspection/test_classes_and_methods.py @@ -0,0 +1,26 @@ +from docforge import GriffeLoader + + +def test_class_and_methods(temp_package): + (temp_package / "cls.py").write_text( + '''class MyClass: + """Class doc.""" + + def method(self, x: int) -> int: + """Method doc.""" + return x +''' + ) + + loader = GriffeLoader() + project = loader.load_project(["testpkg.cls"]) + module = project.get_module("testpkg.cls") + + cls = module.get_object("MyClass") + assert cls.kind == "class" + assert cls.docstring == "Class doc." + assert "method" in cls.members + + method = cls.get_member("method") + assert method.kind in {"method", "function"} + assert method.signature is not None diff --git a/tests/introspection/test_functions_and_signatures.py b/tests/introspection/test_functions_and_signatures.py new file mode 100644 index 0000000..221ecb1 --- /dev/null +++ b/tests/introspection/test_functions_and_signatures.py @@ -0,0 +1,20 @@ +from docforge import GriffeLoader + + +def test_function_signature(temp_package): + (temp_package / "fn.py").write_text( + '''def add(a: int, b: int = 1) -> int: + """Adds numbers.""" + return a + b +''' + ) + + loader = GriffeLoader() + project = loader.load_project(["testpkg.fn"]) + module = project.get_module("testpkg.fn") + + fn = module.get_object("add") + assert fn.kind == "function" + assert fn.signature is not None + assert "a" in fn.signature + assert "b" in fn.signature diff --git a/tests/introspection/test_import_failures.py b/tests/introspection/test_import_failures.py new file mode 100644 index 0000000..451ed55 --- /dev/null +++ b/tests/introspection/test_import_failures.py @@ -0,0 +1,12 @@ +from docforge import GriffeLoader + + +def test_import_failure_does_not_crash(): + loader = GriffeLoader() + + project = loader.load_project( + ["nonexistent.module", "sys"] + ) + + # sys should still load + assert "sys" in project.modules diff --git a/tests/introspection/test_missing_docstrings.py b/tests/introspection/test_missing_docstrings.py new file mode 100644 index 0000000..05e8499 --- /dev/null +++ b/tests/introspection/test_missing_docstrings.py @@ -0,0 +1,14 @@ +from docforge import GriffeLoader + + +def test_missing_docstrings(temp_package): + (temp_package / "nodoc.py").write_text( + '''def f(): pass''' + ) + + loader = GriffeLoader() + project = loader.load_project(["testpkg.nodoc"]) + module = project.get_module("testpkg.nodoc") + + fn = module.get_object("f") + assert fn.docstring is None diff --git a/tests/introspection/test_multiple_modules.py b/tests/introspection/test_multiple_modules.py new file mode 100644 index 0000000..9e23ec0 --- /dev/null +++ b/tests/introspection/test_multiple_modules.py @@ -0,0 +1,12 @@ +from docforge import GriffeLoader + + +def test_load_multiple_modules(temp_package): + (temp_package / "a.py").write_text('"""A."""\n') + (temp_package / "b.py").write_text('"""B."""\n') + + loader = GriffeLoader() + project = loader.load_project(["testpkg.a", "testpkg.b"]) + + assert len(project.modules) == 2 + assert set(project.get_module_list()) == {"testpkg.a", "testpkg.b"} diff --git a/tests/introspection/test_private_members.py b/tests/introspection/test_private_members.py new file mode 100644 index 0000000..a7aae6d --- /dev/null +++ b/tests/introspection/test_private_members.py @@ -0,0 +1,16 @@ +from docforge import GriffeLoader + + +def test_private_members_excluded(temp_package): + (temp_package / "priv.py").write_text( + '''def _hidden(): pass +def visible(): pass +''' + ) + + loader = GriffeLoader() + project = loader.load_project(["testpkg.priv"]) + module = project.get_module("testpkg.priv") + + assert "visible" in module.members + assert "_hidden" not in module.members diff --git a/tests/introspection/test_single_module.py b/tests/introspection/test_single_module.py new file mode 100644 index 0000000..3358f85 --- /dev/null +++ b/tests/introspection/test_single_module.py @@ -0,0 +1,21 @@ +from docforge import GriffeLoader + + +def test_load_single_module(temp_package): + (temp_package / "mod.py").write_text( + '''"""Module docstring."""\n +def foo(): + """Foo docstring.""" + pass +''' + ) + + loader = GriffeLoader() + project = loader.load_project(["testpkg.mod"]) + + assert project.name == "testpkg" + assert "testpkg.mod" in project.modules + + module = project.get_module("testpkg.mod") + assert module.docstring == "Module docstring." + assert "foo" in module.members