Repository: tox-dev/sphinx-autodoc-typehints

Issue:
autodoc-typehints fails on SQLAlchemy models
When trying to document a project that's using SQLAlchemy, I get the following error message:

```
WARNING: error while formatting signature for module.DatabaseModel.created_at: Handler <function process_signature at 0x7f3faac891c0> for event 'autodoc-process-signature' threw an exception (exception: wrapper loop when unwrapping sqlalchemy.orm.mapped_column)
WARNING: error while formatting signature for module.DatabaseModel.updated_at: Handler <function process_signature at 0x7f3faac891c0> for event 'autodoc-process-signature' threw an exception (exception: wrapper loop when unwrapping sqlalchemy.orm.mapped_column)

Extension error (sphinx_autodoc_typehints):
Handler <function process_signature at 0x7fdf8b48d1c0> for event 'autodoc-process-signature' threw an exception (exception: wrapper loop when unwrapping sqlalchemy.orm.mapped_column)
```

I fear SQLalchemy is generally troublesome when it comes to autodoc, which is why it's added to `autodoc_mock_imports` in my `conf.py`. Would it be possible to do something similar with sphinx-autodoc-typehints?

A reproducible example:

```python
from datetime import datetime

from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.sql import func


class DatabaseModel(DeclarativeBase):  # pylint: disable=too-few-public-methods
    """The declarative base for the ORM. Adds ``created_at`` and ``updated_at`` timestamp columns."""

    created_at: Mapped[datetime] = mapped_column(server_default=func.now())
    """A ``datetime`` object, that is set on creation."""

    updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), server_onupdate=func.now())
    """A ``datetime`` object, that is updated on changes."""
```

Discussion:

Fix (patch):
diff --git a/src/sphinx_autodoc_typehints/__init__.py b/src/sphinx_autodoc_typehints/__init__.py
index a7a020b..5c1d662 100644
--- a/src/sphinx_autodoc_typehints/__init__.py
+++ b/src/sphinx_autodoc_typehints/__init__.py
@@ -364,7 +364,7 @@ def remove_prefix(text: str, prefix: str) -> str:
     return "\n".join(aligned_prefix + aligned_suffix)
 
 
-def process_signature(  # noqa: C901, PLR0913, PLR0917
+def process_signature(  # noqa: C901, PLR0912, PLR0913, PLR0917
     app: Sphinx,
     what: str,
     name: str,
@@ -393,7 +393,10 @@ def process_signature(  # noqa: C901, PLR0913, PLR0917
     if not getattr(obj, "__annotations__", None):  # when has no annotation we cannot autodoc typehints so bail
         return None
 
-    obj = inspect.unwrap(obj)
+    try:
+        obj = inspect.unwrap(obj)
+    except ValueError:
+        return None
     sph_signature = sphinx_signature(obj, type_aliases=app.config["autodoc_type_aliases"])
     typehints_formatter: Callable[..., str | None] | None = getattr(app.config, "typehints_formatter", None)
 
@@ -732,7 +735,10 @@ def process_docstring(  # noqa: PLR0913, PLR0917
     if not callable(obj):
         return
     obj = obj.__init__ if inspect.isclass(obj) else obj
-    obj = inspect.unwrap(obj)
+    try:
+        obj = inspect.unwrap(obj)
+    except ValueError:
+        return
 
     try:
         signature = sphinx_signature(obj, type_aliases=app.config["autodoc_type_aliases"])


Test changes:
diff --git a/tests/test_sphinx_autodoc_typehints.py b/tests/test_sphinx_autodoc_typehints.py
index 5768f38..f27c273 100644
--- a/tests/test_sphinx_autodoc_typehints.py
+++ b/tests/test_sphinx_autodoc_typehints.py
@@ -33,6 +33,7 @@
 import typing_extensions
 from sphinx.application import Sphinx
 from sphinx.config import Config
+from sphinx.ext.autodoc import Options
 
 from sphinx_autodoc_typehints import (
     _resolve_type_guarded_imports,
@@ -43,6 +44,7 @@
     get_annotation_module,
     normalize_source_lines,
     process_docstring,
+    process_signature,
 )
 
 if typing.TYPE_CHECKING:
@@ -519,6 +521,58 @@ def test_process_docstring_slot_wrapper() -> None:
     assert not lines
 
 
+def test_process_docstring_wrapper_loop() -> None:
+    """Regression test for #405: inspect.unwrap raises ValueError on wrapper loops."""
+
+    def func(x: int) -> str:
+        return str(x)
+
+    func.__wrapped__ = func  # type: ignore[attr-defined]  # circular wrapper loop
+
+    lines: list[str] = []
+    config = create_autospec(
+        Config,
+        typehints_fully_qualified=False,
+        simplify_optional_unions=False,
+        typehints_formatter=None,
+        autodoc_mock_imports=[],
+    )
+    app: Sphinx = create_autospec(Sphinx, config=config)
+    # Should not raise ValueError
+    process_docstring(app, "function", "func", func, None, lines)
+
+
+def test_process_signature_wrapper_loop() -> None:
+    """Regression test for #405: inspect.unwrap raises ValueError on wrapper loops."""
+
+    def func(x: int) -> str:
+        return str(x)
+
+    func.__wrapped__ = func  # type: ignore[attr-defined]  # circular wrapper loop
+
+    config = create_autospec(
+        Config,
+        typehints_fully_qualified=False,
+        simplify_optional_unions=False,
+        typehints_formatter=None,
+        typehints_use_signature=False,
+        typehints_use_signature_return=False,
+        autodoc_type_aliases={},
+    )
+    app: Sphinx = create_autospec(Sphinx, config=config)
+    # Should return None instead of raising ValueError
+    result = process_signature(
+        app,
+        "function",
+        "func",
+        func,
+        Options(),
+        "",
+        "",
+    )
+    assert result is None
+
+
 def set_python_path() -> None:
     test_path = Path(__file__).parent
     # Add test directory to sys.path to allow imports of dummy module.