Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 = ""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could use none just need to annotation it str | None, no?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes a type error when I try to use it unless I add an extra assert. Empty string is falsey and this makes the type checker happy without adding the assert.

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