Skip to content

Commit b6241c1

Browse files
authored
Annotate tuple return types (#10)
1 parent 18b4749 commit b6241c1

File tree

4 files changed

+178
-6
lines changed

4 files changed

+178
-6
lines changed

scanpydoc/elegant_typehints/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,9 @@ def setup(app: Sphinx) -> Dict[str, Any]:
8282
name, partial(_role_annot, additional_classes=name.split("-"))
8383
)
8484

85+
from .return_tuple import process_docstring # , process_signature
86+
87+
app.connect("autodoc-process-docstring", process_docstring)
88+
# app.connect("autodoc-process-signature", process_signature)
89+
8590
return metadata

scanpydoc/elegant_typehints/formatting.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,10 +112,7 @@ def format_annotation(annotation: Type[Any], fully_qualified: bool = False) -> s
112112
curframe = inspect.currentframe()
113113
calframe = inspect.getouterframes(curframe, 2)
114114
if calframe[1][3] == "process_docstring":
115-
annot_fmt = (
116-
f":annotation-terse:`{_escape(_format_terse(annotation, fully_qualified))}`\\ "
117-
f":annotation-full:`{_escape(_format_full(annotation, fully_qualified))}`"
118-
)
115+
annot_fmt = format_both(annotation, fully_qualified)
119116
if elegant_typehints.annotate_defaults:
120117
variables = calframe[1].frame.f_locals
121118
sig = inspect.signature(variables["obj"])
@@ -128,6 +125,13 @@ def format_annotation(annotation: Type[Any], fully_qualified: bool = False) -> s
128125
return _format_full(annotation, fully_qualified)
129126

130127

