Skip to content

Commit 6470891

Browse files
authored
Merge pull request #81 from machow/feat-module-doc
Feat module doc
2 parents 6814040 + 9a3f80a commit 6470891

File tree

11 files changed

+272
-50
lines changed

11 files changed

+272
-50
lines changed

quartodoc/ast.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
import warnings
4+
35
from enum import Enum
46
from dataclasses import dataclass
57
from griffe.docstrings import dataclasses as ds
@@ -179,7 +181,15 @@ def fields(el: dc.Object):
179181

180182
@dispatch
181183
def fields(el: dc.ObjectAliasMixin):
182-
return fields(el.target)
184+
try:
185+
return fields(el.target)
186+
except dc.AliasResolutionError:
187+
warnings.warn(
188+
f"Could not resolve Alias target `{el.target_path}`."
189+
" This often occurs because the module was not loaded (e.g. it is a"
190+
" package outside of your package)."
191+
)
192+
return ["name", "target_path"]
183193

184194

185195
@dispatch

quartodoc/autosummary.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -199,8 +199,8 @@ def replace_docstring(obj: dc.Object | dc.Alias, f=None):
199199
# since griffe reads class docstrings from the .__init__ method, this should
200200
# also have the effect of updating the class docstring.
201201
if isinstance(obj, dc.Class):
202-
for func_obj in obj.functions.values():
203-
replace_docstring(func_obj)
202+
for child_obj in obj.members.values():
203+
replace_docstring(child_obj)
204204

