Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions quartodoc/builder/blueprint.py
Original file line number Diff line number Diff line change
Expand Up @@ -350,13 +350,16 @@ def enter(self, el: Auto):
children.append(res)

is_flat = el.children == ChoicesChildren.flat
return Doc.from_griffe(
el.name,
obj,
children,
flat=is_flat,
signature_name=el.signature_name,
)

# Only pass desc_first if it was explicitly set by the user
kwargs = {
"flat": is_flat,
"signature_name": el.signature_name,
}
if "desc_first" in el._fields_specified:
kwargs["desc_first"] = el.desc_first

return Doc.from_griffe(el.name, obj, children, **kwargs)

def _fetch_members(self, el: Auto, obj: dc.Object | dc.Alias):
# Note that this could be a static method, if we passed in the griffe loader
Expand Down
4 changes: 4 additions & 0 deletions quartodoc/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ class AutoOptions(_Base):
package: Union[str, None, MISSING] = MISSING()
member_order: Literal["alphabetical", "source"] = "alphabetical"
member_options: Optional["AutoOptions"] = None
desc_first: bool = False

# for tracking fields users manually specify
# so we can tell them apart from defaults
Expand Down Expand Up @@ -344,6 +345,7 @@ class Doc(_Docable):
obj: Union[dc.Object, dc.Alias]
anchor: str
signature_name: SignatureOptions = "relative"
desc_first: Optional[bool] = None

class Config:
arbitrary_types_allowed = True
Expand All @@ -358,6 +360,7 @@ def from_griffe(
anchor: str = None,
flat: bool = False,
signature_name: str = "relative",
desc_first: Optional[bool] = None,
):
if members is None:
members = []
Expand All @@ -370,6 +373,7 @@ def from_griffe(
"obj": obj,
"anchor": anchor,
"signature_name": signature_name,
"desc_first": desc_first,
}

if kind == "function":
Expand Down
122 changes: 114 additions & 8 deletions quartodoc/renderers/md_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ class MdRenderer(Renderer):
"full", or "canonical". These options range from just the function name, to its
full path relative to its package, to including the package name, to its
the its full path relative to its .__module__.
desc_first: bool
Whether to place the description (first paragraph of docstring) after the
object name and before the signature. When True, the order is: title,
description, signature, remaining content. Default is False.

