Skip to content

Commit

Permalink
Use format_annotation to render class attribute type annotations (#299)
Browse files Browse the repository at this point in the history
Resolves #298
  • Loading branch information
hoodmane authored Jan 17, 2023
1 parent c7a156e commit a261a08
Show file tree
Hide file tree
Showing 6 changed files with 122 additions and 0 deletions.
2 changes: 2 additions & 0 deletions src/sphinx_autodoc_typehints/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from sphinx.util.inspect import signature as sphinx_signature
from sphinx.util.inspect import stringify_signature

from .attributes_patch import patch_attribute_handling
from .version import __version__

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


Expand Down
92 changes: 92 additions & 0 deletions src/sphinx_autodoc_typehints/attributes_patch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
from functools import partial
from optparse import Values
from typing import Any, Tuple
from unittest.mock import patch

import sphinx.domains.python
import sphinx.ext.autodoc
from docutils.parsers.rst import Parser as RstParser
from docutils.utils import new_document
from sphinx.addnodes import desc_signature
from sphinx.application import Sphinx
from sphinx.domains.python import PyAttribute
from sphinx.ext.autodoc import AttributeDocumenter

# Defensively check for the things we want to patch
_parse_annotation = getattr(sphinx.domains.python, "_parse_annotation", None)

# We want to patch:
# * sphinx.ext.autodoc.stringify_typehint (in sphinx < 6.1)
# * sphinx.ext.autodoc.stringify_annotation (in sphinx >= 6.1)
STRINGIFY_PATCH_TARGET = ""
for target in ["stringify_typehint", "stringify_annotation"]:
if hasattr(sphinx.ext.autodoc, target):
STRINGIFY_PATCH_TARGET = f"sphinx.ext.autodoc.{target}"
break

# If we didn't locate both patch targets, we will just do nothing.
OKAY_TO_PATCH = bool(_parse_annotation and STRINGIFY_PATCH_TARGET)

# A label we inject to the type string so we know not to try to treat it as a
# type annotation
TYPE_IS_RST_LABEL = "--is-rst--"


orig_add_directive_header = AttributeDocumenter.add_directive_header
orig_handle_signature = PyAttribute.handle_signature


def stringify_annotation(app: Sphinx, annotation: Any, mode: str = "") -> str: # noqa: U100
"""Format the annotation with sphinx-autodoc-typehints and inject our
magic prefix to tell our patched PyAttribute.handle_signature to treat
it as rst."""
from . import format_annotation

return TYPE_IS_RST_LABEL + format_annotation(annotation, app.config)


def patch_attribute_documenter(app: Sphinx) -> None:
"""Instead of using stringify_typehint in
`AttributeDocumenter.add_directive_header`, use `format_annotation`
"""

def add_directive_header(*args: Any, **kwargs: Any) -> Any:
with patch(STRINGIFY_PATCH_TARGET, partial(stringify_annotation, app)):
return orig_add_directive_header(*args, **kwargs)

AttributeDocumenter.add_directive_header = add_directive_header # type:ignore[assignment]


def rst_to_docutils(settings: Values, rst: str) -> Any:
"""Convert rst to a sequence of docutils nodes"""
doc = new_document("", settings)
RstParser().parse(rst, doc)
# Remove top level paragraph node so that there is no line break.
return doc.children[0].children


def patched_parse_annotation(settings: Values, typ: str, env: Any) -> Any:
# if typ doesn't start with our label, use original function
if not typ.startswith(TYPE_IS_RST_LABEL):
return _parse_annotation(typ, env) # type: ignore
# Otherwise handle as rst
typ = typ[len(TYPE_IS_RST_LABEL) :]
return rst_to_docutils(settings, typ)


def patched_handle_signature(self: PyAttribute, sig: str, signode: desc_signature) -> Tuple[str, str]:
target = "sphinx.domains.python._parse_annotation"
new_func = partial(patched_parse_annotation, self.state.document.settings)
with patch(target, new_func):
return orig_handle_signature(self, sig, signode)


def patch_attribute_handling(app: Sphinx) -> None:
"""Use format_signature to format class attribute type annotations"""
if not OKAY_TO_PATCH:
return
PyAttribute.handle_signature = patched_handle_signature # type:ignore[assignment]
patch_attribute_documenter(app)


__all__ = ["patch_attribute_handling"]
8 changes: 8 additions & 0 deletions tests/roots/test-dummy/dummy_module.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from dataclasses import dataclass
from mailbox import Mailbox
from types import CodeType
from typing import Callable, Optional, Union, overload


Expand Down Expand Up @@ -309,3 +310,10 @@ def func_with_overload(a: Union[int, str], b: Union[int, str]) -> None: # noqa:
b:
The second thing
"""


class TestClassAttributeDocs:
"""A class"""

code: Union[CodeType, None]
"""An attribute"""
3 changes: 3 additions & 0 deletions tests/roots/test-dummy/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,6 @@ Dummy Module
.. autofunction:: dummy_module.func_with_examples

.. autofunction:: dummy_module.func_with_overload

.. autoclass:: dummy_module.TestClassAttributeDocs
:members:
8 changes: 8 additions & 0 deletions tests/test_sphinx_autodoc_typehints.py
Original file line number Diff line number Diff line change
Expand Up @@ -782,6 +782,14 @@ class dummy_module.DataClass(x)
Return type:
"None"
class dummy_module.TestClassAttributeDocs
A class
code: "Optional"["CodeType"]
An attribute
"""
expected_contents = dedent(expected_contents).format(**format_args).replace("–", "--")
assert text_contents == maybe_fix_py310(expected_contents)
Expand Down
9 changes: 9 additions & 0 deletions whitelist.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
addnodes
ast3
autodoc
autouse
Expand All @@ -9,9 +10,11 @@ cpython
csv
dedent
delattr
desc
dirname
docnames
Documenter
docutils
dunder
eval
exc
Expand Down Expand Up @@ -41,6 +44,7 @@ nptyping
param
parametrized
params
parsers
pathlib
pos
prepend
Expand All @@ -49,8 +53,11 @@ pydata
pytestconfig
qualname
rootdir
rst
rtype
runtime
sig
signode
skipif
sph
sphobjinv
Expand All @@ -63,9 +70,11 @@ supertype
tempdir
testroot
textwrap
typ
typehint
typehints
unittest
unresolvable
util
utils
vararg

0 comments on commit a261a08

Please sign in to comment.