Skip to content

Commit a6a2461

Browse files
authored
Add fancy typehints on hover (#6)
1 parent 802eb17 commit a6a2461

File tree

4 files changed

+125
-21
lines changed

4 files changed

+125
-21
lines changed

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,7 @@ doc = [
3333
'sphinx-autodoc-typehints',
3434
]
3535

36+
[tool.black]
37+
py36 = false
38+
exclude = '3.5'
39+

scanpydoc/elegant_typehints.py

Lines changed: 90 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,29 @@
2727
2828
"""
2929
import inspect
30-
from collections import ChainMap
31-
from typing import Union, Mapping, Dict, Any, Type
30+
from collections import abc, ChainMap
31+
from functools import partial
32+
from pathlib import Path
33+
from typing import Any, Union, Optional # Meta
34+
from typing import Type, Mapping, Sequence # ABC
35+
from typing import Dict, List, Tuple # Concrete
3236

3337
import sphinx_autodoc_typehints
38+
from sphinx_autodoc_typehints import format_annotation as _format_full
39+
from docutils import nodes
40+
from docutils.nodes import Node
41+
from docutils.parsers.rst.roles import set_classes
42+
from docutils.parsers.rst.states import Inliner, Struct
43+
from docutils.utils import SystemMessage, unescape
3444
from sphinx.application import Sphinx
3545
from sphinx.config import Config
46+
from docutils.parsers.rst import roles
3647

3748
from . import _setup_sig, metadata
3849

3950

51+
HERE = Path(__file__).parent.resolve()
52+
4053
qualname_overrides_default = {
4154
"anndata.base.AnnData": "anndata.AnnData",
4255
"pandas.core.frame.DataFrame": "pandas.DataFrame",
@@ -49,24 +62,12 @@
4962

5063
def _init_vars(app: Sphinx, config: Config):
5164
qualname_overrides.update(config.qualname_overrides)
65+
config.html_static_path.append(str(HERE / "static"))
5266

5367

54-
fa_orig = sphinx_autodoc_typehints.format_annotation
55-
56-
57-
def format_annotation(annotation: Type[Any]) -> str:
58-
"""Generate reStructuredText containing links to the types.
59-
60-
Unlike :func:`sphinx_autodoc_typehints.format_annotation`,
61-
it tries to achieve a simpler style as seen in numeric packages like numpy.
62-
63-
Args:
64-
annotation: A type or class used as type annotation.
65-
66-
Returns:
67-
reStructuredText describing the type
68-
"""
68+
def _format_terse(annotation: Type[Any]) -> str:
6969
union_params = getattr(annotation, "__union_params__", None)
70+
7071
# display `Union[A, B]` as `A, B`
7172
if getattr(annotation, "__origin__", None) is Union or union_params:
7273
params = union_params or getattr(annotation, "__args__", None)
@@ -75,23 +76,91 @@ def format_annotation(annotation: Type[Any]) -> str:
7576
# as is the convention in the other large numerical packages
7677
# if len(params or []) == 2 and getattr(params[1], '__qualname__', None) == 'NoneType':
7778
# return fa_orig(annotation) # Optional[...]
78-
return ", ".join(map(format_annotation, params))
79+
return ", ".join(map(_format_terse, params))
80+
7981
# do not show the arguments of Mapping
80-
if getattr(annotation, "__origin__", None) is Mapping:
81-
return ":class:`~typing.Mapping`"
82+
if getattr(annotation, "__origin__", None) in (abc.Mapping, Mapping):
83+
return ":py:class:`~typing.Mapping`"
84+
85+
# display dict as {k: v}
86+
if getattr(annotation, "__origin__", None) in (dict, Dict):
87+
k, v = annotation.__args__
88+
return f"{{{_format_terse(k)}: {_format_terse(v)}}}"
89+
8290
if inspect.isclass(annotation):
8391
full_name = f"{annotation.__module__}.{annotation.__qualname__}"
8492
override = qualname_overrides.get(full_name)
8593
if override is not None:
8694
return f":py:class:`~{override}`"
87-
return fa_orig(annotation)
95+
return _format_full(annotation)
96+
97+
98+
def format_annotation(annotation: Type[Any]) -> str:
99+
"""Generate reStructuredText containing links to the types.
100+
101+
Unlike :func:`sphinx_autodoc_typehints.format_annotation`,
102+
it tries to achieve a simpler style as seen in numeric packages like numpy.
103+
104+
Args:
105+
annotation: A type or class used as type annotation.
106+
107+
Returns:
108+
reStructuredText describing the type
109+
"""
110+
111+
curframe = inspect.currentframe()
112+
calframe = inspect.getouterframes(curframe, 2)
113+
if calframe[1][3] == "process_docstring":
114+
return (
115+
f":annotation-terse:`{_escape(_format_terse(annotation))}`\\ "
116+
f":annotation-full:`{_escape(_format_full(annotation))}`"
117+
)
118+
else: # recursive use
119+
return _format_full(annotation)
120+
121+
122+
def _role_annot(
123+
name: str,
124+
rawtext: str,
125+
text: str,
126+
lineno: int,
127+
inliner: Inliner,
128+
options: Dict[str, Any] = {},
129+
content: Sequence[str] = (),
130+
# *, # https://github.com/ambv/black/issues/613
131+
additional_class: Optional[str] = None,
132+
) -> Tuple[List[Node], List[SystemMessage]]:
133+
options = options.copy()
134+
set_classes(options)
135+
if additional_class is not None:
136+
options["classes"] = options.get("classes", []).copy()
137+
options["classes"].append(additional_class)
138+
memo = Struct(
139+
document=inliner.document, reporter=inliner.reporter, language=inliner.language
140+
)
141+
node = nodes.inline(unescape(rawtext), "", **options)
142+
children, messages = inliner.parse(_unescape(text), lineno, memo, node)
143+
node.extend(children)
144+
return [node], messages
145+
146+
147+
def _escape(rst: str) -> str:
148+
return rst.replace("`", "\\`")
149+
150+
151+
def _unescape(rst: str) -> str:
152+
# TODO: IDK why the [ part is necessary.
153+
return unescape(rst).replace("\\`", "`").replace("[", "\\[")
88154

89155

90156
@_setup_sig
91157
def setup(app: Sphinx) -> Dict[str, Any]:
92158
"""Patches :mod:`sphinx_autodoc_typehints` for a more elegant display."""
93159
app.add_config_value("qualname_overrides", {}, "")
160+
app.add_css_file("typehints.css")
94161
app.connect("config-inited", _init_vars)
95162
sphinx_autodoc_typehints.format_annotation = format_annotation
163+
for name in ["annotation-terse", "annotation-full"]:
164+
roles.register_canonical_role(name, partial(_role_annot, additional_class=name))
96165

97166
return metadata

scanpydoc/static/typehints.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
*:not(:hover) > .annotation-full { display: none }
2+
*:hover > .annotation-terse { display: none }

tests/test_elegant_typehints.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from typing import Mapping, Any, Dict
2+
3+
from scanpydoc.elegant_typehints import format_annotation, _format_terse, _format_full
4+
5+
6+
def test_default():
7+
assert format_annotation(str) == ":py:class:`str`"
8+
9+
10+
def test_alternatives():
11+
def process_docstring(a):
12+
"""Caller needs to be `process_docstring` to create both formats"""
13+
return format_annotation(a)
14+
15+
assert process_docstring(str) == (
16+
":annotation-terse:`:py:class:\\`str\\``\\ "
17+
":annotation-full:`:py:class:\\`str\\``"
18+
)
19+
20+
21+
def test_mapping():
22+
assert _format_terse(Mapping[str, Any]) == ":py:class:`~typing.Mapping`"
23+
assert _format_full(Mapping[str, Any]) == (
24+
":py:class:`~typing.Mapping`\\[:py:class:`str`, :py:data:`~typing.Any`]"
25+
)
26+
27+
28+
def test_dict():
29+
assert _format_terse(Dict[str, Any]) == "{:py:class:`str`: :py:data:`~typing.Any`}"

0 commit comments

Comments
 (0)