205205
if f is None:
206206
mod = importlib.import_module(obj.module.canonical_path)
@@ -253,12 +253,14 @@ def dynamic_alias(
253253
except ValueError:
254254
mod_name, object_path = path, None
255255

256+
# get underlying object dynamically ----
257+
256258
mod = importlib.import_module(mod_name)
257259

258260
# Case 1: path is just to a module
259261
if object_path is None:
260-
attr = get_object(path)
261-
canonical_path = attr.__name__
262+
attr = mod
263+
canonical_path = mod.__name__
262264

263265
# Case 2: path is to a member of a module
264266
else:
@@ -284,7 +286,7 @@ def dynamic_alias(
284286

285287
attr = crnt_part
286288

287-
# start loading things with griffe ----
289+
# start loading object with griffe ----
288290

289291
if target:
290292
obj = get_object(target, loader=loader)

quartodoc/builder/blueprint.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ def __init__(self, get_object=None, parser="numpy"):
4444

4545
self.crnt_package = None
4646

47+
@staticmethod
48+
def _append_member_path(path: str, new: str):
49+
if ":" in path:
50+
return f"{path}.{new}"
51+
52+
return f"{path}:{new}"
53+
4754
def get_object_fixed(self, *args, **kwargs):
4855
try:
4956
return self.get_object(*args, **kwargs)
@@ -79,7 +86,7 @@ def exit(self, el: Section):
7986
# otherwise, replace all contents with pages.
8087
new = el.copy()
8188
contents = [
82-
Page(contents=[el], path=el.name) if isinstance(el, Doc) else el
89+
Page(contents=[el], path=el.name) if not isinstance(el, Page) else el
8390
for el in new.contents
8491
]
8592

@@ -112,7 +119,8 @@ def enter(self, el: Auto):
112119
# but the actual objects on the target.
113120
# On the other hand, we've wired get_object up to make sure getting
114121
# the member of an Alias also returns an Alias.
115-
obj_member = self.get_object_fixed(f"{path}.{entry}", dynamic=el.dynamic)
122+
member_path = self._append_member_path(path, entry)
123+
obj_member = self.get_object_fixed(member_path, dynamic=el.dynamic)
116124

117125
# do no document submodules
118126
if obj_member.kind.value == "module":
@@ -126,7 +134,7 @@ def enter(self, el: Auto):
126134
res = MemberPage(path=obj_member.path, contents=[doc])
127135
# Case2: use just the Doc element, so it gets embedded directly
128136
# into the class being documented
129-
elif el.children == ChoicesChildren.embedded:
137+
elif el.children in {ChoicesChildren.embedded, ChoicesChildren.flat}:
130138
res = doc
131139
# Case 3: make each member just a link in a summary table.
132140
# if the page for the member is not created somewhere else, then it
@@ -139,7 +147,8 @@ def enter(self, el: Auto):
139147

140148
children.append(res)
141149

142-
return Doc.from_griffe(el.name, obj, members=children)
150+
is_flat = el.children == ChoicesChildren.flat
151+
return Doc.from_griffe(el.name, obj, members=children, flat=is_flat)
143152

144153
@staticmethod
145154
def _fetch_members(el: Auto, obj: dc.Object | dc.Alias):
@@ -182,6 +191,17 @@ def exit(self, el: Page):
182191
return el
183192

184193

194+
def blueprint(el: _Base, package: str = None):
195+
"""Create a blueprint of a layout element, that is ready to render."""
196+
197+
trans = BlueprintTransformer()
198+
199+
if package is not None:
200+
trans.crnt_package = package
201+
202+
return trans.visit(el)
203+
204+
185205
def strip_package_name(el: _Base, package: str):
186206
"""Removes leading package name from layout Pages."""
187207

quartodoc/builder/collect.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,7 @@ def exit(self, el: layout.Doc):
3838
page_node = self.find_page_node()
3939
p_el = page_node.value
4040

41-
anchor = el.name
42-
uri = f"{self.base_dir}/{p_el.path}.html#{anchor}"
41+
uri = f"{self.base_dir}/{p_el.path}.html#{el.anchor}"
4342
self.items.append(
4443
layout.Item(name=el.obj.path, obj=el.obj, uri=uri, dispname=None)
4544
)

quartodoc/layout.py

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ class Section(_Base):
6060
title: str
6161
desc: str
6262
package: Union[str, None, MISSING] = MISSING()
63-
contents: list[Union[ContentElement, Doc, _AutoDefault]]
63+
contents: ContentList
6464

6565

6666
class SummaryDetails(_Base):
@@ -79,7 +79,7 @@ class Page(_Base):
7979
summary: Optional[SummaryDetails] = None
8080
flatten: bool = False
8181

82-
contents: list[Union[ContentElement, Doc, _AutoDefault]]
82+
contents: ContentList
8383

8484
@property
8585
def obj(self):
@@ -99,6 +99,32 @@ class MemberPage(Page):
9999
contents: list[Doc]
100100

101101

102+
class Interlaced(BaseModel):
103+
"""A group of objects, whose documentation will be interlaced.
104+
105+
Rather than list each object's documentation in sequence, this element indicates
106+
that each piece of documentation (e.g. signatures, examples) should be grouped
107+
together.
108+
"""
109+
110+
kind: Literal["interlaced"] = "interlaced"
111+
package: Union[str, None, MISSING] = MISSING()
112+
113+
# note that this is similar to a ContentList, except it cannot include
114+
# elements like Pages, etc..
115+
contents: list[Union[Auto, Doc, _AutoDefault]]
116+
117+
@property
118+
def name(self):
119+
if not self.contents:
120+
raise AttributeError(
121+
f"Cannot get property name for object of type {type(self)}."
122+
" There are no content elements."
123+
)
124+
125+
return self.contents[0].name
126+
127+
102128
class Text(_Base):
103129
kind: Literal["text"] = "text"
104130
contents: str
@@ -180,7 +206,12 @@ class Config:
180206

181207
@classmethod
182208
def from_griffe(
183-
cls, name, obj: Union[dc.Object, dc.Alias], members=None, anchor: str = None
209+
cls,
210+
name,
211+
obj: Union[dc.Object, dc.Alias],
212+
members=None,
213+
anchor: str = None,
214+
flat: bool = False,
184215
):
185216
if members is None:
186217
members = []
@@ -195,9 +226,9 @@ def from_griffe(
195226
elif kind == "attribute":
196227
return DocAttribute(**kwargs)
197228
elif kind == "class":
198-
return DocClass(members=members, **kwargs)
229+
return DocClass(members=members, flat=flat, **kwargs)
199230
elif kind == "module":
200-
return DocModule(members=members, **kwargs)
231+
return DocModule(members=members, flat=flat, **kwargs)
201232

202233
raise TypeError(f"Cannot handle auto for object kind: {obj.kind}")
203234

@@ -209,6 +240,7 @@ class DocFunction(Doc):
209240
class DocClass(Doc):
210241
kind: Literal["class"] = "class"
211242
members: list[Union[MemberPage, Doc, Link]] = tuple()
243+
flat: bool
212244

213245

214246
class DocAttribute(Doc):
@@ -218,16 +250,19 @@ class DocAttribute(Doc):
218250
class DocModule(Doc):
219251
kind: Literal["module"] = "module"
220252
members: list[Union[MemberPage, Doc, Link]] = tuple()
253+
flat: bool
221254

222255

223256
SectionElement = Annotated[Union[Section, Page], Field(discriminator="kind")]
224257
"""Entry in the sections list."""
225258

226259
ContentElement = Annotated[
227-
Union[Page, Section, Text, Auto], Field(discriminator="kind")
260+
Union[Page, Section, Interlaced, Text, Auto], Field(discriminator="kind")
228261
]
229262
"""Entry in the contents list."""
230263

264+
ContentList = list[Union[ContentElement, Doc, _AutoDefault]]
265+
231266
# Item ----
232267

233268

@@ -248,3 +283,4 @@ class Config:
248283
Page.update_forward_refs()
249284
Auto.update_forward_refs()
250285
MemberPage.update_forward_refs()
286+
Interlaced.update_forward_refs()

quartodoc/renderers/md_renderer.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,53 @@ def render(self, el: layout.Section):
181181
body = list(map(self.render, el.contents))
182182

183183
return "\n\n".join([section_top, *body])
184+
185+
@dispatch
186+
def render(self, el: layout.Interlaced):
187+
# render a sequence of objects with like-sections together.
188+
# restrict its behavior to documenting functions for now ----
189+
for doc in el.contents:
190+
if not isinstance(doc, (layout.DocFunction, layout.DocAttribute)):
191+
raise NotImplementedError(
192+
"Can only render Interlaced elements if all content elements"
193+
" are function or attribute docs."
194+
f" Found an element of type {type(doc)}, with name {doc.name}"
195+
)
196+
197+
# render ----
198+
# currently, we use everything from the first function, and just render
199+
# the signatures together
200+
first_doc = el.contents[0]
201+
objs = [doc.obj for doc in el.contents]
202+
203+
if first_doc.obj.docstring is None:
204+
raise ValueError("The first element of Interlaced must have a docstring.")
205+
206+
207+
str_title = self.render_header(first_doc)
208+
str_sig = "\n\n".join(map(self.signature, objs))
209+
str_body = []
210+
211+
# TODO: we should also interlace parameters and examples
212+
# parsed = map(qast.transform, [x.docstring.parsed for x in objs if x.docstring])
213+
214+
# TODO: this is copied from the render method for dc.Object
215+
for section in qast.transform(first_doc.obj.docstring.parsed):
216+
title = section.title or section.kind.value
217+
body = self.render(section)
218+
219+
if title != "text":
220+
header = f"{'#' * (self.crnt_header_level + 1)} {title.title()}"
221+
str_body.append("\n\n".join([header, body]))
222+
else:
223+
str_body.append(body)
224+
225+
if self.show_signature:
226+
parts = [str_title, str_sig, *str_body]
227+
else:
228+
parts = [str_title, *str_body]
229+
230+
return "\n\n".join(parts)
184231

185232
@dispatch
186233
def render(self, el: layout.Doc):
@@ -226,7 +273,8 @@ def render(self, el: Union[layout.DocClass, layout.DocModule]):
226273
extra_parts.append(meths)
227274

228275
# TODO use context manager, or context variable?
229-
with self._increment_header(2):
276+
n_incr = 1 if el.flat else 2
277+
with self._increment_header(n_incr):
230278
meth_docs = [self.render(x) for x in raw_meths if isinstance(x, layout.Doc)]
231279

232280
body = self.render(el.obj)
@@ -477,6 +525,12 @@ def summarize(self, el: layout.Page):
477525
def summarize(self, el: layout.MemberPage):
478526
# TODO: model should validate these only have a single entry
479527
return self.summarize(el.contents[0], el.path, shorten = True)
528+
529+
@dispatch
530+
def summarize(self, el: layout.Interlaced, *args, **kwargs):
531+
rows = [self.summarize(doc, *args, **kwargs) for doc in el.contents]
532+
533+
return "\n".join(rows)
480534

481535
@dispatch
482536
def summarize(self, el: layout.Doc, path: Optional[str] = None, shorten: bool = False):

quartodoc/tests/example_dynamic.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from functools import partial
2+
13
NOTE = "Notes\n----\nI am a note"
24

35

@@ -9,3 +11,18 @@ def f(a, b, c):
911

1012

1113
f.__doc__ = f.__doc__.format(note=NOTE)
14+
15+
16+
class AClass:
17+
def simple(self, x):
18+
"""A simple method"""
19+
20+
def dynamic_doc(self, x):
21+
...
22+
23+
dynamic_doc.__doc__ = """A dynamic method"""
24+
25+
# note that we could use the partialmethod, but I am not sure how to
26+
# correctly set its __doc__ attribute in that case.
27+
dynamic_create = partial(dynamic_doc, x=1)
28+
dynamic_create.__doc__ = dynamic_doc.__doc__

quartodoc/tests/test_ast.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,17 @@ def test_preview_no_fail(capsys):
7575
assert "get_object" in res.out
7676

7777

78+
def test_preview_warn_alias_no_load():
79+
# fetch an alias to pydantic.BaseModel, without loading pydantic
80+
# attempting to get alias.target will fail, but preview should still work.
81+
obj = get_object("quartodoc.ast.BaseModel", load_aliases=False)
82+
with pytest.warns(UserWarning) as record:
83+
qast.preview(obj)
84+
85+
msg = record[0].message.args[0]
86+
assert "Could not resolve Alias target `pydantic.BaseModel`" in msg
87+
88+
7889
@pytest.mark.parametrize(
7990
"text, dst",
8091
[("One\n---\nab\n\nTwo\n---\n\ncd", [("One", "ab\n\n"), ("Two", "\ncd")])],

0 commit comments

Comments
 (0)