128+
def format_both(annotation: Type[Any], fully_qualified: bool = False):
129+
return (
130+
f":annotation-terse:`{_escape(_format_terse(annotation, fully_qualified))}`\\ "
131+
f":annotation-full:`{_escape(_format_full(annotation, fully_qualified))}`"
132+
)
133+
134+
131135
def _role_annot(
132136
name: str,
133137
rawtext: str,
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import inspect
2+
import re
3+
from typing import get_type_hints, Any, Union, Optional, Type, Tuple, List
4+
5+
from sphinx.application import Sphinx
6+
from sphinx.ext.autodoc import Options
7+
8+
from .formatting import format_both
9+
10+
11+
re_ret = re.compile("^:returns?: ")
12+
13+
14+
def get_tuple_annot(annotation: Optional[Type]) -> Optional[Tuple[Type, ...]]:
15+
if annotation is None:
16+
return None
17+
origin = getattr(annotation, "__origin__")
18+
if not origin:
19+
return None
20+
if origin is Union:
21+
for annotation in annotation.__args__:
22+
origin = getattr(annotation, "__origin__")
23+
if origin in (tuple, Tuple):
24+
break
25+
else:
26+
return None
27+
return annotation.__args__
28+
29+
30+
def process_docstring(
31+
app: Sphinx,
32+
what: str,
33+
name: str,
34+
obj: Any,
35+
options: Optional[Options],
36+
lines: List[str],
37+
) -> None:
38+
# Handle complex objects
39+
if isinstance(obj, property):
40+
obj = obj.fget
41+
if not callable(obj):
42+
return
43+
if what in ("class", "exception"):
44+
obj = obj.__init__
45+
obj = inspect.unwrap(obj)
46+
ret_types = get_tuple_annot(get_type_hints(obj).get("return"))
47+
if ret_types is None:
48+
return
49+
50+
# Get return section
51+
i_prefix = None
52+
l_start = None
53+
l_end = None
54+
for l, line in enumerate(lines):
55+
if i_prefix is None:
56+
m = re_ret.match(line)
57+
if m:
58+
i_prefix = m.span()[1]
59+
l_start = l
60+
elif len(line[:i_prefix].strip()) > 0:
61+
l_end = l - 1
62+
break
63+
else:
64+
l_end = len(lines) - 1
65+
if i_prefix is None:
66+
return
67+
68+
# Meat
69+
idxs_ret_names = []
70+
for l, line in enumerate([l[i_prefix:] for l in lines[l_start : l_end + 1]]):
71+
if line.isidentifier() and lines[l + l_start + 1].startswith(" "):
72+
idxs_ret_names.append(l + l_start)
73+
74+
if len(idxs_ret_names) == len(ret_types):
75+
for l, rt in zip(idxs_ret_names, ret_types):
76+
typ = format_both(rt, app.config.typehints_fully_qualified)
77+
lines[l : l + 1] = [f"{lines[l]} : {typ}"]
78+
79+
80+
# def process_signature(
81+
# app: Sphinx,
82+
# what: str,
83+
# name: str,
84+
# obj: Any,
85+
# options: Options,
86+
# signature: Optional[str],
87+
# return_annotation: str,
88+
# ) -> Optional[Tuple[Optional[str], Optional[str]]]:
89+
# return signature, return_annotation

tests/test_elegant_typehints.py

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import inspect
2+
import re
23
import typing as t
34

45
try:
@@ -7,14 +8,16 @@
78
from typing_extensions import Literal
89

910
import pytest
11+
import sphinx_autodoc_typehints as sat
1012
from sphinx.application import Sphinx
11-
from sphinx_autodoc_typehints import process_docstring
1213

1314
from scanpydoc.elegant_typehints.formatting import (
1415
format_annotation,
1516
_format_terse,
1617
_format_full,
1718
)
19+
from scanpydoc.elegant_typehints.return_tuple import process_docstring
20+
1821

1922
TestCls = type("Class", (), {})
2023
TestCls.__module__ = "_testmod"
@@ -23,7 +26,11 @@
2326
@pytest.fixture
2427
def app(make_app_setup) -> Sphinx:
2528
return make_app_setup(
26-
extensions="scanpydoc.elegant_typehints",
29+
extensions=[
30+
"sphinx.ext.napoleon",
31+
"sphinx_autodoc_typehints",
32+
"scanpydoc.elegant_typehints",
33+
],
2734
qualname_overrides={"_testmod.Class": "test.Class"},
2835
)
2936

@@ -34,6 +41,7 @@ def process_doc(app):
3441

3542
def process(fn: t.Callable) -> t.List[str]:
3643
lines = inspect.getdoc(fn).split("\n")
44+
sat.process_docstring(app, "function", fn.__name__, fn, None, lines)
3745
process_docstring(app, "function", fn.__name__, fn, None, lines)
3846
return lines
3947

@@ -202,3 +210,69 @@ def test_typing_class_nested(app):
202210
":py:data:`~typing.Tuple`\\[:py:class:`int`, :py:class:`str`]"
203211
"]"
204212
)
213+
214+
215+
@pytest.mark.parametrize(
216+
"docstring",
217+
[
218+
"""
219+
:param: x
220+
:return: foo
221+
A foo!
222+
bar
223+
A bar!
224+
""",
225+
"""
226+
:return: foo
227+
A foo!
228+
bar
229+
A bar!
230+
:param: x
231+
""",
232+
],
233+
ids=["Last", "First"],
234+
)
235+
@pytest.mark.parametrize(
236+
"return_ann",
237+
[t.Tuple[str, int], t.Optional[t.Tuple[str, int]]],
238+
ids=["Tuple", "Optional[Tuple]"],
239+
)
240+
def test_return(process_doc, docstring, return_ann):
241+
def fn_test():
242+
pass
243+
244+
fn_test.__doc__ = docstring
245+
fn_test.__annotations__["return"] = return_ann
246+
lines = [
247+
l
248+
for l in process_doc(fn_test)
249+
if not re.match("^:(rtype|param|annotation-(full|terse)):", l)
250+
]
251+
assert lines == [
252+
r":return: foo : "
253+
r":annotation-terse:`:py:class:\`str\``\ "
254+
r":annotation-full:`:py:class:\`str\``",
255+
" A foo!",
256+
r" bar : "
257+
r":annotation-terse:`:py:class:\`int\``\ "
258+
r":annotation-full:`:py:class:\`int\``",
259+
" A bar!",
260+
]
261+
262+
263+
def test_return_too_many(process_doc):
264+
def fn_test() -> t.Tuple[int, str]:
265+
"""
266+
:return: foo
267+
A foo!
268+
bar
269+
A bar!
270+
baz
271+
A baz!
272+
"""
273+
274+
assert not any(
275+
"annotation-terse" in l
276+
for l in process_doc(fn_test)
277+
if not l.startswith(":rtype:")
278+
)

0 commit comments

Comments
 (0)