Examples
--------
Expand All @@ -154,6 +158,7 @@ def __init__(
render_interlinks=False,
# table_style="description-list",
table_style="table",
desc_first: bool = False,
):
self.header_level = header_level
self.show_signature = show_signature
Expand All @@ -162,6 +167,7 @@ def __init__(
self.hook_pre = hook_pre
self.render_interlinks = render_interlinks
self.table_style = table_style
self.desc_first = desc_first

self.crnt_header_level = self.header_level

Expand All @@ -173,6 +179,59 @@ def _increment_header(self, n=1):
finally:
self.crnt_header_level -= n

def _extract_description(self, el: Union[dc.Object, dc.Alias]) -> Optional[str]:
if el.docstring is None:
return None

patched_sections = qast.transform(el.docstring.parsed)

for section in patched_sections:
title = section.title or section.kind.value
if title == "text":
# Get the raw text value from the section
text = section.value if hasattr(section, 'value') else ""
if text:
# Split on double newline to get first paragraph
paragraphs = text.split('\n\n')
return paragraphs[0].strip() if paragraphs else None

return None

def _render_without_first_paragraph(self, el: Union[dc.Object, dc.Alias]) -> str:
if el.docstring is None:
return ""

str_body = []
patched_sections = qast.transform(el.docstring.parsed)
first_text_seen = False

for section in patched_sections:
title = section.title or section.kind.value

# Handle the first text section specially
if title == "text" and not first_text_seen:
first_text_seen = True
# Get remaining paragraphs after the first one
text = section.value if hasattr(section, 'value') else ""
if text:
paragraphs = text.split('\n\n')
if len(paragraphs) > 1:
# Join remaining paragraphs and add them
remaining = '\n\n'.join(paragraphs[1:]).strip()
if remaining:
str_body.append(remaining)
continue

body: str = self.render(section)

if title != "text":
header = self.render_header(section)
str_body.append("\n\n".join([header, body]))
else:
str_body.append(body)

return "\n\n".join(str_body)

def _fetch_object_dispname(self, el: "dc.Alias | dc.Object"):
# TODO: copied from Builder, should move into util function
if self.display_name in {"name", "short"}:
Expand Down Expand Up @@ -453,12 +512,36 @@ def render(self, el: Union[layout.DocClass, layout.DocModule]):
str_sig = self.signature(el)
sig_part = [str_sig] if self.show_signature else []

with self._increment_header():
body = self.render(el.obj)
# Check for desc_first on the element first, then fall back to renderer default
desc_first = getattr(el, 'desc_first', None)
if desc_first is None:
desc_first = self.desc_first

if desc_first:
# Extract first paragraph as description
desc = self._extract_description(el.obj)

with self._increment_header():
# Render body without first paragraph
body = self._render_without_first_paragraph(el.obj)

# Reorder to get title, description, signature, rest of body, members
if desc:
# Wrap description in a div with inline styles
desc_wrapped = f'::: {{.lead style="font-style: italic; margin-top: -10px;"}}\n{desc}\n:::'
# If body is not empty, include it; otherwise, don't include it
parts = ([title, desc_wrapped, *sig_part, body, *attr_docs, *class_docs, *meth_docs] if body
else [title, desc_wrapped, *sig_part, *attr_docs, *class_docs, *meth_docs])
else:
# Case with no description extracted
parts = [title, *sig_part, body, *attr_docs, *class_docs, *meth_docs]
else:
with self._increment_header():
body = self.render(el.obj)

parts = [title, *sig_part, body, *attr_docs, *class_docs, *meth_docs]

return "\n\n".join(
[title, *sig_part, body, *attr_docs, *class_docs, *meth_docs]
)
return "\n\n".join(parts)

@dispatch
def render(self, el: Union[layout.DocFunction, layout.DocAttribute]):
Expand All @@ -467,10 +550,33 @@ def render(self, el: Union[layout.DocFunction, layout.DocAttribute]):
str_sig = self.signature(el)
sig_part = [str_sig] if self.show_signature else []

with self._increment_header():
body = self.render(el.obj)
# Check for desc_first on the element first, then fall back to renderer default
desc_first = getattr(el, 'desc_first', None)
if desc_first is None:
desc_first = self.desc_first

if desc_first:
# Extract first paragraph as description
desc = self._extract_description(el.obj)

with self._increment_header():
# Render body without the first paragraph
body = self._render_without_first_paragraph(el.obj)

# Reorder: title, description, signature, rest of body
if desc:
# Wrap description in a div with inline styles
desc_wrapped = f'::: {{.lead style="font-size: 1rem; font-style: italic; margin-top: -10px; line-height: 1;"}}\n{desc}\n:::'
parts = [title, desc_wrapped, *sig_part, body] if body else [title, desc_wrapped, *sig_part]
else:
parts = [title, *sig_part, body]
else:
with self._increment_header():
body = self.render(el.obj)

parts = [title, *sig_part, body]

return "\n\n".join([title, *sig_part, body])
return "\n\n".join(parts)

# render griffe objects ===================================================

Expand Down
68 changes: 68 additions & 0 deletions quartodoc/tests/test_renderers.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,3 +247,71 @@ def test_render_numpydoc_section_return(snapshot, doc):
assert snapshot == indented_sections(
Code=full_doc, Default=res_default, List=res_list
)


# desc_first tests ------------------------------------------------------------


def test_render_desc_first_function():
auto = Auto(name="a_func", package="quartodoc.tests.example", desc_first=True)
bp = blueprint(auto)

renderer = MdRenderer()
result = renderer.render(bp)

# The result should have: title, then description, then signature, then rest
desc_pos = result.find("A function")
title_pos = result.find("# a_func")

assert desc_pos != -1
assert title_pos != -1
assert title_pos < desc_pos


def test_render_desc_first_false():
auto = Auto(name="a_func", package="quartodoc.tests.example", desc_first=False)
bp = blueprint(auto)

renderer = MdRenderer()
result = renderer.render(bp)

# The result should have: title, signature, then description
desc_pos = result.find("A function")
title_pos = result.find("# a_func")

assert desc_pos != -1
assert title_pos != -1
assert title_pos < desc_pos


def test_render_desc_first_class():
auto = Auto(name="AClass", package="quartodoc.tests.example", desc_first=True)
bp = blueprint(auto)

renderer = MdRenderer()
result = renderer.render(bp)

# Check that "# AClass" (title) appears before "A class" (description)
desc_pos = result.find("A class")
title_pos = result.find("# AClass")

assert desc_pos != -1
assert title_pos != -1
assert title_pos < desc_pos


def test_render_desc_first_renderer_default():
auto = Auto(name="a_func", package="quartodoc.tests.example")
bp = blueprint(auto)

# Renderer with desc_first=True as default
renderer = MdRenderer(desc_first=True)
result = renderer.render(bp)

# Check that title comes before description
desc_pos = result.find("A function")
title_pos = result.find("# a_func")

assert desc_pos != -1
assert title_pos != -1
assert title_pos < desc_pos