From 58779011cf5503d14c6e2cce7ce6539e6eadea77 Mon Sep 17 00:00:00 2001 From: Michael Chow Date: Fri, 5 Dec 2025 15:48:22 -0500 Subject: [PATCH 1/3] feat: simplify return table and list if possible --- quartodoc/renderers/md_renderer.py | 33 +++- .../tests/__snapshots__/test_renderers.ambr | 178 ++++++++++++++++++ quartodoc/tests/test_renderers.py | 23 +++ 3 files changed, 225 insertions(+), 9 deletions(-) diff --git a/quartodoc/renderers/md_renderer.py b/quartodoc/renderers/md_renderer.py index 72d12430..af67ab4e 100644 --- a/quartodoc/renderers/md_renderer.py +++ b/quartodoc/renderers/md_renderer.py @@ -84,16 +84,16 @@ def to_definition_list(self): part_desc = desc if desc is not None else "" - anno_sep = Span(":", Attr(classes=["parameter-annotation-sep"])) + # Only include the colon separator if there's a name + if name is not None: + anno_sep = Span(":", Attr(classes=["parameter-annotation-sep"])) + parts = [part_name, anno_sep, part_anno, part_default_sep, part_default] + else: + # No name means no colon separator (e.g., for Raises) + parts = [part_anno, part_default_sep, part_default] # TODO: should code wrap the whole thing like this? - param = Code( - str( - Inlines( - [part_name, anno_sep, part_anno, part_default_sep, part_default] - ) - ) - ).html + param = Code(str(Inlines(parts))).html return (param, part_desc) def to_tuple(self, style: Literal["parameters", "attributes", "returns"]): @@ -265,7 +265,21 @@ def _render_table( if self.table_style == "description-list": return str(DefinitionList([row.to_definition_list() for row in rows])) else: - row_tuples = [row.to_tuple(style) for row in rows] + # Check if any rows have names - if not, omit the Name column + # Note: Parameters always have names, but Returns/Raises may not + has_names = any(row.name is not None for row in rows) + + if not has_names and style == "returns" and headers[0] == "Name": + # Omit Name column when no items have names (e.g., Raises section) + headers = headers[1:] # Remove "Name" from headers + row_tuples = [ + (row.annotation, sanitize(row.description, allow_markdown=True)) + for row in rows + ] + else: + # Standard rendering with all columns + row_tuples = [row.to_tuple(style) for row in rows] + table = tabulate(row_tuples, headers=headers, tablefmt="github") return table @@ -766,6 +780,7 @@ def render(self, el: Union[ds.DocstringSectionReturns, ds.DocstringSectionRaises rows = list(map(self.render, el.value)) header = ["Name", "Type", "Description"] + # _render_table will dynamically omit the Name column if no rows have names return self._render_table(rows, header, "returns") @dispatch diff --git a/quartodoc/tests/__snapshots__/test_renderers.ambr b/quartodoc/tests/__snapshots__/test_renderers.ambr index 9a848944..8b021ffc 100644 --- a/quartodoc/tests/__snapshots__/test_renderers.ambr +++ b/quartodoc/tests/__snapshots__/test_renderers.ambr @@ -579,6 +579,184 @@ | b | str | The b parameter. | _required_ | ''' # --- +# name: test_render_full_numpydoc + ''' + # full_numpydoc_function { #quartodoc.tests.example_docstring_full.full_numpydoc_function } + + ```python + tests.example_docstring_full.full_numpydoc_function( + x, + y=None, + *args, + option=False, + **kwargs, + ) + ``` + + A one-line summary. + + An extended summary that provides more detail about what this + function does. This demonstrates the full range of numpydoc + sections that are supported. + + ## Parameters {.doc-section .doc-section-parameters} + + | Name | Type | Description | Default | + |----------|--------|--------------------------------|------------| + | x | int | The first parameter. | _required_ | + | y | str | The second parameter. | `None` | + | *args | float | Variable positional arguments. | `()` | + | option | bool | A keyword-only parameter. | `False` | + | **kwargs | dict | Variable keyword arguments. | `{}` | + + ## Returns {.doc-section .doc-section-returns} + + | Name | Type | Description | + |--------|--------|------------------------------------------| + | result | int | The computed result with name and type. | + | | list | A secondary return value with only type. | + + ## Yields {.doc-section .doc-section-yields} + + | Name | Type | Description | + |--------|--------|--------------------------| + | value | str | Generated string values. | + + ## Raises {.doc-section .doc-section-raises} + + | Type | Description | + |------------|-----------------------| + | ValueError | If x is negative. | + | TypeError | If y is not a string. | + + ## See Also {.doc-section .doc-section-see-also} + + other_function : Related functionality. + module.another_function : Another related function. + + ## Notes {.doc-section .doc-section-notes} + + I am a note. + + ## References {.doc-section .doc-section-references} + + .. [1] Author Name, "Paper Title", Journal, 2024. TODO + + ## Examples {.doc-section .doc-section-examples} + + Basic usage: + + ```python + >>> full_numpydoc_function(1, "test") + (42, [1, 2, 3]) + ``` + + With optional parameters: + + ```python + >>> full_numpydoc_function(1, option=True) + (42, []) + ``` + ''' +# --- +# name: test_render_full_numpydoc_description_list + ''' + # full_numpydoc_function { #quartodoc.tests.example_docstring_full.full_numpydoc_function } + + ```python + tests.example_docstring_full.full_numpydoc_function( + x, + y=None, + *args, + option=False, + **kwargs, + ) + ``` + + A one-line summary. + + An extended summary that provides more detail about what this + function does. This demonstrates the full range of numpydoc + sections that are supported. + + ## Parameters {.doc-section .doc-section-parameters} + + [**x**]{.parameter-name} [:]{.parameter-annotation-sep} [int]{.parameter-annotation} + + : The first parameter. + + [**y**]{.parameter-name} [:]{.parameter-annotation-sep} [str]{.parameter-annotation} [ = ]{.parameter-default-sep} [None]{.parameter-default} + + : The second parameter. + + [***args**]{.parameter-name} [:]{.parameter-annotation-sep} [float]{.parameter-annotation} [ = ]{.parameter-default-sep} [()]{.parameter-default} + + : Variable positional arguments. + + [**option**]{.parameter-name} [:]{.parameter-annotation-sep} [bool]{.parameter-annotation} [ = ]{.parameter-default-sep} [False]{.parameter-default} + + : A keyword-only parameter. + + [****kwargs**]{.parameter-name} [:]{.parameter-annotation-sep} [dict]{.parameter-annotation} [ = ]{.parameter-default-sep} [{}]{.parameter-default} + + : Variable keyword arguments. + + ## Returns {.doc-section .doc-section-returns} + + [**result**]{.parameter-name} [:]{.parameter-annotation-sep} [int]{.parameter-annotation} + + : The computed result with name and type. + + []{.parameter-name} [:]{.parameter-annotation-sep} [list]{.parameter-annotation} + + : A secondary return value with only type. + + ## Yields {.doc-section .doc-section-yields} + + [**value**]{.parameter-name} [:]{.parameter-annotation-sep} [str]{.parameter-annotation} + + : Generated string values. + + ## Raises {.doc-section .doc-section-raises} + + [ValueError]{.parameter-annotation} + + : If x is negative. + + [TypeError]{.parameter-annotation} + + : If y is not a string. + + ## See Also {.doc-section .doc-section-see-also} + + other_function : Related functionality. + module.another_function : Another related function. + + ## Notes {.doc-section .doc-section-notes} + + I am a note. + + ## References {.doc-section .doc-section-references} + + .. [1] Author Name, "Paper Title", Journal, 2024. TODO + + ## Examples {.doc-section .doc-section-examples} + + Basic usage: + + ```python + >>> full_numpydoc_function(1, "test") + (42, [1, 2, 3]) + ``` + + With optional parameters: + + ```python + >>> full_numpydoc_function(1, option=True) + (42, []) + ``` + ''' +# --- # name: test_render_google_section_yields[int: A description.] ''' Code diff --git a/quartodoc/tests/test_renderers.py b/quartodoc/tests/test_renderers.py index 89f09e8c..c5d85734 100644 --- a/quartodoc/tests/test_renderers.py +++ b/quartodoc/tests/test_renderers.py @@ -409,3 +409,26 @@ def test_render_doc_summarize_toc_table_vs_description_list(snapshot): ) assert snapshot == indented_sections(Table=res_table, DescriptionList=res_list) + + +def test_render_full_numpydoc(snapshot, renderer): + """Test rendering a function with comprehensive numpydoc sections.""" + package = "quartodoc.tests.example_docstring_full" + auto = Auto(name="full_numpydoc_function", package=package) + bp = blueprint(auto, parser="numpy") + + res = renderer.render(bp) + + assert res == snapshot + + +def test_render_full_numpydoc_description_list(snapshot): + """Test rendering a function with comprehensive numpydoc sections using description list style.""" + renderer = MdRenderer(table_style="description-list") + package = "quartodoc.tests.example_docstring_full" + auto = Auto(name="full_numpydoc_function", package=package) + bp = blueprint(auto, parser="numpy") + + res = renderer.render(bp) + + assert res == snapshot From a4025b48031cccb604b41320ff6132be28c8fe53 Mon Sep 17 00:00:00 2001 From: Michael Chow Date: Fri, 5 Dec 2025 15:57:34 -0500 Subject: [PATCH 2/3] add missing example module --- quartodoc/tests/example_docstring_full.py | 68 +++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 quartodoc/tests/example_docstring_full.py diff --git a/quartodoc/tests/example_docstring_full.py b/quartodoc/tests/example_docstring_full.py new file mode 100644 index 00000000..e99fd466 --- /dev/null +++ b/quartodoc/tests/example_docstring_full.py @@ -0,0 +1,68 @@ +"""Module with comprehensive numpydoc examples.""" + + +def full_numpydoc_function(x, y=None, *args, option=False, **kwargs): + """A one-line summary. + + An extended summary that provides more detail about what this + function does. This demonstrates the full range of numpydoc + sections that are supported. + + Parameters + ---------- + x : int + The first parameter. + y : str, optional + The second parameter. + *args : float + Variable positional arguments. + option : bool, default False + A keyword-only parameter. + **kwargs : dict + Variable keyword arguments. + + Returns + ------- + result : int + The computed result with name and type. + list + A secondary return value with only type. + + Yields + ------ + value : str + Generated string values. + + Raises + ------ + ValueError + If x is negative. + TypeError + If y is not a string. + + See Also + -------- + other_function : Related functionality. + module.another_function : Another related function. + + Notes + ----- + I am a note. + + References + ---------- + .. [1] Author Name, "Paper Title", Journal, 2024. TODO + + Examples + -------- + Basic usage: + + >>> full_numpydoc_function(1, "test") + (42, [1, 2, 3]) + + With optional parameters: + + >>> full_numpydoc_function(1, option=True) + (42, []) + """ + pass From dd83a9db23f18618615713ff32819c3ca8557c25 Mon Sep 17 00:00:00 2001 From: Michael Chow Date: Fri, 5 Dec 2025 22:41:35 -0500 Subject: [PATCH 3/3] feat: signature_summary for signature in api index --- quartodoc/builder/blueprint.py | 1 + quartodoc/layout.py | 5 ++ quartodoc/renderers/md_renderer.py | 100 +++++++++++++++++++++++++- quartodoc/tests/test_renderers.py | 109 +++++++++++++++++++++++++++++ 4 files changed, 213 insertions(+), 2 deletions(-) diff --git a/quartodoc/builder/blueprint.py b/quartodoc/builder/blueprint.py index e796a894..24a9ab24 100644 --- a/quartodoc/builder/blueprint.py +++ b/quartodoc/builder/blueprint.py @@ -356,6 +356,7 @@ def enter(self, el: Auto): children, flat=is_flat, signature_name=el.signature_name, + signature_summary=el.signature_summary, ) def _fetch_members(self, el: Auto, obj: dc.Object | dc.Alias): diff --git a/quartodoc/layout.py b/quartodoc/layout.py index dcd8db62..d4996b99 100644 --- a/quartodoc/layout.py +++ b/quartodoc/layout.py @@ -202,12 +202,14 @@ class ChoicesChildren(Enum): SignatureOptions = Literal["full", "short", "relative"] +SignatureSummaryOptions = Literal["full", "parens", None] class AutoOptions(_Base): """Options available for Auto content layout element.""" signature_name: SignatureOptions = "relative" + signature_summary: SignatureSummaryOptions = None members: Optional[list[str]] = None include_private: bool = False include_imports: bool = False @@ -344,6 +346,7 @@ class Doc(_Docable): obj: Union[dc.Object, dc.Alias] anchor: str signature_name: SignatureOptions = "relative" + signature_summary: SignatureSummaryOptions = None class Config: arbitrary_types_allowed = True @@ -358,6 +361,7 @@ def from_griffe( anchor: str = None, flat: bool = False, signature_name: str = "relative", + signature_summary: str = None, ): if members is None: members = [] @@ -370,6 +374,7 @@ def from_griffe( "obj": obj, "anchor": anchor, "signature_name": signature_name, + "signature_summary": signature_summary, } if kind == "function": diff --git a/quartodoc/renderers/md_renderer.py b/quartodoc/renderers/md_renderer.py index af67ab4e..e070db0f 100644 --- a/quartodoc/renderers/md_renderer.py +++ b/quartodoc/renderers/md_renderer.py @@ -379,6 +379,95 @@ def signature( name = self._fetch_object_dispname(source or el) return f"`{name}`" + @dispatch + def _signature_summary( + self, + el: layout.Doc, + mode: Literal["full", "parens"] + ) -> str: + """Generate a short signature summary for index/TOC tables. + + Parameters + ---------- + el : layout.Doc + The documented element + mode : str + Either "full" (show all params) or "parens" (just empty parens) + + Returns + ------- + str + Signature summary like "(path, [object_name, parser, ...])" or "()" + """ + if mode == "parens": + return "()" + + obj = el.obj + + # Get parameters based on object type + if obj.is_class: + if "__init__" in obj.members: + params = self._fetch_method_parameters(obj.members["__init__"]) + else: + return "()" + elif obj.is_function: + params = self._fetch_method_parameters(obj) + else: + return "" # Attributes and modules don't have signatures + + if not params: + return "()" + + # Build parameter list + required_params = [] + optional_params = [] + + for param in params: + # Skip self/cls + if param.name in {"self", "cls"}: + continue + + # Format parameter name + if param.kind == dc.ParameterKind.var_positional: + name = "*" + param.name + elif param.kind == dc.ParameterKind.var_keyword: + name = "**" + param.name + else: + name = param.name + + # Categorize as required or optional + if param.required: + required_params.append(name) + else: + optional_params.append(name) + + # Build final signature with truncation + MAX_PARAMS = 3 # Show max 3 params before truncating + + parts = [] + + # Add required params (up to MAX_PARAMS) + if required_params: + if len(required_params) > MAX_PARAMS: + parts.extend(required_params[:MAX_PARAMS-1]) + parts.append("...") + else: + parts.extend(required_params) + + # Add optional params in brackets + if optional_params: + if parts and len(parts) >= MAX_PARAMS: + # Already at limit, just show [...] + parts.append("[...]") + elif len(optional_params) > 2: + # Show first optional then ... + parts.append(f"[{optional_params[0]}, ...]") + else: + # Show all optional params + parts.append("[" + ", ".join(optional_params) + "]") + + return "(" + ", ".join(parts) + ")" + @dispatch def render_header(self, el: layout.Doc) -> str: """Render the header of a docstring, including any anchors.""" @@ -913,13 +1002,20 @@ def summarize(self, el: layout.Interlaced, *args, **kwargs): def summarize( self, el: layout.Doc, path: Optional[str] = None, shorten: bool = False ): + # Build display name with signature if configured + # Only show signature on index (path is not None), not on TOC tables (path is None) + display_name = el.name + if el.signature_summary and path is not None: + sig_summary = self._signature_summary(el, el.signature_summary) + display_name = f"{el.name}{sig_summary}" + # When path is None, we're being called directly from render() for TOC # When path is provided, we're being called from Page for index if path is None: - link = f"[{el.name}](#{el.anchor})" + link = f"[{display_name}](#{el.anchor})" else: # TODO: assumes that files end with .qmd - link = f"[{el.name}]({path}.qmd#{el.anchor})" + link = f"[{display_name}]({path}.qmd#{el.anchor})" description = self.summarize(el.obj) return self._summary_row(link, description) diff --git a/quartodoc/tests/test_renderers.py b/quartodoc/tests/test_renderers.py index c5d85734..dbe4b3de 100644 --- a/quartodoc/tests/test_renderers.py +++ b/quartodoc/tests/test_renderers.py @@ -432,3 +432,112 @@ def test_render_full_numpydoc_description_list(snapshot): res = renderer.render(bp) assert res == snapshot + + +# Signature summary tests ------------------------------------------------------ + + +def test_signature_summary_parens_mode(renderer): + """Test that 'parens' mode returns just ()""" + package = "quartodoc.tests.example_signature" + auto = Auto(name="no_annotations", package=package) + bp = blueprint(auto) + + result = renderer._signature_summary(bp, "parens") + assert result == "()" + + +def test_signature_summary_full_mode_simple(renderer): + """Test 'full' mode with a simple function signature""" + package = "quartodoc.tests.example_signature" + # pos_only has signature: (x, /, a, b=2) + auto = Auto(name="pos_only", package=package) + bp = blueprint(auto) + + result = renderer._signature_summary(bp, "full") + # x and a are required, b is optional + assert result == "(x, a, [b])" + + +def test_signature_summary_full_mode_mixed_params(renderer): + """Test 'full' mode with mixed required and optional parameters""" + package = "quartodoc.tests.example_signature" + # no_annotations has: (a, b=1, *args, c, d=2, **kwargs) + auto = Auto(name="no_annotations", package=package) + bp = blueprint(auto) + + result = renderer._signature_summary(bp, "full") + # a and c are required, b, d, *args, **kwargs are optional + assert result == "(a, c, [b, ...])" + + +def test_signature_summary_full_mode_var_args(renderer): + """Test 'full' mode with *args and **kwargs""" + package = "quartodoc.tests.example_signature" + # early_args has: (x, *args, a, b=2, **kwargs) + auto = Auto(name="early_args", package=package) + bp = blueprint(auto) + + result = renderer._signature_summary(bp, "full") + # x and a are required + assert result == "(x, a, [*args, ...])" + + +def test_signature_summary_no_params(renderer): + """Test signature summary with function that has no parameters""" + package = "quartodoc.tests.example_signature" + auto = Auto(name="C", package=package) # Class with no __init__ + bp = blueprint(auto) + + result = renderer._signature_summary(bp, "full") + assert result == "()" + + +def test_summarize_with_signature_summary_full(renderer): + """Test that summarize includes signature when signature_summary='full'""" + package = "quartodoc.tests.example_signature" + auto = Auto(name="pos_only", package=package, signature_summary="full") + bp = blueprint(auto) + + result = renderer.summarize(bp, path="test") + # Should include the signature in the link text + assert "pos_only(x, a, [b])" in result.link + + +def test_summarize_with_signature_summary_parens(renderer): + """Test that summarize includes () when signature_summary='parens'""" + package = "quartodoc.tests.example_signature" + auto = Auto(name="pos_only", package=package, signature_summary="parens") + bp = blueprint(auto) + + result = renderer.summarize(bp, path="test") + # Should include () in the link text + assert "pos_only()" in result.link + + +def test_summarize_without_signature_summary(renderer): + """Test that summarize doesn't include signature when signature_summary is None""" + package = "quartodoc.tests.example_signature" + auto = Auto(name="pos_only", package=package) # No signature_summary + bp = blueprint(auto) + + result = renderer.summarize(bp, path="test") + # Should NOT include any parentheses in the link + assert "pos_only]" in result.link # Just the closing bracket of markdown link + assert "pos_only(" not in result.link + + +def test_summarize_signature_only_on_index_not_toc(renderer): + """Test that signatures appear on index but not on TOC tables""" + package = "quartodoc.tests.example_signature" + auto = Auto(name="pos_only", package=package, signature_summary="full") + bp = blueprint(auto) + + # Test index (path is provided) - should include signature + result_index = renderer.summarize(bp, path="test") + assert "pos_only(x, a, [b])" in result_index.link + + # Test TOC (path is None) - should NOT include signature + result_toc = renderer.summarize(bp, path=None) + assert "pos_only]" in result_toc.link + assert "pos_only(" not in result_toc.link