Skip to content

Commit 7d00a72

Browse files
committed
refactor: move transform logic in ast module; add tests
1 parent 11c0f47 commit 7d00a72

File tree

3 files changed

+170
-102
lines changed

3 files changed

+170
-102
lines changed

quartodoc/ast.py

Lines changed: 102 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,109 @@
1+
from enum import Enum
2+
from dataclasses import dataclass
13
from griffe.docstrings import dataclasses as ds
24
from griffe import dataclasses as dc
35
from plum import dispatch
46
from typing import Union
57

6-
# TODO: these classes are created to wrap some tuple outputs
7-
# we should consolidate logic for transforming the griffe
8-
# docstring here (or open a griffe issue).
9-
from .renderers import tuple_to_data, docstring_section_narrow, ExampleCode, ExampleText
8+
9+
# Transform and patched-in classes ============================================
10+
# TODO: annotate transform return types. make sure subtypes inherit from correct
11+
# griffe base objects.
12+
# TODO: it seems like transform should happen on the root, not individual elements.
13+
14+
15+
def transform(el):
16+
"""Return a more specific docstring element, or simply return the original one."""
17+
18+
if isinstance(el, tuple):
19+
try:
20+
return tuple_to_data(el)
21+
except ValueError:
22+
pass
23+
elif isinstance(el, ds.DocstringSection):
24+
return _DocstringSectionPatched.transform(el)
25+
26+
return el
27+
28+
29+
# Patch DocstringSection ------------------------------------------------------
30+
31+
32+
class DocstringSectionKindPatched(Enum):
33+
see_also = "see also"
34+
notes = "notes"
35+
warnings = "warnings"
36+
37+
38+
class _DocstringSectionPatched(ds.DocstringSection):
39+
_registry: "dict[Enum, _DocstringSectionPatched]" = {}
40+
41+
def __init__(self, value: str, title: "str | None" = None):
42+
self.value = value
43+
super().__init__(title)
44+
45+
def __init_subclass__(cls, **kwargs):
46+
super().__init_subclass__(**kwargs)
47+
48+
if cls.kind.value in cls._registry:
49+
raise KeyError(f"A section for kind {cls.kind} already exists")
50+
51+
cls._registry[cls.kind] = cls
52+
53+
@classmethod
54+
def transform(cls, el: ds.DocstringSection) -> ds.DocstringSection:
55+
"""Attempt to cast DocstringSection element to more specific section type.
56+
57+
Note that this is meant to patch cases where the general DocstringSectionText
58+
class represents a section like See Also, etc..
59+
"""
60+
61+
if isinstance(el, ds.DocstringSectionText):
62+
for kind, sub_cls in cls._registry.items():
63+
prefix = kind.value.title() + "\n---"
64+
if el.value.lstrip("\n").startswith(prefix):
65+
stripped = el.value.replace(prefix, "", 1).lstrip("-\n")
66+
return sub_cls(stripped, el.title)
67+
68+
return el
69+
70+
71+
class DocstringSectionSeeAlso(_DocstringSectionPatched):
72+
kind = DocstringSectionKindPatched.see_also
73+
74+
75+
class DocstringSectionNotes(_DocstringSectionPatched):
76+
kind = DocstringSectionKindPatched.notes
77+
78+
79+
class DocstringSectionWarnings(_DocstringSectionPatched):
80+
kind = DocstringSectionKindPatched.warnings
81+
82+
83+
# Patch Example elements ------------------------------------------------------
84+
85+
86+
@dataclass
87+
class ExampleCode:
88+
value: str
89+
90+
91+
@dataclass
92+
class ExampleText:
93+
value: str
94+
95+
96+
def tuple_to_data(el: "tuple[ds.DocstringSectionKind, str]"):
97+
"""Re-format funky tuple setup in example section to be a class."""
98+
assert len(el) == 2
99+
100+
kind, value = el
101+
if kind.value == "examples":
102+
return ExampleCode(value)
103+
elif kind.value == "text":
104+
return ExampleText(value)
105+
106+
raise ValueError(f"Unsupported first element in tuple: {kind}")
10107

11108

