Skip to content

Commit 02c0042

Browse files
committed
feat: Support attributes sections for Google-style docstrings
1 parent 9ec1edc commit 02c0042

File tree

5 files changed

+130
-3
lines changed

5 files changed

+130
-3
lines changed

Diff for: src/pytkdocs/parsers/docstrings/base.py

+18-1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,22 @@ def __init__(self, annotation: Any, description: str) -> None:
2222
self.description = description
2323

2424

25+
class Attribute(AnnotatedObject):
26+
"""A helper class to store information about a documented attribute."""
27+
28+
def __init__(self, name: str, annotation: Any, description: str) -> None:
29+
"""
30+
Initialization method.
31+
32+
Arguments:
33+
name: The attribute's name.
34+
annotation: The object's annotation.
35+
description: The object's description.
36+
"""
37+
super().__init__(annotation, description)
38+
self.name = name
39+
40+
2541
class Parameter(AnnotatedObject):
2642
"""A helper class to store information about a signature parameter."""
2743

@@ -90,6 +106,7 @@ class Type:
90106
EXCEPTIONS = "exceptions"
91107
RETURN = "return"
92108
EXAMPLES = "examples"
109+
ATTRIBUTES = "attributes"
93110

94111
def __init__(self, section_type: str, value: Any) -> None:
95112
"""
@@ -125,7 +142,7 @@ def __init__(self) -> None:
125142
self.context: dict = {}
126143
self.errors: List[str] = []
127144

128-
def parse(self, docstring: str, context: Optional[dict] = None,) -> Tuple[List[Section], List[str]]:
145+
def parse(self, docstring: str, context: Optional[dict] = None) -> Tuple[List[Section], List[str]]:
129146
"""
130147
Parse a docstring and return a list of sections and parsing errors.
131148

Diff for: src/pytkdocs/parsers/docstrings/google.py

+55-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import re
33
from typing import Any, List, Optional, Pattern, Sequence, Tuple
44

5-
from pytkdocs.parsers.docstrings.base import AnnotatedObject, Parameter, Parser, Section, empty
5+
from pytkdocs.parsers.docstrings.base import AnnotatedObject, Attribute, Parameter, Parser, Section, empty
66

77
TITLES_PARAMETERS: Sequence[str] = ("args:", "arguments:", "params:", "parameters:")
88
"""Titles to match for "parameters" sections."""
@@ -16,6 +16,9 @@
1616
TITLES_EXAMPLES: Sequence[str] = ("example:", "examples:")
1717
"""Titles to match for "examples" sections."""
1818

19+
TITLES_ATTRIBUTES: Sequence[str] = ("attribute:", "attributes:")
20+
"""Titles to match for "attributes" sections."""
21+
1922
RE_GOOGLE_STYLE_ADMONITION: Pattern = re.compile(r"^(?P<indent>\s*)(?P<type>[\w-]+):((?:\s+)(?P<title>.+))?$")
2023
"""Regular expressions to match lines starting admonitions, of the form `TYPE: [TITLE]`."""
2124

@@ -38,6 +41,8 @@ def parse_sections(self, docstring: str) -> List[Section]: # noqa: D102
3841
self.context["signature"] = getattr(self.context["obj"], "signature", None)
3942
if "annotation" not in self.context:
4043
self.context["annotation"] = getattr(self.context["obj"], "type", empty)
44+
if "attributes" not in self.context:
45+
self.context["attributes"] = {}
4146

4247
sections = []
4348
current_section = []
@@ -55,6 +60,15 @@ def parse_sections(self, docstring: str) -> List[Section]: # noqa: D102
5560
in_code_block = False
5661
current_section.append(lines[i])
5762

