Skip to content

Commit a261a08

Browse files
authored
Use format_annotation to render class attribute type annotations (#299)
Resolves #298
1 parent c7a156e commit a261a08

File tree

6 files changed

+122
-0
lines changed

6 files changed

+122
-0
lines changed

src/sphinx_autodoc_typehints/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from sphinx.util.inspect import signature as sphinx_signature
1919
from sphinx.util.inspect import stringify_signature
2020

21+
from .attributes_patch import patch_attribute_handling
2122
from .version import __version__
2223

2324
_LOGGER = logging.getLogger(__name__)
@@ -732,6 +733,7 @@ def setup(app: Sphinx) -> dict[str, bool]:
732733
app.connect("autodoc-process-signature", process_signature)
733734
app.connect("autodoc-process-docstring", process_docstring)
734735
fix_autodoc_typehints_for_overloaded_methods()
736+
patch_attribute_handling(app)
735737
return {"parallel_read_safe": True}
736738

737739

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
from functools import partial
2+
from optparse import Values
3+
from typing import Any, Tuple
4+
from unittest.mock import patch
5+
6+
import sphinx.domains.python
7+
import sphinx.ext.autodoc
8+
from docutils.parsers.rst import Parser as RstParser
9+
from docutils.utils import new_document
10+
from sphinx.addnodes import desc_signature
11+
from sphinx.application import Sphinx
12+
from sphinx.domains.python import PyAttribute
13+
from sphinx.ext.autodoc import AttributeDocumenter
14+
15+
# Defensively check for the things we want to patch
16+
_parse_annotation = getattr(sphinx.domains.python, "_parse_annotation", None)
17+
18+
# We want to patch:
19+
# * sphinx.ext.autodoc.stringify_typehint (in sphinx < 6.1)
20+
# * sphinx.ext.autodoc.stringify_annotation (in sphinx >= 6.1)
21+
STRINGIFY_PATCH_TARGET = ""
22+
for target in ["stringify_typehint", "stringify_annotation"]:
23+
if hasattr(sphinx.ext.autodoc, target):
24+
STRINGIFY_PATCH_TARGET = f"sphinx.ext.autodoc.{target}"
25+
break
26+
27+
# If we didn't locate both patch targets, we will just do nothing.
28+
OKAY_TO_PATCH = bool(_parse_annotation and STRINGIFY_PATCH_TARGET)
29+
30+
# A label we inject to the type string so we know not to try to treat it as a
31+
# type annotation
32+
TYPE_IS_RST_LABEL = "--is-rst--"
33+
34+
35+
orig_add_directive_header = AttributeDocumenter.add_directive_header
36+
orig_handle_signature = PyAttribute.handle_signature
37+
38+
39+
def stringify_annotation(app: Sphinx, annotation: Any, mode: str = "") -> str: # noqa: U100
40+
"""Format the annotation with sphinx-autodoc-typehints and inject our
41+
magic prefix to tell our patched PyAttribute.handle_signature to treat
42+
it as rst."""
43+
from . import format_annotation
44+
45+
return TYPE_IS_RST_LABEL + format_annotation(annotation, app.config)
46+
47+
48+
def patch_attribute_documenter(app: Sphinx) -> None:
49+
"""Instead of using stringify_typehint in
50+
`AttributeDocumenter.add_directive_header`, use `format_annotation`
51+
"""
52+
53+
def add_directive_header(*args: Any, **kwargs: Any) -> Any:
54+
with patch(STRINGIFY_PATCH_TARGET, partial(stringify_annotation, app)):
55+
return orig_add_directive_header(*args, **kwargs)
56+
57+
AttributeDocumenter.add_directive_header = add_directive_header # type:ignore[assignment]
58+
59+
60+
def rst_to_docutils(settings: Values, rst: str) -> Any:
61+
"""Convert rst to a sequence of docutils nodes"""
62+
doc = new_document("", settings)
63+
RstParser().parse(rst, doc)
64+
# Remove top level paragraph node so that there is no line break.
65+
return doc.children[0].children
66+
67+
68+
def patched_parse_annotation(settings: Values, typ: str, env: Any) -> Any:
69+
# if typ doesn't start with our label, use original function
70+
if not typ.startswith(TYPE_IS_RST_LABEL):
71+
return _parse_annotation(typ, env) # type: ignore
72+
# Otherwise handle as rst
73+
typ = typ[len(TYPE_IS_RST_LABEL) :]
74+
return rst_to_docutils(settings, typ)
75+
76+
77+
def patched_handle_signature(self: PyAttribute, sig: str, signode: desc_signature) -> Tuple[str, str]:
78+
target = "sphinx.domains.python._parse_annotation"
79+
new_func = partial(patched_parse_annotation, self.state.document.settings)
80+
with patch(target, new_func):
81+
return orig_handle_signature(self, sig, signode)
82+
83+
84+
def patch_attribute_handling(app: Sphinx) -> None:
85+
"""Use format_signature to format class attribute type annotations"""
86+
if not OKAY_TO_PATCH:
87+
return
88+
PyAttribute.handle_signature = patched_handle_signature # type:ignore[assignment]
89+
patch_attribute_documenter(app)
90+
91+
92+
__all__ = ["patch_attribute_handling"]

tests/roots/test-dummy/dummy_module.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from dataclasses import dataclass
22
from mailbox import Mailbox
3+
from types import CodeType
34
from typing import Callable, Optional, Union, overload
45

56

@@ -309,3 +310,10 @@ def func_with_overload(a: Union[int, str], b: Union[int, str]) -> None: # noqa:
309310
b:
310311
The second thing
311312
"""
313+
314+
315+
class TestClassAttributeDocs:
316+
"""A class"""
317+
318+
code: Union[CodeType, None]
319+
"""An attribute"""

tests/roots/test-dummy/index.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,6 @@ Dummy Module
4242
.. autofunction:: dummy_module.func_with_examples
4343

4444
.. autofunction:: dummy_module.func_with_overload
45+
46+
.. autoclass:: dummy_module.TestClassAttributeDocs
47+
:members:

tests/test_sphinx_autodoc_typehints.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -782,6 +782,14 @@ class dummy_module.DataClass(x)
782782
783783
Return type:
784784
"None"
785+
786+
class dummy_module.TestClassAttributeDocs
787+
788+
A class
789+
790+
code: "Optional"["CodeType"]
791+
792+
An attribute
785793
"""
786794
expected_contents = dedent(expected_contents).format(**format_args).replace("–", "--")
787795
assert text_contents == maybe_fix_py310(expected_contents)

whitelist.txt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
addnodes
12
ast3
23
autodoc
34
autouse
@@ -9,9 +10,11 @@ cpython
910
csv
1011
dedent
1112
delattr
13+
desc
1214
dirname
1315
docnames
1416
Documenter
17+
docutils
1518
dunder
1619
eval
1720
exc
@@ -41,6 +44,7 @@ nptyping
4144
param
4245
parametrized
4346
params
47+
parsers
4448
pathlib
4549
pos
4650
prepend
@@ -49,8 +53,11 @@ pydata
4953
pytestconfig
5054
qualname
5155
rootdir
56+
rst
5257
rtype
5358
runtime
59+
sig
60+
signode
5461
skipif
5562
sph
5663
sphobjinv
@@ -63,9 +70,11 @@ supertype
6370
tempdir
6471
testroot
6572
textwrap
73+
typ
6674
typehint
6775
typehints
6876
unittest
6977
unresolvable
7078
util
79+
utils
7180
vararg

0 commit comments

Comments
 (0)