Repository: tox-dev/sphinx-autodoc-typehints (python)

Issue:
Does not handle class attributes
I have a fix for this but it isn't great. More monkey patching. I'll open a PR and provide an explanation there.

Suppose I have the following:

```py
class T:
    """A class"""

    code : CodeType | None
    """An attribute"""
```
#### What I see:

The type for the attribute was rendered directly by `sphinx-autodoc` inconsistently with the way that `sphinx-autodoc-typehints` would render it:

![Screenshot from 2023-01-17 10-16-18](https://user-images.githubusercontent.com/8739626/212979356-a1f5b2e6-de8f-4c10-a371-3d748585c2c7.png)

#### What I'd like to see:

The attribute type rendered by `sphinx-autodoc-typehints` 

![Screenshot from 2023-01-17 10-16-59](https://user-images.githubusercontent.com/8739626/212979426-49105630-954b-4ef3-9c56-b2c162b7fc78.png)


Pull request description:
Use format_annotation to render class attribute type annotations
Resolves #298.

This is a bit hard to do because autodoc doesn't emit any events to help us adjust the type rendering for attributes and because the type annotation is currently converted to text and then rendered from text by the `PyAttribute` directive.

The system in autodoc for attribute rendering is:
1. In `AttributeDocumenter.get_directive_header`, call `stringify_annotation` to render the annotation to a string
2. add `:type: stringified annotation` to the directive header of `PyAttribute`
3. When `sphinx.domains.python.PyAttribute` sees the `:type:` it calls `format_annotation` which attempts to convert the string representation of the type into a more fancy representation with cross referencing

Notes:
2. we don't get an opportunity to adjust the directive header with `autodoc-process-docstring` or some other event
3. there isn't enough information retained in the string for `_inject_types_to_docstring` to work.

So my approach is:
1. Patch `AttributeDocumenter.get_directive_header` to use `format_annotation` instead of `stringify_annotation`. Add a prefix to the output to indicate that it is rst not a plain text type annotation.
3. Patch `PyAttribute` so that when it sees the prefix, it formats the output as rst rather than trying to handle it as a plain text type annotation.

As future proofing, we check that the patch targets exist and bail if they don't exist. If they move this patch should gracefully decay into a no-op rather than crashing.

Patch:
diff --git a/src/sphinx_autodoc_typehints/__init__.py b/src/sphinx_autodoc_typehints/__init__.py
index c08720d..287b2e1 100644
--- a/src/sphinx_autodoc_typehints/__init__.py
+++ b/src/sphinx_autodoc_typehints/__init__.py
@@ -18,6 +18,7 @@ from sphinx.util import logging
 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__)
@@ -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}
 
 
diff --git a/src/sphinx_autodoc_typehints/attributes_patch.py b/src/sphinx_autodoc_typehints/attributes_patch.py
new file mode 100644
index 0000000..c4a033b
--- /dev/null
+++ b/src/sphinx_autodoc_typehints/attributes_patch.py
@@ -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"]
diff --git a/whitelist.txt b/whitelist.txt
index fd0b8eb..a3608f3 100644
--- a/whitelist.txt
+++ b/whitelist.txt
@@ -1,3 +1,4 @@
+addnodes
 ast3
 autodoc
 autouse
@@ -9,9 +10,11 @@ cpython
 csv
 dedent
 delattr
+desc
 dirname
 docnames
 Documenter
+docutils
 dunder
 eval
 exc
@@ -41,6 +44,7 @@ nptyping
 param
 parametrized
 params
+parsers
 pathlib
 pos
 prepend
@@ -49,8 +53,11 @@ pydata
 pytestconfig
 qualname
 rootdir
+rst
 rtype
 runtime
+sig
+signode
 skipif
 sph
 sphobjinv
@@ -63,9 +70,11 @@ supertype
 tempdir
 testroot
 textwrap
+typ
 typehint
 typehints
 unittest
 unresolvable
 util
+utils
 vararg


Test patch:
diff --git a/tests/roots/test-dummy/dummy_module.py b/tests/roots/test-dummy/dummy_module.py
index 5cea4d5..8ffe0b2 100644
--- a/tests/roots/test-dummy/dummy_module.py
+++ b/tests/roots/test-dummy/dummy_module.py
@@ -1,5 +1,6 @@
 from dataclasses import dataclass
 from mailbox import Mailbox
+from types import CodeType
 from typing import Callable, Optional, Union, overload
 
 
@@ -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"""
diff --git a/tests/roots/test-dummy/index.rst b/tests/roots/test-dummy/index.rst
index 50f06f5..7ce503b 100644
--- a/tests/roots/test-dummy/index.rst
+++ b/tests/roots/test-dummy/index.rst
@@ -42,3 +42,6 @@ Dummy Module
 .. autofunction:: dummy_module.func_with_examples
 
 .. autofunction:: dummy_module.func_with_overload
+
+.. autoclass:: dummy_module.TestClassAttributeDocs
+   :members:
diff --git a/tests/test_sphinx_autodoc_typehints.py b/tests/test_sphinx_autodoc_typehints.py
index 4042d09..16bbe0c 100644
--- a/tests/test_sphinx_autodoc_typehints.py
+++ b/tests/test_sphinx_autodoc_typehints.py
@@ -782,6 +782,14 @@ def test_sphinx_output(
 
            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)