Skip to content

Commit 155a7f2

Browse files
authored
Unify/control placement of members, better support attrs (#21)
* fix: changes for baybe * typing * pragmas * add tests * update readme
1 parent 2cdb0c1 commit 155a7f2

File tree

4 files changed

+250
-86
lines changed

4 files changed

+250
-86
lines changed

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,19 @@ You may use any of the following options, provided as a dictionary under the
4747
|---------------------|--------------------------------------------------|---------|
4848
| `include_inherited` | Include inherited fields in class parameters. | `False` |
4949
| `include_private` | Include private fields in the documentation. | `False` |
50+
| `add_fields_to` | Where in the documentation to add the detected fields. Must be one of:<br><br>- `docstring-parameters`: add fields to the *Parameters* section of the docstring<br>- `docstring-attributes`: add fields to the *Attributes* section of the docstring<br>- `class-attributes`: add fields as class attributes | `docstring-parameters` |
51+
| `remove_fields_from_members` | If `True`, fields are *removed* as class members. This is not encouraged (since fields are *indeed* class attributes), but will prevent duplication of the name in the docstring as well as the class. This value is ignored if `add_fields_to` is `class-attributes`. | `False` |
5052

5153
For example:
5254

5355
```yml
5456
options:
5557
extensions:
56-
- griffe_fieldz: {include_inherited: true}
58+
- griffe_fieldz:
59+
include_inherited: false
60+
include_private: false
61+
add_fields_to: docstring-attributes
62+
remove_fields_from_members: false
5763
```
5864

5965
## Example

src/griffe_fieldz/_extension.py

Lines changed: 162 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,17 @@
55
import inspect
66
import textwrap
77
from contextlib import suppress
8-
from typing import TYPE_CHECKING, Any, Iterable, Sequence
8+
from typing import TYPE_CHECKING, Any, Iterable, Literal, TypedDict, TypeVar, cast
99

1010
import fieldz
1111
from fieldz._repr import display_as_type
1212
from griffe import (
13+
Attribute,
1314
Class,
1415
Docstring,
1516
DocstringAttribute,
1617
DocstringParameter,
18+
DocstringSection,
1719
DocstringSectionAttributes,
1820
DocstringSectionParameters,
1921
Extension,
@@ -29,7 +31,11 @@
2931

3032
from griffe import Expr, Inspector, Visitor
3133

32-
logger = get_logger(__name__)
34+
AddFieldsTo = Literal[
35+
"docstring-parameters", "docstring-attributes", "class-attributes"
36+
]
37+
38+
logger = get_logger("griffe-fieldz")
3339

3440

3541
class FieldzExtension(Extension):
@@ -40,14 +46,35 @@ def __init__(
4046
object_paths: list[str] | None = None,
4147
include_private: bool = False,
4248
include_inherited: bool = False,
49+
add_fields_to: AddFieldsTo = "docstring-parameters",
50+
remove_fields_from_members: bool = False,
4351
**kwargs: Any,
4452
) -> None:
4553
self.object_paths = object_paths
4654
self._kwargs = kwargs
55+
if kwargs:
56+
logger.warning(
57+
"Unknown kwargs passed to FieldzExtension: %s", ", ".join(kwargs)
58+
)
4759
self.include_private = include_private
4860
self.include_inherited = include_inherited
4961

50-
def on_class_instance(
62+
self.remove_fields_from_members = remove_fields_from_members
63+
if add_fields_to not in (
64+
"docstring-parameters",
65+
"docstring-attributes",
66+
"class-attributes",
67+
): # pragma: no cover
68+
logger.error(
69+
"'add_fields_to' must be one of {'docstring-parameters', "
70+
f"'docstring-attributes', or 'class-attributes'}}, not {add_fields_to}."
71+
"\n\nDefaulting to 'docstring-parameters'."
72+
)
73+
add_fields_to = "docstring-parameters"
74+
75+
self.add_fields_to: AddFieldsTo = add_fields_to
76+
77+
def on_class_members(
5178
self,
5279
*,
5380
node: ast.AST | ObjectNode,
@@ -70,42 +97,31 @@ def on_class_instance(
7097

7198
try:
7299
fieldz.get_adapter(runtime_obj)
73-
except TypeError:
100+
except TypeError: # pragma: no cover
74101
return
75102
self._inject_fields(cls, runtime_obj)
76103

77104
# ------------------------------
78105

79-
def _inject_fields(self, obj: Object, runtime_obj: Any) -> None:
106+
def _inject_fields(self, griffe_obj: Object, runtime_obj: Any) -> None:
80107
# update the object instance with the evaluated docstring
81108
docstring = inspect.cleandoc(getattr(runtime_obj, "__doc__", "") or "")
82-
if not obj.docstring:
83-
obj.docstring = Docstring(docstring, parent=obj)
84-
sections = obj.docstring.parsed
109+
if not griffe_obj.docstring:
110+
griffe_obj.docstring = Docstring(docstring, parent=griffe_obj)
85111

86112
# collect field info
87113
fields = fieldz.fields(runtime_obj)
88114
if not self.include_inherited:
89115
annotations = getattr(runtime_obj, "__annotations__", {})
90116
fields = tuple(f for f in fields if f.name in annotations)
91117

92-
params, attrs = _fields_to_params(fields, obj.docstring, self.include_private)
93-
94-
# merge/add field info to docstring
95-
if params:
96-
for x in sections:
97-
if isinstance(x, DocstringSectionParameters):
98-
_merge(x, params)
99-
break
100-
else:
101-
sections.insert(1, DocstringSectionParameters(params))
102-
if attrs:
103-
for x in sections:
104-
if isinstance(x, DocstringSectionAttributes):
105-
_merge(x, params)
106-
break
107-
else:
108-
sections.append(DocstringSectionAttributes(attrs))
118+
_unify_fields(
119+
fields,
120+
griffe_obj,
121+
include_private=self.include_private,
122+
add_fields_to=self.add_fields_to,
123+
remove_fields_from_members=self.remove_fields_from_members,
124+
)
109125

110126

111127
def _to_annotation(type_: Any, docstring: Docstring) -> str | Expr | None:
@@ -119,63 +135,136 @@ def _to_annotation(type_: Any, docstring: Docstring) -> str | Expr | None:
119135

120136
def _default_repr(field: fieldz.Field) -> str | None:
121137
"""Return a repr for a field default."""
122-
if field.default is not field.MISSING:
123-
return repr(field.default)
124-
if (factory := field.default_factory) is not field.MISSING:
125-
if len(inspect.signature(factory).parameters) == 0:
126-
with suppress(Exception):
127-
return repr(factory()) # type: ignore[call-arg]
128-
return "<dynamic>"
138+
try:
139+
if field.default is not field.MISSING:
140+
return repr(field.default)
141+
if (factory := field.default_factory) is not field.MISSING:
142+
try:
143+
sig = inspect.signature(factory)
144+
except ValueError:
145+
return repr(factory)
146+
else:
147+
if len(sig.parameters) == 0:
148+
with suppress(Exception):
149+
return repr(factory()) # type: ignore[call-arg]
150+
151+
return "<dynamic>"
152+
except Exception as exc: # pragma: no cover
153+
logger.warning("Failed to get default repr for %s: %s", field.name, exc)
154+
pass
129155
return None
130156

131157

132-
def _fields_to_params(
158+
class DocstringNamedElementKwargs(TypedDict):
159+
"""Docstring named element kwargs."""
160+
161+
name: str
162+
description: str
163+
annotation: str | Expr | None
164+
value: str | None
165+
166+
167+
def _unify_fields(
133168
fields: Iterable[fieldz.Field],
134-
docstring: Docstring,
135-
include_private: bool = False,
136-
) -> tuple[list[DocstringParameter], list[DocstringAttribute]]:
137-
"""Get all docstring attributes and parameters for fields."""
138-
params: list[DocstringParameter] = []
139-
attrs: list[DocstringAttribute] = []
169+
griffe_obj: Object,
170+
include_private: bool,
171+
add_fields_to: AddFieldsTo,
172+
remove_fields_from_members: bool,
173+
) -> None:
174+
docstring = cast("Docstring", griffe_obj.docstring)
175+
sections = docstring.parsed
176+
140177
for field in fields:
178+
if not include_private and field.name.startswith("_"):
179+
continue
180+
141181
try:
142-
desc = field.description or field.metadata.get("description", "") or ""
143-
if not desc and (doc := getattr(field.default_factory, "__doc__", None)):
144-
desc = inspect.cleandoc(doc) or ""
145-
146-
kwargs: dict = {
147-
"name": field.name,
148-
"annotation": _to_annotation(field.type, docstring),
149-
"description": textwrap.dedent(desc).strip(),
150-
"value": _default_repr(field),
151-
}
152-
if field.init:
153-
params.append(DocstringParameter(**kwargs))
154-
elif include_private or not field.name.startswith("_"):
155-
attrs.append(DocstringAttribute(**kwargs))
182+
item_kwargs = _merged_kwargs(field, docstring, griffe_obj)
183+
184+
if add_fields_to == "class-attributes":
185+
if field.name not in griffe_obj.attributes:
186+
griffe_obj.members[field.name] = Attribute(
187+
name=item_kwargs["name"],
188+
value=item_kwargs["value"],
189+
annotation=item_kwargs["annotation"],
190+
docstring=item_kwargs["description"],
191+
)
192+
elif add_fields_to == "docstring-attributes" or (not field.init):
193+
_add_if_missing(sections, DocstringAttribute(**item_kwargs))
194+
# remove from parameters if it exists
195+
if p_sect := _get_section(sections, DocstringSectionParameters):
196+
p_sect.value = [x for x in p_sect.value if x.name != field.name]
197+
if remove_fields_from_members:
198+
# remove from griffe_obj.parameters
199+
griffe_obj.members.pop(field.name, None)
200+
griffe_obj.inherited_members.pop(field.name, None)
201+
elif add_fields_to == "docstring-parameters":
202+
_add_if_missing(sections, DocstringParameter(**item_kwargs))
203+
# remove from attributes if it exists
204+
if a_sect := _get_section(sections, DocstringSectionAttributes):
205+
a_sect.value = [x for x in a_sect.value if x.name != field.name]
206+
if remove_fields_from_members:
207+
# remove from griffe_obj.attributes
208+
griffe_obj.members.pop(field.name, None)
209+
griffe_obj.inherited_members.pop(field.name, None)
210+
156211
except Exception as exc:
157212
logger.warning("Failed to parse field %s: %s", field.name, exc)
158213

159-
return params, attrs
214+
215+
def _merged_kwargs(
216+
field: fieldz.Field, docstring: Docstring, griffe_obj: Object
217+
) -> DocstringNamedElementKwargs:
218+
desc = field.description or field.metadata.get("description", "") or ""
219+
if not desc and (doc := getattr(field.default_factory, "__doc__", None)):
220+
desc = inspect.cleandoc(doc) or ""
221+
222+
if not desc and field.name in griffe_obj.attributes:
223+
griffe_attr = griffe_obj.attributes[field.name]
224+
if griffe_attr.docstring:
225+
desc = griffe_attr.docstring.value
226+
227+
return DocstringNamedElementKwargs(
228+
name=field.name,
229+
description=textwrap.dedent(desc).strip(),
230+
annotation=_to_annotation(field.type, docstring),
231+
value=_default_repr(field),
232+
)
160233

161234

162-
def _merge(
163-
existing_section: DocstringSectionParameters | DocstringSectionAttributes,
164-
field_params: Sequence[DocstringParameter],
235+
T = TypeVar("T", bound="DocstringSectionParameters | DocstringSectionAttributes")
236+
237+
238+
def _get_section(sections: list[DocstringSection], cls: type[T]) -> T | None:
239+
for section in sections:
240+
if isinstance(section, cls):
241+
return section
242+
return None
243+
244+
245+
def _add_if_missing(
246+
sections: list[DocstringSection], item: DocstringParameter | DocstringAttribute
165247
) -> None:
166-
"""Update DocstringSection with field params (if missing)."""
167-
existing_members = {x.name: x for x in existing_section.value}
168-
169-
for param in field_params:
170-
if existing := existing_members.get(param.name):
171-
# if the field already exists ...
172-
# extend missing attributes with the values from the fieldz params
173-
if existing.value is None and param.value is not None:
174-
existing.value = param.value
175-
if existing.description is None and param.description:
176-
existing.description = param.description
177-
if existing.annotation is None and param.annotation is not None:
178-
existing.annotation = param.annotation
179-
else:
180-
# otherwise, add the missing fields
181-
existing_section.value.append(param) # type: ignore
248+
section: DocstringSectionParameters | DocstringSectionAttributes | None
249+
if isinstance(item, DocstringParameter):
250+
if not (section := _get_section(sections, DocstringSectionParameters)):
251+
section = DocstringSectionParameters([])
252+
sections.append(section)
253+
elif isinstance(item, DocstringAttribute):
254+
if not (section := _get_section(sections, DocstringSectionAttributes)):
255+
section = DocstringSectionAttributes([])
256+
sections.append(section)
257+
else: # pragma: no cover
258+
raise TypeError(f"Unknown section type: {type(item)}")
259+
260+
existing = {x.name: x for x in section.value}
261+
if item.name in existing:
262+
current = existing[item.name]
263+
if current.description is None and item.description:
264+
current.description = item.description
265+
if current.annotation is None and item.annotation:
266+
current.annotation = item.annotation
267+
if current.value is None and item.value is not None:
268+
current.value = item.value
269+
else:
270+
section.value.append(item) # type: ignore [arg-type]

tests/fake_module.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,25 @@ class SomeDataclass:
66
"""SomeDataclass."""
77

88
x: int = field(default=1, metadata={"description": "The x field."})
9+
10+
11+
@dataclass
12+
class Inherited(SomeDataclass):
13+
y: int = 2
14+
15+
16+
@dataclass
17+
class WithPrivate:
18+
"""Class with private field."""
19+
20+
_hidden: int = 99
21+
22+
23+
def factory_func() -> int:
24+
"""Factory docstring."""
25+
return 7
26+
27+
28+
@dataclass
29+
class WithFactory:
30+
y: int = field(default_factory=factory_func)

0 commit comments

Comments
 (0)