Repository: tox-dev/sphinx-autodoc-typehints
Pull request #464 (2024-06-22 01:46:27)

Fix for Issue #384: typehints_defaults = "braces-after" fails for a multiline `:param:` entry
Here is another quick fix.

The fix advances the line to the last line of the last `:param:` paragraph by detecting either that the next line is empty or has no indentation and placing the default at the end of the last nonempty line.

I've read somewhere else that rst docstring spec does not explicitly mention anything about multiline argument descriptions, but I understand it is widely accepted to use hanging indentation.

@gaborbernat & @hoodmane - One thing I'm not sure of is the handling of a multi-paragraph `:param:` description. It is likely rare to document in such a way. But when it does, should the default be placed at the end of the first or the last paragraph? 

I've gone with the latter for now, but I can see myself preferring the default to appear on the first paragraph when it happens. (2nd+ paragraphs for in-depth descriptions.) Ultimately, we can add a new option for it, but a default behavior must be set regardless. The current on-the-last-paragraph implementation is simply the last one I thought of, and I can go either way. Let me know what you guys think, and I'd be happy to modify to suit your preference.

gaborbernat: CI broken 😎
tikuma-lsuhsc: @gaborbernat - Working on it... too many nesting? y'all are picky!/s

Patch:
diff --git a/src/sphinx_autodoc_typehints/__init__.py b/src/sphinx_autodoc_typehints/__init__.py
index 837598c..d5b062a 100644
--- a/src/sphinx_autodoc_typehints/__init__.py
+++ b/src/sphinx_autodoc_typehints/__init__.py
@@ -716,7 +716,7 @@ def _inject_types_to_docstring(  # noqa: PLR0913, PLR0917
         _inject_rtype(type_hints, original_obj, app, what, name, lines)
 
 
-def _inject_signature(  # noqa: C901
+def _inject_signature(
     type_hints: dict[str, Any],
     signature: inspect.Signature,
     app: Sphinx,
@@ -754,14 +754,33 @@ def _inject_signature(  # noqa: C901
             if app.config.typehints_defaults:
                 formatted_default = format_default(app, default, annotation is not None)
                 if formatted_default:
-                    if app.config.typehints_defaults.endswith("after"):
-                        lines[insert_index] += formatted_default
-                    else:  # add to last param doc line
-                        type_annotation += formatted_default
+                    type_annotation = _append_default(app, lines, insert_index, type_annotation, formatted_default)
 
             lines.insert(insert_index, type_annotation)
 
 
+def _append_default(
+    app: Sphinx, lines: list[str], insert_index: int, type_annotation: str, formatted_default: str
+) -> str:
+    if app.config.typehints_defaults.endswith("after"):
+        # advance the index to the end of the :param: paragraphs
+        # (terminated by a line with no indentation)
+        # append default to the last nonempty line
+        nlines = len(lines)
+        next_index = insert_index + 1
+        append_index = insert_index  # last nonempty line
+        while next_index < nlines and (not lines[next_index] or lines[next_index].startswith(" ")):
+            if lines[next_index]:
+                append_index = next_index
+            next_index += 1
+        lines[append_index] += formatted_default
+
+    else:  # add to last param doc line
+        type_annotation += formatted_default
+
+    return type_annotation
+
+
 @dataclass
 class InsertIndexInfo:
     insert_index: int


Test patch:
diff --git a/tests/test_integration_issue_384.py b/tests/test_integration_issue_384.py
new file mode 100644
index 0000000..66815e6
--- /dev/null
+++ b/tests/test_integration_issue_384.py
@@ -0,0 +1,119 @@
+from __future__ import annotations
+
+import re
+import sys
+from pathlib import Path
+from textwrap import dedent, indent
+from typing import TYPE_CHECKING, Any, Callable, NewType, TypeVar  # no type comments
+
+import pytest
+
+if TYPE_CHECKING:
+    from io import StringIO
+
+    from sphinx.testing.util import SphinxTestApp
+
+T = TypeVar("T")
+W = NewType("W", str)
+
+
+def expected(expected: str, **options: dict[str, Any]) -> Callable[[T], T]:
+    def dec(val: T) -> T:
+        val.EXPECTED = expected
+        val.OPTIONS = options
+        return val
+
+    return dec
+
+
+def warns(pattern: str) -> Callable[[T], T]:
+    def dec(val: T) -> T:
+        val.WARNING = pattern
+        return val
+
+    return dec
+
+
+@expected(
+    """\
+mod.function(x=5, y=10, z=15)
+
+   Function docstring.
+
+   Parameters:
+      * **x** ("int") -- optional specifier line 2 (default: "5")
+
+      * **y** ("int") --
+
+        another optional line 4
+
+        second paragraph for y (default: "10")
+
+      * **z** ("int") -- yet another optional s line 6 (default: "15")
+
+   Returns:
+      something
+
+   Return type:
+      bytes
+
+""",
+)
+def function(x: int = 5, y: int = 10, z: int = 15) -> str:  # noqa: ARG001
+    """
+    Function docstring.
+
+    :param x: optional specifier
+              line 2
+    :param y: another optional
+              line 4
+
+              second paragraph for y
+
+    :param z: yet another optional s
+              line 6
+
+    :return: something
+    :rtype: bytes
+    """
+
+
+# Config settings for each test run.
+# Config Name: Sphinx Options as Dict.
+configs = {"default_conf": {"typehints_defaults": "braces-after"}}
+
+
+@pytest.mark.parametrize("val", [x for x in globals().values() if hasattr(x, "EXPECTED")])
+@pytest.mark.parametrize("conf_run", list(configs.keys()))
+@pytest.mark.sphinx("text", testroot="integration")
+def test_integration(
+    app: SphinxTestApp, status: StringIO, warning: StringIO, monkeypatch: pytest.MonkeyPatch, val: Any, conf_run: str
+) -> None:
+    template = ".. autofunction:: mod.{}"
+
+    (Path(app.srcdir) / "index.rst").write_text(template.format(val.__name__))
+    app.config.__dict__.update(configs[conf_run])
+    app.config.__dict__.update(val.OPTIONS)
+    monkeypatch.setitem(sys.modules, "mod", sys.modules[__name__])
+    app.build()
+    assert "build succeeded" in status.getvalue()  # Build succeeded
+
+    regexp = getattr(val, "WARNING", None)
+    value = warning.getvalue().strip()
+    if regexp:
+        msg = f"Regex pattern did not match.\n Regex: {regexp!r}\n Input: {value!r}"
+        assert re.search(regexp, value), msg
+    else:
+        assert not value
+
+    result = (Path(app.srcdir) / "_build/text/index.txt").read_text()
+
+    expected = val.EXPECTED
+    if sys.version_info < (3, 10):
+        expected = expected.replace("NewType", "NewType()")
+    try:
+        assert result.strip() == dedent(expected).strip()
+    except Exception:
+        indented = indent(f'"""\n{result}\n"""', " " * 4)
+        print(f"@expected(\n{indented}\n)\n")  # noqa: T201
+        raise