Repository: tox-dev/sphinx-autodoc-typehints (python)

Issue:
Invalid spacing between `:rtype:` and following directives
In the 1.21.0 release, the position of the emitted `:rtype:` field seems to have changed so that it is now earlier in the docstring, rather than reliably being at the end.  This is fine, except when the place the injector chooses to place it is immediately before a `.. <whatever>::` directive. There doesn't seem to reliable be a blank line inserted after it, and if not, Sphinx complains that the field list ends unexpectedly.

For example, given this Python file `tmp/__init__.py`:
```python
def my_function() -> None:
    """
    .. rubric:: Notes

    Hello, world.
    """
```
and this Sphinx `conf.py`:
```python
project = 'tmp'
extensions = [
    "sphinx.ext.autodoc",
    "sphinx_autodoc_typehints",
]
```
running Sphinx's `make html` gives:
```text
Running Sphinx v6.1.3
loading pickled environment... done
building [mo]: targets for 0 po files that are out of date
writing output...
building [html]: targets for 0 source files that are out of date
updating environment: 0 added, 1 changed, 0 removed
reading sources... [100%] index
/Users/jake/tmp/autodoc-typehints/pkg/tmp/__init__.py:docstring of tmp.my_function:1: WARNING: Field list ends without a blank line; unexpected unindent.
looking for now-outdated files... none found
pickling environment... done
checking consistency... done
preparing documents... done
writing output... [100%] index
generating indices... genindex py-modindex done
writing additional pages... search done
copying static files... done
copying extra files... done
dumping search index in English (code: en)... done
dumping object inventory... done
build succeeded, 1 warning.
```

The docstring enters `_inject_types_to_docstring` as
```text
.. rubric:: Notes

Hello, world.

```
and is modified to
```text
:rtype: :py:obj:`None`
.. rubric:: Notes

Hello, world.

```

This also happens if there's any regular text before the `.. rubric:: notes` as well (which is a bit more of a realistic scenario).

Pull request description:
Fix spacing between `:rtype:` and directives
If the `:rtype:` field is being inserted immediately before a directive, we must ensure there is a blank line between the two to avoid a Sphinx warning.

Given that #290, which added this behaviour, already added a suitable test that emits a warning that doesn't fail the suite, I wasn't clear on how best to add a further test for this.

Fix #293.

Patch:
diff --git a/src/sphinx_autodoc_typehints/__init__.py b/src/sphinx_autodoc_typehints/__init__.py
index 997c93e..4551e25 100644
--- a/src/sphinx_autodoc_typehints/__init__.py
+++ b/src/sphinx_autodoc_typehints/__init__.py
@@ -588,74 +588,100 @@ def _inject_types_to_docstring(
     name: str,
     lines: list[str],
 ) -> None:
-
     if signature is not None:
-        for arg_name in signature.parameters:
-            annotation = type_hints.get(arg_name, None)
+        _inject_signature(type_hints, signature, app, lines)
+    if "return" in type_hints:
+        _inject_rtype(type_hints, original_obj, app, what, name, lines)
 
-            default = signature.parameters[arg_name].default
 
-            if arg_name.endswith("_"):
-                arg_name = f"{arg_name[:-1]}\\_"
+def _inject_signature(
+    type_hints: dict[str, Any],
+    signature: inspect.Signature,
+    app: Sphinx,
+    lines: list[str],
+) -> None:
+    for arg_name in signature.parameters:
+        annotation = type_hints.get(arg_name, None)
 
-            insert_index = None
-            for at, line in enumerate(lines):
-                if _line_is_param_line_for_arg(line, arg_name):
-                    # Get the arg_name from the doc to match up for type in case it has a star prefix.
-                    # Line is in the correct format so this is guaranteed to return tuple[str, str].
-                    _, arg_name = _get_sphinx_line_keyword_and_argument(line)  # type: ignore[assignment, misc]
-                    insert_index = at
-                    break
-
-            if annotation is not None and insert_index is None and app.config.always_document_param_types:
-                lines.append(f":param {arg_name}:")
-                insert_index = len(lines)
-
-            if insert_index is not None:
-                if annotation is None:
-                    type_annotation = f":type {arg_name}: "
-                else:
-                    formatted_annotation = format_annotation(annotation, app.config)
-                    type_annotation = f":type {arg_name}: {formatted_annotation}"
-
-                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
-
-                lines.insert(insert_index, type_annotation)
-
-    if "return" in type_hints and not inspect.isclass(original_obj) and not inspect.isdatadescriptor(original_obj):
-        if what == "method" and name.endswith(".__init__"):  # avoid adding a return type for data class __init__
-            return
-        formatted_annotation = format_annotation(type_hints["return"], app.config)
-        insert_index = len(lines)
+        default = signature.parameters[arg_name].default
+
+        if arg_name.endswith("_"):
+            arg_name = f"{arg_name[:-1]}\\_"
+
+        insert_index = None
         for at, line in enumerate(lines):
