Skip to content

Commit 232fbb0

Browse files
feat: Add merge option to merge docstrings downwards
Issue-2: #2 PR-3: #3 Co-authored-by: Timothée Mazzucotelli <dev@pawamoy.fr>
1 parent 48370d6 commit 232fbb0

File tree

3 files changed

+161
-16
lines changed

3 files changed

+161
-16
lines changed

README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,52 @@ plugins:
3737
3838
The extension will iterate on every class and their members
3939
to set docstrings from parent classes when they are not already defined.
40+
41+
The extension accepts a `merge` option, that when set to true
42+
will actually merge all parent docstrings in the class hierarchy
43+
to the child docstring, if any.
44+
45+
```yaml
46+
plugins:
47+
- mkdocstrings:
48+
handlers:
49+
python:
50+
options:
51+
extensions:
52+
- griffe_inherited_docstrings:
53+
merge: true
54+
```
55+
56+
```python
57+
class A:
58+
def method(self):
59+
"""Method in A."""
60+
61+
class B(A):
62+
def method(self):
63+
...
64+
65+
class C(B):
66+
...
67+
68+
class D(C):
69+
def method(self):
70+
"""Method in D."""
71+
72+
class E(D):
73+
def method(self):
74+
"""Method in E."""
75+
```
76+
77+
With the code above, docstrings will be merged like following:
78+
79+
Class | Method docstring
80+
----- | ----------------
81+
`A` | Method in A.
82+
`B` | Method in A.
83+
`C` | Method in A.
84+
`D` | Method in A.<br><br>Method in D.
85+
`E` | Method in A.<br><br>Method in D.<br><br>Method in E.
86+
87+
WARNING: **Limitation**
88+
This extension runs once on whole packages. There is no way to toggle merging or simple inheritance for specifc objects.

src/griffe_inherited_docstrings/extension.py

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,40 +5,74 @@
55
import contextlib
66
from typing import TYPE_CHECKING, Any
77

8-
from griffe import AliasResolutionError, Extension
8+
from griffe import AliasResolutionError, Docstring, Extension
99

1010
if TYPE_CHECKING:
11-
from griffe import Docstring, Module, Object
11+
from griffe import Module, Object
1212

1313

14-
def _inherited_docstring(obj: Object) -> Docstring | None:
15-
for parent_class in obj.parent.mro(): # type: ignore[union-attr]
16-
try:
17-
if docstring := parent_class.members[obj.name].docstring:
18-
return docstring
19-
except KeyError:
20-
pass
14+
def _docstring_above(obj: Object) -> Docstring | None:
15+
with contextlib.suppress(IndexError, KeyError):
16+
parent = obj.parent.mro()[0] # type: ignore[union-attr]
17+
return parent.members[obj.name].docstring
2118
return None
2219

2320

24-
def _inherit_docstrings(obj: Object) -> None:
21+
def _inherit_docstrings(obj: Object, *, merge: bool = False, seen: set[str] | None = None) -> None:
22+
if seen is None:
23+
seen = set()
24+
25+
if obj.path in seen:
26+
return
27+
28+
seen.add(obj.path)
29+
2530
if obj.is_module:
2631
for member in obj.members.values():
2732
if not member.is_alias:
2833
with contextlib.suppress(AliasResolutionError):
29-
_inherit_docstrings(member) # type: ignore[arg-type]
34+
_inherit_docstrings(member, merge=merge, seen=seen) # type: ignore[arg-type]
35+
3036
elif obj.is_class:
37+
# Recursively handle top-most parents first.
38+
# It means that we can just check the first parent
39+
# when actually inheriting (and optionally merging) a docstring,
40+
# since the docstrings of the other parents have already been inherited.
41+
for parent in reversed(obj.mro()): # type: ignore[attr-defined]
42+
_inherit_docstrings(parent, merge=merge, seen=seen)
43+
3144
for member in obj.members.values():
3245
if not member.is_alias:
33-
if member.docstring is None and (inherited := _inherited_docstring(member)): # type: ignore[arg-type]
34-
member.docstring = inherited
46+
if docstring_above := _docstring_above(member): # type: ignore[arg-type]
47+
if merge:
48+
if member.docstring is None:
49+
member.docstring = Docstring(
50+
docstring_above.value,
51+
parent=member, # type: ignore[arg-type]
52+
parser=docstring_above.parser,
53+
parser_options=docstring_above.parser_options,
54+
)
55+
elif member.docstring.value:
56+
member.docstring.value = docstring_above.value + "\n\n" + member.docstring.value
57+
else:
58+
member.docstring.value = docstring_above.value
59+
elif member.docstring is None:
60+
member.docstring = docstring_above
3561
if member.is_class:
36-
_inherit_docstrings(member) # type: ignore[arg-type]
62+
_inherit_docstrings(member, merge=merge, seen=seen) # type: ignore[arg-type]
3763