12109
# Tree previewer ==============================================================
@@ -106,7 +203,7 @@ def __init__(self, string_max_length: int = 50, max_depth=999):
106203
def format(self, call, depth=0, pad=0):
107204
"""Return a Symbolic or Call back as a nice tree, with boxes for nodes."""
108205

109-
call = self.transform(call)
206+
call = transform(call)
110207

111208
crnt_fields = fields(call)
112209

@@ -147,19 +244,6 @@ def get_field(self, obj, k):
147244

148245
return getattr(obj, k)
149246

150-
def transform(self, obj):
151-
# TODO: currently this transform happens here, and in the renderer.
152-
# let's consolidate this into one step (when getting the object)
153-
if isinstance(obj, tuple):
154-
try:
155-
return tuple_to_data(obj)
156-
except ValueError:
157-
pass
158-
elif isinstance(obj, ds.DocstringSectionText):
159-
return docstring_section_narrow(obj)
160-
161-
return obj
162-
163247
def fmt_pipe(self, x, is_final=False, pad=0):
164248
if not is_final:
165249
connector = self.icon_connector if not is_final else " "

quartodoc/renderers.py

Lines changed: 7 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,91 +1,14 @@
1+
import quartodoc.ast as qast
12
import re
23

3-
from enum import Enum
44
from griffe.docstrings import dataclasses as ds
55
from griffe import dataclasses as dc
6-
from dataclasses import dataclass
76
from tabulate import tabulate
87
from plum import dispatch
98
from typing import Tuple, Union
109

1110

12-
# Docstring rendering =========================================================
13-
1411
# utils -----------------------------------------------------------------------
15-
# these largely re-format the output of griffe
16-
17-
18-
def tuple_to_data(el: "tuple[ds.DocstringSectionKind, str]"):
19-
"""Re-format funky tuple setup in example section to be a class."""
20-
assert len(el) == 2
21-
22-
kind, value = el
23-
if kind.value == "examples":
24-
return ExampleCode(value)
25-
elif kind.value == "text":
26-
return ExampleText(value)
27-
28-
raise ValueError(f"Unsupported first element in tuple: {kind}")
29-
30-
31-
def docstring_section_narrow(el: ds.DocstringSection) -> ds.DocstringSection:
32-
# attempt to narrow down text sections
33-
to_narrow = [
34-
DocstringSectionSeeAlso,
35-
DocstringSectionNotes,
36-
DocstringSectionWarnings,
37-
]
38-
prefix = "See Also\n---"
39-
40-
if isinstance(el, ds.DocstringSectionText):
41-
for cls in to_narrow:
42-
prefix = cls.kind.value.title() + "\n---"
43-
if el.value.lstrip("\n").startswith(prefix):
44-
45-
stripped = el.value.replace(prefix, "", 1).lstrip("-\n")
46-
return cls(stripped, el.title)
47-
48-
return el
49-
50-
51-
class DocstringSectionKindPatched(Enum):
52-
see_also = "see also"
53-
notes = "notes"
54-
warnings = "warnings"
55-
56-
57-
class DocstringSectionSeeAlso(ds.DocstringSection):
58-
kind = DocstringSectionKindPatched.see_also
59-
60-
def __init__(self, value: str, title: "str | None"):
61-
self.value = value
62-
super().__init__(title)
63-
64-
65-
class DocstringSectionNotes(ds.DocstringSection):
66-
kind = DocstringSectionKindPatched.notes
67-
68-
def __init__(self, value: str, title: "str | None"):
69-
self.value = value
70-
super().__init__(title)
71-
72-
73-
class DocstringSectionWarnings(ds.DocstringSection):
74-
kind = DocstringSectionKindPatched.warnings
75-
76-
def __init__(self, value: str, title: "str | None"):
77-
self.value = value
78-
super().__init__(title)
79-
80-
81-
@dataclass
82-
class ExampleCode:
83-
value: str
84-
85-
86-
@dataclass
87-
class ExampleText:
88-
value: str
8912

9013

9114
def escape(val: str):
@@ -258,7 +181,7 @@ def render(self, el: Union[dc.Object, dc.Alias]):
258181
pass
259182
else:
260183
for section in el.docstring.parsed:
261-
new_el = docstring_section_narrow(section)
184+
new_el = qast.transform(section)
262185
title = new_el.kind.value
263186
body = self.render(new_el)
264187

