Skip to content

Commit 7b2f213

Browse files
authored
Improvements to the location of the return type (#300)
* Improvements to the location of the return type The current strategy to place the rtype is still not fully satisfactory on my own documentation. I think this improves it to avoid any need for fudging. 1. If there is an existing :rtype: don't add another 2. If there is a :returns: anywhere, either add directly before it or modify the :returns: line as appropriate 3. If there is a block of :param: documentation, add directly after this 4. If there is a .. directive, add before the directive 5. Add at the end Step 4 could be refined further, we would really only like to break at directives that introduce headings * Use docutils tree to figure out where params end * Fix changelog * Fix test * Update comment * Update comment
1 parent 4b6897e commit 7b2f213

File tree

6 files changed

+293
-31
lines changed

6 files changed

+293
-31
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Changelog
22

3-
## 1.21.2
3+
## 1.21.3
44

55
- Use format_annotation to render class attribute type annotations
66

src/sphinx_autodoc_typehints/__init__.py

Lines changed: 112 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,14 @@
66
import textwrap
77
import types
88
from ast import FunctionDef, Module, stmt
9+
from dataclasses import dataclass
910
from functools import lru_cache
1011
from typing import Any, AnyStr, Callable, ForwardRef, NewType, TypeVar, get_type_hints
1112

13+
from docutils.frontend import OptionParser
14+
from docutils.nodes import Node
15+
from docutils.parsers.rst import Parser as RstParser
16+
from docutils.utils import new_document
1217
from sphinx.application import Sphinx
1318
from sphinx.config import Config
1419
from sphinx.environment import BuildEnvironment
@@ -641,6 +646,89 @@ def _inject_signature(
641646
lines.insert(insert_index, type_annotation)
642647

643648

649+
@dataclass
650+
class InsertIndexInfo:
651+
insert_index: int
652+
found_param: bool = False
653+
found_return: bool = False
654+
found_directive: bool = False
655+
656+
657+
# Sphinx allows so many synonyms...
658+
# See sphinx.domains.python.PyObject
659+
PARAM_SYNONYMS = ("param ", "parameter ", "arg ", "argument ", "keyword ", "kwarg ", "kwparam ")
660+
661+
662+
def line_before_node(node: Node) -> int:
663+
line = node.line
664+
assert line
665+
return line - 2
666+
667+
668+
def tag_name(node: Node) -> str:
669+
return node.tagname # type:ignore[attr-defined,no-any-return] # noqa: SC200
670+
671+
672+
def get_insert_index(app: Sphinx, lines: list[str]) -> InsertIndexInfo | None:
673+
# 1. If there is an existing :rtype: anywhere, don't insert anything.
674+
if any(line.startswith(":rtype:") for line in lines):
675+
return None
676+
677+
# 2. If there is a :returns: anywhere, either modify that line or insert
678+
# just before it.
679+
for at, line in enumerate(lines):
680+
if line.startswith((":return:", ":returns:")):
681+
return InsertIndexInfo(insert_index=at, found_return=True)
682+
683+
# 3. Insert after the parameters.
684+
# To find the parameters, parse as a docutils tree.
685+
settings = OptionParser(components=(RstParser,)).get_default_values()
686+
settings.env = app.env
687+
doc = new_document("", settings=settings)
688+
RstParser().parse("\n".join(lines), doc)
689+
690+
# Find a top level child which is a field_list that contains a field whose
691+
# name starts with one of the PARAM_SYNONYMS. This is the parameter list. We
692+
# hope there is at most of these.
693+
for idx, child in enumerate(doc.children):
694+
if tag_name(child) != "field_list":
695+
continue
696+
697+
if any(c.children[0].astext().startswith(PARAM_SYNONYMS) for c in child.children):
698+
idx = idx
699+
break
700+
else:
701+
idx = -1
702+
703+
if idx == -1:
704+
# No parameters
705+
pass
706+
elif idx + 1 < len(doc.children):
707+
# Unfortunately docutils only tells us the line numbers that nodes start on,
708+
# not the range (boo!). So insert before the line before the next sibling.
709+
at = line_before_node(doc.children[idx + 1])
710+
return InsertIndexInfo(insert_index=at, found_param=True)
711+
else:
712+
# No next sibling, insert at end
713+
return InsertIndexInfo(insert_index=len(lines), found_param=True)
714+
715+
# 4. Insert before examples
716+
# TODO: Maybe adjust which tags to insert ahead of
717+
for idx, child in enumerate(doc.children):
718+
if tag_name(child) not in ["literal_block", "paragraph", "field_list"]:
719+
idx = idx
720+
break
721+
else:
722+
idx = -1
723+
724+
if idx != -1:
725+
at = line_before_node(doc.children[idx])
726+
return InsertIndexInfo(insert_index=at, found_directive=True)
727+
728+
# 5. Otherwise, insert at end
729+
return InsertIndexInfo(insert_index=len(lines))
730+
731+
644732
def _inject_rtype(
645733
type_hints: dict[str, Any],
646734
original_obj: Any,
@@ -653,37 +741,32 @@ def _inject_rtype(
653741
return
654742
if what == "method" and name.endswith(".__init__"): # avoid adding a return type for data class __init__
655743
return
744+
if not app.config.typehints_document_rtype:
745+
return
746+
747+
r = get_insert_index(app, lines)
748+
if r is None:
749+
return
750+
751+
insert_index = r.insert_index
752+
753+
if not app.config.typehints_use_rtype and r.found_return and " -- " in lines[insert_index]:
754+
return
755+
656756
formatted_annotation = format_annotation(type_hints["return"], app.config)
657-
insert_index: int | None = len(lines)
658-
extra_newline = False
659-
for at, line in enumerate(lines):
660-
if line.startswith(":rtype:"):
661-
insert_index = None
662-
break
663-
if line.startswith(":return:") or line.startswith(":returns:"):
664-
if " -- " in line and not app.config.typehints_use_rtype:
665-
insert_index = None
666-
break
667-
insert_index = at
668-
elif line.startswith(".."):
669-
# Make sure that rtype comes before any usage or examples section, with a blank line between.
670-
insert_index = at
671-
extra_newline = True
672-
break
673757

674-
if insert_index is not None and app.config.typehints_document_rtype:
675-
if insert_index == len(lines): # ensure that :rtype: doesn't get joined with a paragraph of text
676-
lines.append("")
677-
insert_index += 1
678-
if app.config.typehints_use_rtype or insert_index == len(lines):
679-
line = f":rtype: {formatted_annotation}"
680-
if extra_newline:
681-
lines[insert_index:insert_index] = [line, "\n"]
682-
else:
683-
lines.insert(insert_index, line)
684-
else:
685-
line = lines[insert_index]
686-
lines[insert_index] = f":return: {formatted_annotation} --{line[line.find(' '):]}"
758+
if insert_index == len(lines) and not r.found_param:
759+
# ensure that :rtype: doesn't get joined with a paragraph of text
760+
lines.append("")
761+
insert_index += 1
762+
if app.config.typehints_use_rtype or not r.found_return:
763+
line = f":rtype: {formatted_annotation}"
764+
lines.insert(insert_index, line)
765+
if r.found_directive:
766+
lines.insert(insert_index + 1, "")
767+
else:
768+
line = lines[insert_index]
769+
lines[insert_index] = f":return: {formatted_annotation} --{line[line.find(' '):]}"
687770

688771

689772
def validate_config(app: Sphinx, env: BuildEnvironment, docnames: list[str]) -> None: # noqa: U100

tests/roots/test-dummy/dummy_module.py

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ def function_with_typehint_comment(
164164
Function docstring.
165165
166166
:parameter x: foo
167-
:param y: bar
167+
:parameter y: bar
168168
"""
169169

170170

@@ -317,3 +317,81 @@ class TestClassAttributeDocs:
317317

318318
code: Union[CodeType, None]
319319
"""An attribute"""
320+
321+
322+
def func_with_examples_and_returns_after() -> int:
323+
"""
324+
f does the thing.
325+
326+
Examples
327+
--------
328+
329+
Here is an example
330+
331+
:returns: The index of the widget
332+
"""
333+
334+
335+
def func_with_parameters_and_stuff_after(a: int, b: int) -> int: # noqa: U100
336+
"""A func
337+
338+
:param a: a tells us something
339+
:param b: b tells us something
340+
341+
More info about the function here.
342+
"""
343+
344+
345+
def func_with_rtype_in_weird_spot(a: int, b: int) -> int: # noqa: U100
346+
"""A func
347+
348+
:param a: a tells us something
349+
:param b: b tells us something
350+
351+
Examples
352+
--------
353+
354+
Here is an example
355+
356+
:returns: The index of the widget
357+
358+
More info about the function here.
359+
360+
:rtype: int
361+
"""
362+
363+
364+
def empty_line_between_parameters(a: int, b: int) -> int: # noqa: U100
365+
"""A func
366+
367+
:param a: One of the following possibilities:
368+
369+
- a
370+
371+
- b
372+
373+
- c
374+
375+
:param b: Whatever else we have to say.
376+
377+
There is more of it And here too
378+
379+
More stuff here.
380+
"""
381+
382+
383+
def func_with_code_block() -> int:
384+
"""
385+
A docstring.
386+
387+
You would say:
388+
389+
.. code-block::
390+
391+
print("some python code here")
392+
393+
394+
.. rubric:: Examples
395+
396+
Here are a couple of examples of how to use this function.
397+
"""

tests/roots/test-dummy/index.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,13 @@ Dummy Module
4545

4646
.. autoclass:: dummy_module.TestClassAttributeDocs
4747
:members:
48+
49+
.. autofunction:: dummy_module.func_with_examples_and_returns_after
50+
51+
.. autofunction:: dummy_module.func_with_parameters_and_stuff_after
52+
53+
.. autofunction:: dummy_module.func_with_rtype_in_weird_spot
54+
55+
.. autofunction:: dummy_module.empty_line_between_parameters
56+
57+
.. autofunction:: dummy_module.func_with_code_block

tests/test_sphinx_autodoc_typehints.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -790,6 +790,96 @@ class dummy_module.TestClassAttributeDocs
790790
code: "Optional"["CodeType"]
791791
792792
An attribute
793+
794+
dummy_module.func_with_examples_and_returns_after()
795+
796+
f does the thing.
797+
798+
-[ Examples ]-
799+
800+
Here is an example
801+
802+
Return type:
803+
"int"
804+
805+
Returns:
806+
The index of the widget
807+
808+
dummy_module.func_with_parameters_and_stuff_after(a, b)
809+
810+
A func
811+
812+
Parameters:
813+
* **a** ("int") -- a tells us something
814+
815+
* **b** ("int") -- b tells us something
816+
817+
Return type:
818+
"int"
819+
820+
More info about the function here.
821+
822+
dummy_module.func_with_rtype_in_weird_spot(a, b)
823+
824+
A func
825+
826+
Parameters:
827+
* **a** ("int") -- a tells us something
828+
829+
* **b** ("int") -- b tells us something
830+
831+
-[ Examples ]-
832+
833+
Here is an example
834+
835+
Returns:
836+
The index of the widget
837+
838+
More info about the function here.
839+
840+
Return type:
841+
int
842+
843+
dummy_module.empty_line_between_parameters(a, b)
844+
845+
A func
846+
847+
Parameters:
848+
* **a** ("int") --
849+
850+
One of the following possibilities:
851+
852+
* a
853+
854+
* b
855+
856+
* c
857+
858+
* **b** ("int") --
859+
860+
Whatever else we have to say.
861+
862+
There is more of it And here too
863+
864+
Return type:
865+
"int"
866+
867+
More stuff here.
868+
869+
dummy_module.func_with_code_block()
870+
871+
A docstring.
872+
873+
You would say:
874+
875+
print("some python code here")
876+
877+
Return type:
878+
"int"
879+
880+
-[ Examples ]-
881+
882+
Here are a couple of examples of how to use this function.
793883
"""
794884
expected_contents = dedent(expected_contents).format(**format_args).replace("–", "--")
795885
assert text_contents == maybe_fix_py310(expected_contents)

whitelist.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
addnodes
22
ast3
3+
astext
34
autodoc
45
autouse
56
backfill

0 commit comments

Comments
 (0)