3864

3965
class InheritDocstringsExtension(Extension):
4066
"""Griffe extension for inheriting docstrings."""
4167

68+
def __init__(self, *, merge: bool = False) -> None:
69+
"""Initialize the extension by setting the merge flag.
70+
71+
Parameters:
72+
merge: Whether to merge the docstrings from the parent classes into the docstring of the member.
73+
"""
74+
self.merge = merge
75+
4276
def on_package_loaded(self, *, pkg: Module, **kwargs: Any) -> None: # noqa: ARG002
4377
"""Inherit docstrings from parent classes once the whole package is loaded."""
44-
_inherit_docstrings(pkg)
78+
_inherit_docstrings(pkg, merge=self.merge, seen=set())

tests/test_extension.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ def test_inherit_docstrings() -> None:
1616
class Parent:
1717
def method(self):
1818
'''Docstring from parent method.'''
19-
2019
class Child(Parent):
2120
def method(self):
2221
...
@@ -25,3 +24,66 @@ def method(self):
2524
extensions=Extensions(InheritDocstringsExtension()),
2625
) as package:
2726
assert package["Child.method"].docstring.value == package["Parent.method"].docstring.value
27+
28+
29+
def test_inherit_and_merge_docstrings() -> None:
30+
"""Inherit and merge docstrings from parent classes."""
31+
attr_doc = "Attribute docstring from class"
32+
meth_doc = "Method docstring from class"
33+
code = f"""
34+
# Base docstrings.
35+
class A:
36+
attr = 42
37+
'''{attr_doc} A.'''
38+
39+
def meth(self):
40+
'''{meth_doc} A.'''
41+
42+
43+
# Redeclare members but without docstrings.
44+
class B(A):
45+
attr = 42
46+
47+
def meth(self):
48+
...
49+
50+
51+
# Redeclare members but with empty docstrings.
52+
class C(B):
53+
attr = 42
54+
''''''
55+
56+
def meth(self):
57+
''''''
58+
59+
60+
# Redeclare members with docstrings.
61+
class D(C):
62+
attr = 42
63+
'''{attr_doc} D.'''
64+
65+
def meth(self):
66+
'''{meth_doc} D.'''
67+
68+
69+
# Redeclare members with docstrings again.
70+
class E(D):
71+
attr = 42
72+
'''{attr_doc} E.'''
73+
74+
def meth(self):
75+
'''{meth_doc} E.'''
76+
"""
77+
with temporary_visited_package(
78+
"package",
79+
modules={"__init__.py": code},
80+
extensions=Extensions(InheritDocstringsExtension(merge=True)),
81+
) as package:
82+
assert package["B.attr"].docstring.value == package["A.attr"].docstring.value
83+
assert package["B.meth"].docstring.value == package["A.meth"].docstring.value
84+
assert package["C.attr"].docstring.value == package["A.attr"].docstring.value
85+
assert package["C.meth"].docstring.value == package["A.meth"].docstring.value
86+
assert package["D.attr"].docstring.value == package["A.attr"].docstring.value + "\n\n" + f"{attr_doc} D."
87+
assert package["D.meth"].docstring.value == package["A.meth"].docstring.value + "\n\n" + f"{meth_doc} D."
88+
assert package["E.attr"].docstring.value == package["D.attr"].docstring.value + "\n\n" + f"{attr_doc} E."
89+
assert package["E.meth"].docstring.value == package["D.meth"].docstring.value + "\n\n" + f"{meth_doc} E."

0 commit comments

Comments
 (0)