-            if line.startswith(":rtype:"):
-                insert_index = None
-                break
-            elif line.startswith(":return:") or line.startswith(":returns:"):
-                if " -- " in line and not app.config.typehints_use_rtype:
-                    insert_index = None
-                    break
-                insert_index = at
-            elif line.startswith(".."):
-                # Make sure that rtype comes before any usage or examples section
+            if _line_is_param_line_for_arg(line, arg_name):
+                # Get the arg_name from the doc to match up for type in case it has a star prefix.
+                # Line is in the correct format so this is guaranteed to return tuple[str, str].
+                _, arg_name = _get_sphinx_line_keyword_and_argument(line)  # type: ignore[assignment, misc]
                 insert_index = at
                 break
 
-        if insert_index is not None and app.config.typehints_document_rtype:
-            if insert_index == len(lines):  # ensure that :rtype: doesn't get joined with a paragraph of text
-                lines.append("")
-                insert_index += 1
-            if app.config.typehints_use_rtype or insert_index == len(lines):
-                lines.insert(insert_index, f":rtype: {formatted_annotation}")
+        if annotation is not None and insert_index is None and app.config.always_document_param_types:
+            lines.append(f":param {arg_name}:")
+            insert_index = len(lines)
+
+        if insert_index is not None:
+            if annotation is None:
+                type_annotation = f":type {arg_name}: "
+            else:
+                formatted_annotation = format_annotation(annotation, app.config)
+                type_annotation = f":type {arg_name}: {formatted_annotation}"
+
+            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
+
+            lines.insert(insert_index, type_annotation)
+
+
+def _inject_rtype(
+    type_hints: dict[str, Any],
+    original_obj: Any,
+    app: Sphinx,
+    what: str,
+    name: str,
+    lines: list[str],
+) -> None:
+    if inspect.isclass(original_obj) or inspect.isdatadescriptor(original_obj):
+        return
+    if what == "method" and name.endswith(".__init__"):  # avoid adding a return type for data class __init__
+        return
+    formatted_annotation = format_annotation(type_hints["return"], app.config)
+    insert_index: int | None = len(lines)
+    extra_newline = False
+    for at, line in enumerate(lines):
+        if line.startswith(":rtype:"):
+            insert_index = None
+            break
+        if line.startswith(":return:") or line.startswith(":returns:"):
+            if " -- " in line and not app.config.typehints_use_rtype:
+                insert_index = None
+                break
+            insert_index = at
+        elif line.startswith(".."):
+            # Make sure that rtype comes before any usage or examples section, with a blank line between.
+            insert_index = at
+            extra_newline = True
+            break
+
+    if insert_index is not None and app.config.typehints_document_rtype:
+        if insert_index == len(lines):  # ensure that :rtype: doesn't get joined with a paragraph of text
+            lines.append("")
+            insert_index += 1
+        if app.config.typehints_use_rtype or insert_index == len(lines):
+            line = f":rtype: {formatted_annotation}"
+            if extra_newline:
+                lines[insert_index:insert_index] = [line, "\n"]
             else:
-                line = lines[insert_index]
-                lines[insert_index] = f":return: {formatted_annotation} --{line[line.find(' '):]}"
+                lines.insert(insert_index, line)
+        else:
+            line = lines[insert_index]
+            lines[insert_index] = f":return: {formatted_annotation} --{line[line.find(' '):]}"
 
 
 def validate_config(app: Sphinx, env: BuildEnvironment, docnames: list[str]) -> None:  # noqa: U100


Test patch:
diff --git a/tests/test_sphinx_autodoc_typehints.py b/tests/test_sphinx_autodoc_typehints.py
index 6d2728b..3be4638 100644
--- a/tests/test_sphinx_autodoc_typehints.py
+++ b/tests/test_sphinx_autodoc_typehints.py
@@ -470,6 +470,8 @@ def test_sphinx_output(
     # There should be a warning about an unresolved forward reference
     warnings = warning.getvalue().strip()
     assert "Cannot resolve forward reference in type annotations of " in warnings, warnings
+    # There should not be warnings about incorrect block endings.
+    assert "Field list ends without a blank line; unexpected unindent." not in warnings, warnings
 
     format_args = {}
     for indentation_level in range(2):