@@ -309,7 +232,7 @@ def render(self, el: dc.Parameter):
309232
# or a section with a header not included in the numpydoc standard
310233
@dispatch
311234
def render(self, el: ds.DocstringSectionText):
312-
new_el = docstring_section_narrow(el)
235+
new_el = qast.transform(el)
313236
if isinstance(new_el, ds.DocstringSectionText):
314237
# ensures we don't recurse forever
315238
return el.value
@@ -349,7 +272,7 @@ def render(self, el: ds.DocstringAttribute):
349272
# see also ----
350273

351274
@dispatch
352-
def render(self, el: DocstringSectionSeeAlso):
275+
def render(self, el: qast.DocstringSectionSeeAlso):
353276
# TODO: attempt to parse See Also sections
354277
return convert_rst_link_to_md(el.value)
355278

@@ -358,11 +281,11 @@ def render(self, el: DocstringSectionSeeAlso):
358281
@dispatch
359282
def render(self, el: ds.DocstringSectionExamples):
360283
# its value is a tuple: DocstringSectionKind["text" | "examples"], str
361-
data = map(tuple_to_data, el.value)
284+
data = map(qast.transform, el.value)
362285
return "\n\n".join(list(map(self.render, data)))
363286

364287
@dispatch
365-
def render(self, el: ExampleCode):
288+
def render(self, el: qast.ExampleCode):
366289
return f"""```python
367290
{el.value}
368291
```"""
@@ -384,7 +307,7 @@ def render(self, el: Union[ds.DocstringReturn, ds.DocstringRaise]):
384307
# unsupported parts ----
385308

386309
@dispatch
387-
def render(self, el: ExampleText):
310+
def render(self, el: qast.ExampleText):
388311
return el.value
389312

390313
@dispatch.multi(

quartodoc/tests/test_ast.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import quartodoc.ast as qast
2+
3+
import pytest
4+
from griffe.docstrings import dataclasses as ds
5+
from griffe import dataclasses as dc
6+
from griffe.docstrings.parsers import parse_numpy
7+
8+
9+
@pytest.mark.parametrize(
10+
"el, body",
11+
[
12+
("See Also\n---", ""),
13+
("See Also\n---\n", ""),
14+
("See Also\n--------", ""),
15+
("\n\nSee Also\n---\n", ""),
16+
("See Also\n---\nbody text", "body text"),
17+
("See Also\n---\nbody text", "body text"),
18+
],
19+
)
20+
def test_transform_docstring_section(el, body):
21+
src = ds.DocstringSectionText(el, title=None)
22+
res = qast._DocstringSectionPatched.transform(src)
23+
24+
assert isinstance(res, qast.DocstringSectionSeeAlso)
25+
assert res.value == body
26+
27+
28+
@pytest.mark.parametrize(
29+
"el, cls",
30+
[
31+
("See Also\n---", qast.DocstringSectionSeeAlso),
32+
("Warnings\n---", qast.DocstringSectionWarnings),
33+
("Notes\n---", qast.DocstringSectionNotes),
34+
],
35+
)
36+
def test_transform_docstring_section_subtype(el, cls):
37+
# using transform method ----
38+
src = ds.DocstringSectionText(el, title=None)
39+
res = qast._DocstringSectionPatched.transform(src)
40+
41+
assert isinstance(res, cls)
42+
43+
# using transform function ----
44+
parsed = parse_numpy(dc.Docstring(el))
45+
assert len(parsed) == 1
46+
47+
res2 = qast.transform(parsed[0])
48+
assert isinstance(res2, cls)
49+
50+
51+
@pytest.mark.xfail(reason="TODO: sections get grouped into single element")
52+
def test_transform_docstring_section_clump():
53+
docstring = "See Also---\n\nWarnings\n---\n\nNotes---\n\n"
54+
parsed = parse_numpy(dc.Docstring(docstring))
55+
56+
assert len(parsed) == 1
57+
58+
# res = transform(parsed[0])
59+
60+
# what to do here? this should more reasonably be handled when transform
61+
# operates on the root.

0 commit comments

Comments
 (0)