Skip to content

Commit 245b017

Browse files
authored
feat: add support for inheriting field descriptions from parent classes (#45)
* feat: add support for inheriting field descriptions from parent classes * fix for 3.10 * fix: handle ValueError in _backfill_inherited_descriptions and improve comments
1 parent c8a6f34 commit 245b017

3 files changed

Lines changed: 132 additions & 1 deletion

File tree

src/griffe_fieldz/_extension.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,17 @@ def on_class_members(
114114

115115
# ------------------------------
116116

117+
def on_class(self, *, cls: Class, **kwargs: Any) -> None:
118+
"""Fill in missing descriptions for inherited fields.
119+
120+
This runs after the full object tree is built, so griffe's
121+
inheritance APIs (mro, attributes, inherited_members) work here.
122+
"""
123+
if self.include_inherited and cls.docstring:
124+
_backfill_inherited_descriptions(cls)
125+
126+
# ------------------------------
127+
117128
def _inject_fields(self, griffe_obj: Object, runtime_obj: Any) -> None:
118129
# update the object instance with the evaluated docstring
119130
docstring = inspect.cleandoc(getattr(runtime_obj, "__doc__", "") or "")
@@ -136,6 +147,56 @@ def _inject_fields(self, griffe_obj: Object, runtime_obj: Any) -> None:
136147
)
137148

138149

150+
def _backfill_inherited_descriptions(cls: Class) -> None:
151+
"""Fill in missing field descriptions from parent classes.
152+
153+
Uses griffe's MRO to find descriptions from parent docstring sections
154+
and inherited attribute docstrings. Must be called after the full object
155+
tree is built (e.g. from on_class), so that mro() works.
156+
"""
157+
sections = cls.docstring.parsed # pyright: ignore[reportOptionalMemberAccess]
158+
159+
# Collect descriptions with empty strings that we need to fill
160+
empty_items: dict[str, DocstringParameter | DocstringAttribute] = {}
161+
params_or_attrs = (DocstringSectionParameters, DocstringSectionAttributes)
162+
for section in sections:
163+
if isinstance(section, params_or_attrs):
164+
for item in section.value:
165+
if not item.description:
166+
empty_items[item.name] = item
167+
168+
if not empty_items:
169+
return
170+
171+
# Walk parent classes looking for descriptions
172+
try:
173+
parents = cls.mro()
174+
except ValueError: # pragma: no cover
175+
return
176+
177+
for parent in parents:
178+
# Check parent's parsed docstring sections
179+
if parent.docstring:
180+
for section in parent.docstring.parsed:
181+
if isinstance(section, params_or_attrs):
182+
for item in section.value:
183+
if item.name in empty_items and item.description:
184+
empty_items[item.name].description = item.description
185+
del empty_items[item.name]
186+
# Check parent's direct member inline docstrings.
187+
# This is a fallback for parents not processed by this extension
188+
# (e.g. a base class from another package). possibly unreachable
189+
for name in list(empty_items): # pragma: no cover
190+
if name in parent.members:
191+
member = parent.members[name]
192+
if member.docstring:
193+
empty_items[name].description = member.docstring.value
194+
del empty_items[name]
195+
196+
if not empty_items:
197+
return
198+
199+
139200
def _to_annotation(
140201
type_: Any, docstring: Docstring, *, strip_annotated: bool = False
141202
) -> str | Expr | None:
@@ -284,7 +345,11 @@ def _merged_kwargs(
284345
strip_annotated: bool = False,
285346
) -> DocstringNamedElementKwargs:
286347
desc = field.description or field.metadata.get("description", "") or ""
287-
if not desc and (doc := getattr(field.default_factory, "__doc__", None)):
348+
if (
349+
not desc
350+
and field.default_factory is not field.MISSING
351+
and (doc := getattr(field.default_factory, "__doc__", None))
352+
):
288353
desc = inspect.cleandoc(doc) or ""
289354

290355
if not desc and field.name in griffe_obj.attributes:

tests/fake_module.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,27 @@ class Gt:
3737
"""Mock annotation."""
3838

3939

40+
@dataclass
41+
class ParentWithDocstringDesc:
42+
"""Parent with field descriptions in docstring.
43+
44+
Parameters:
45+
x: x described in parent docstring
46+
"""
47+
48+
x: int = 1
49+
y: int = field(default=2, metadata={"description": "y from metadata"})
50+
z: int = 3
51+
"""z inline docstring"""
52+
53+
54+
@dataclass
55+
class ChildInheritsDocstringDesc(ParentWithDocstringDesc):
56+
"""Child that inherits all fields."""
57+
58+
pass
59+
60+
4061
@dataclass
4162
class WithAnnotated:
4263
"""Class with Annotated fields."""

tests/test_griffe_fieldz.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,51 @@ def test_add_if_missing_merges() -> None:
7979
assert section.value[0].value == "val"
8080

8181

82+
def test_inherited_docstring_descriptions() -> None:
83+
"""Test that child classes inherit field descriptions from parent docstrings.
84+
85+
https://github.com/pyapp-kit/griffe-fieldz/issues/19
86+
"""
87+
loader = GriffeLoader(
88+
extensions=Extensions(FieldzExtension(include_inherited=True)),
89+
docstring_parser="google",
90+
)
91+
fake_mod = loader.load("tests.fake_module")
92+
93+
# First verify that the parent class itself has the descriptions
94+
parent_cls = cast("griffe.Class", fake_mod["ParentWithDocstringDesc"])
95+
parent_params = None
96+
for section in parent_cls.docstring.parsed:
97+
if isinstance(section, griffe.DocstringSectionParameters):
98+
parent_params = section
99+
break
100+
assert parent_params is not None
101+
parent_param_dict = {p.name: p for p in parent_params.value}
102+
assert parent_param_dict["x"].description == "x described in parent docstring"
103+
assert parent_param_dict["y"].description == "y from metadata"
104+
assert parent_param_dict["z"].description == "z inline docstring"
105+
106+
# Now check the child class - inherited fields should have descriptions too
107+
child_cls = cast("griffe.Class", fake_mod["ChildInheritsDocstringDesc"])
108+
child_params = None
109+
for section in child_cls.docstring.parsed:
110+
if isinstance(section, griffe.DocstringSectionParameters):
111+
child_params = section
112+
break
113+
assert child_params is not None
114+
child_param_dict = {p.name: p for p in child_params.value}
115+
116+
# y has description via field metadata - this works because fieldz has it
117+
assert child_param_dict["y"].description == "y from metadata"
118+
119+
# x has description only in parent's docstring Parameters section.
120+
# z has an inline docstring on the parent class attribute.
121+
# Both are lost when inheriting because _merged_kwargs doesn't look at
122+
# the parent class's parsed docstring or inherited attributes.
123+
assert child_param_dict["x"].description == "x described in parent docstring"
124+
assert child_param_dict["z"].description == "z inline docstring"
125+
126+
82127
def test_strip_annotated() -> None:
83128
"""Test that strip_annotated option works correctly."""
84129
# Test with strip_annotated=False (default)

0 commit comments

Comments
 (0)