introspection
This commit is contained in:
31
.run/pytest.run.xml
Normal file
31
.run/pytest.run.xml
Normal file
@@ -0,0 +1,31 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="pytest" type="tests" factoryName="py.test">
|
||||
<module name="docforge" />
|
||||
<option name="ENV_FILES" value="" />
|
||||
<option name="INTERPRETER_OPTIONS" value="" />
|
||||
<option name="PARENT_ENVS" value="true" />
|
||||
<option name="SDK_HOME" value="" />
|
||||
<option name="SDK_NAME" value="Python 3.13 (doc-forge)" />
|
||||
<option name="WORKING_DIRECTORY" value="" />
|
||||
<option name="IS_MODULE_SDK" value="false" />
|
||||
<option name="ADD_CONTENT_ROOTS" value="true" />
|
||||
<option name="ADD_SOURCE_ROOTS" value="true" />
|
||||
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
|
||||
<EXTENSION ID="net.ashald.envfile">
|
||||
<option name="IS_ENABLED" value="false" />
|
||||
<option name="IS_SUBST" value="false" />
|
||||
<option name="IS_PATH_MACRO_SUPPORTED" value="false" />
|
||||
<option name="IS_IGNORE_MISSING_FILES" value="false" />
|
||||
<option name="IS_ENABLE_EXPERIMENTAL_INTEGRATIONS" value="false" />
|
||||
<ENTRIES>
|
||||
<ENTRY IS_ENABLED="true" PARSER="runconfig" IS_EXECUTABLE="false" />
|
||||
</ENTRIES>
|
||||
</EXTENSION>
|
||||
<option name="_new_keywords" value="""" />
|
||||
<option name="_new_parameters" value="""" />
|
||||
<option name="_new_additionalArguments" value=""--cov-report term-missing --cov-report html:.cov/htmlcov --cov-report xml:.cov/coverage.xml"" />
|
||||
<option name="_new_target" value=""C:\\Users\\vishe\\WorkSpace\\code\\aetos\\doc-forge\\tests"" />
|
||||
<option name="_new_targetType" value=""PATH"" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
15
docforge/__init__.py
Normal file
15
docforge/__init__.py
Normal file
@@ -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",
|
||||
]
|
||||
4
docforge/__init__.pyi
Normal file
4
docforge/__init__.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from .loader import GriffeLoader
|
||||
from . import model
|
||||
|
||||
__all__: list[str]
|
||||
3
docforge/loader/__init__.py
Normal file
3
docforge/loader/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .griffe_loader import GriffeLoader
|
||||
|
||||
__all__ = ["GriffeLoader"]
|
||||
3
docforge/loader/__init__.pyi
Normal file
3
docforge/loader/__init__.pyi
Normal file
@@ -0,0 +1,3 @@
|
||||
from .griffe_loader import GriffeLoader
|
||||
|
||||
__all__ = ["GriffeLoader"]
|
||||
119
docforge/loader/griffe_loader.py
Normal file
119
docforge/loader/griffe_loader.py
Normal file
@@ -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
|
||||
22
docforge/loader/griffe_loader.pyi
Normal file
22
docforge/loader/griffe_loader.pyi
Normal file
@@ -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."""
|
||||
16
docforge/model/__init__.py
Normal file
16
docforge/model/__init__.py
Normal file
@@ -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",
|
||||
]
|
||||
5
docforge/model/__init__.pyi
Normal file
5
docforge/model/__init__.pyi
Normal file
@@ -0,0 +1,5 @@
|
||||
from .project import Project
|
||||
from .module import Module
|
||||
from .object import DocObject
|
||||
|
||||
__all__ = ["Project", "Module", "DocObject"]
|
||||
27
docforge/model/module.py
Normal file
27
docforge/model/module.py
Normal file
@@ -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()
|
||||
23
docforge/model/module.pyi
Normal file
23
docforge/model/module.pyi
Normal file
@@ -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]: ...
|
||||
31
docforge/model/object.py
Normal file
31
docforge/model/object.py
Normal file
@@ -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()
|
||||
27
docforge/model/object.pyi
Normal file
27
docforge/model/object.pyi
Normal file
@@ -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"]: ...
|
||||
25
docforge/model/project.py
Normal file
25
docforge/model/project.py
Normal file
@@ -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())
|
||||
20
docforge/model/project.pyi
Normal file
20
docforge/model/project.pyi
Normal file
@@ -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]: ...
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
0
tests/introspection/__init__.py
Normal file
0
tests/introspection/__init__.py
Normal file
18
tests/introspection/conftest.py
Normal file
18
tests/introspection/conftest.py
Normal file
@@ -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))
|
||||
15
tests/introspection/test_alias_safety.py
Normal file
15
tests/introspection/test_alias_safety.py
Normal file
@@ -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
|
||||
26
tests/introspection/test_classes_and_methods.py
Normal file
26
tests/introspection/test_classes_and_methods.py
Normal file
@@ -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
|
||||
20
tests/introspection/test_functions_and_signatures.py
Normal file
20
tests/introspection/test_functions_and_signatures.py
Normal file
@@ -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
|
||||
12
tests/introspection/test_import_failures.py
Normal file
12
tests/introspection/test_import_failures.py
Normal file
@@ -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
|
||||
14
tests/introspection/test_missing_docstrings.py
Normal file
14
tests/introspection/test_missing_docstrings.py
Normal file
@@ -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
|
||||
12
tests/introspection/test_multiple_modules.py
Normal file
12
tests/introspection/test_multiple_modules.py
Normal file
@@ -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"}
|
||||
16
tests/introspection/test_private_members.py
Normal file
16
tests/introspection/test_private_members.py
Normal file
@@ -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
|
||||
21
tests/introspection/test_single_module.py
Normal file
21
tests/introspection/test_single_module.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user