63+
elif line_lower in TITLES_ATTRIBUTES:
64+
if current_section:
65+
if any(current_section):
66+
sections.append(Section(Section.Type.MARKDOWN, "\n".join(current_section)))
67+
current_section = []
68+
section, i = self.read_attributes_section(lines, i + 1)
69+
if section:
70+
sections.append(section)
71+
5872
elif line_lower in TITLES_PARAMETERS:
5973
if current_section:
6074
if any(current_section):
@@ -296,6 +310,46 @@ def read_parameters_section(self, lines: List[str], start_index: int) -> Tuple[O
296310
self.error(f"Empty parameters section at line {start_index}")
297311
return None, i
298312

313+
def read_attributes_section(self, lines: List[str], start_index: int) -> Tuple[Optional[Section], int]:
314+
"""
315+
Parse an "attributes" section.
316+
317+
Arguments:
318+
lines: The parameters block lines.
319+
start_index: The line number to start at.
320+
321+
Returns:
322+
A tuple containing a `Section` (or `None`) and the index at which to continue parsing.
323+
"""
324+
attributes = []
325+
block, i = self.read_block_items(lines, start_index)
326+
327+
for attr_line in block:
328+
try:
329+
name_with_type, description = attr_line.split(":", 1)
330+
except ValueError:
331+
self.error(f"Failed to get 'name: description' pair from '{attr_line}'")
332+
continue
333+
334+
description = description.lstrip()
335+
336+
if " " in name_with_type:
337+
name, annotation = name_with_type.split(" ", 1)
338+
annotation = annotation.strip("()")
339+
if annotation.endswith(", optional"):
340+
annotation = annotation[:-10]
341+
else:
342+
name = name_with_type
343+
annotation = self.context["attributes"].get(name, {}).get("annotation", empty)
344+
345+
attributes.append(Attribute(name=name, annotation=annotation, description=description))
346+
347+
if attributes:
348+
return Section(Section.Type.ATTRIBUTES, attributes), i
349+
350+
self.error(f"Empty attributes section at line {start_index}")
351+
return None, i
352+
299353
def read_exceptions_section(self, lines: List[str], start_index: int) -> Tuple[Optional[Section], int]:
300354
"""
301355
Parse an "exceptions" section.

Diff for: src/pytkdocs/serializer.py

+20-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from typing import Any, Optional, Pattern
1010

1111
from pytkdocs.objects import Object, Source
12-
from pytkdocs.parsers.docstrings.base import AnnotatedObject, Parameter, Section
12+
from pytkdocs.parsers.docstrings.base import AnnotatedObject, Attribute, Parameter, Section
1313

1414
try:
1515
from typing import GenericMeta # python 3.6
@@ -84,6 +84,23 @@ def serialize_annotated_object(obj: AnnotatedObject) -> dict:
8484
return {"description": obj.description, "annotation": annotation_to_string(obj.annotation)}
8585

8686

87+
def serialize_attribute(attribute: Attribute) -> dict:
88+
"""
89+
Serialize an instance of [`Attribute`][pytkdocs.parsers.docstrings.base.Attribute].
90+
91+
Arguments:
92+
attribute: The attribute to serialize.
93+
94+
Returns:
95+
A JSON-serializable dictionary.
96+
"""
97+
return {
98+
"name": attribute.name,
99+
"description": attribute.description,
100+
"annotation": annotation_to_string(attribute.annotation),
101+
}
102+
103+
87104
def serialize_parameter(parameter: Parameter) -> dict:
88105
"""
89106
Serialize an instance of [`Parameter`][pytkdocs.parsers.docstrings.base.Parameter].
@@ -166,6 +183,8 @@ def serialize_docstring_section(section: Section) -> dict:
166183
serialized.update({"value": [serialize_annotated_object(e) for e in section.value]}) # type: ignore
167184
elif section.type == section.Type.PARAMETERS:
168185
serialized.update({"value": [serialize_parameter(p) for p in section.value]}) # type: ignore
186+
elif section.type == section.Type.ATTRIBUTES:
187+
serialized.update({"value": [serialize_attribute(p) for p in section.value]}) # type: ignore
169188
elif section.type == section.Type.EXAMPLES:
170189
serialized.update({"value": section.value}) # type: ignore
171190
return serialized

Diff for: tests/fixtures/docstring_attributes_section.py

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""
2+
Let's describe some attributes.
3+
4+
Attributes:
5+
A: Alpha.
6+
B (bytes): Beta.
7+
C: Gamma.
8+
D: Delta.
9+
E (float): Epsilon.
10+
"""
11+
12+
A: int = 0
13+
B: str = "ŧ"
14+
C: bool = True
15+
D = 3.0
16+
E = None

Diff for: tests/test_parsers/test_docstrings/test_google.py

+21
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
from textwrap import dedent
55

66
from pytkdocs.loader import Loader
7+
from pytkdocs.parsers.docstrings.base import Section
78
from pytkdocs.parsers.docstrings.google import Google
9+
from pytkdocs.serializer import serialize_attribute
810

911

1012
class DummyObject:
@@ -521,3 +523,22 @@ def f():
521523
assert sections[2].value == " AnyLine: ...indented with less than 5 spaces signifies the end of the section."
522524
assert len(errors) == 1
523525
assert "should be 5 * 2 = 10 spaces, not 6" in errors[0]
526+
527+
528+
def test_parse_module_attributes_section():
529+
"""Parse attributes section in modules."""
530+
loader = Loader()
531+
obj = loader.get_object_documentation("tests.fixtures.docstring_attributes_section")
532+
assert len(obj.docstring_sections) == 2
533+
assert not obj.docstring_errors
534+
attr_section = obj.docstring_sections[1]
535+
assert attr_section.type == Section.Type.ATTRIBUTES
536+
assert len(attr_section.value) == 5
537+
expected = [
538+
{"name": "A", "annotation": "int", "description": "Alpha."},
539+
{"name": "B", "annotation": "bytes", "description": "Beta."},
540+
{"name": "C", "annotation": "bool", "description": "Gamma."},
541+
{"name": "D", "annotation": "", "description": "Delta."},
542+
{"name": "E", "annotation": "float", "description": "Epsilon."},
543+
]
544+
assert [serialize_attribute(attr) for attr in attr_section.value] == expected

0 commit comments

Comments
 (0)