Repository: sphinx-doc/sphinx

Issue:
[BUG] singledispatch classmethods are not detected by MethodDocumenter
### Describe the bug

When using [functools.singledispatchmethod](https://docs.python.org/3/library/functools.html#functools.singledispatchmethod) together with `classmethod`, no `:classmethod:` directive option is added by `sphinx.ext.autodoc.MethodDocumenter`. 

Using the example below, the reST directive result is

```rst
.. py:class:: MyClass()
   :module: class

   My class.


   .. py:method:: MyClass.dispatch(x)
      :module: class

      A singledispatch class method.


   .. py:method:: MyClass.method()
      :module: class
      :classmethod:

      A class method.
```

The issue lies in the following line:

https://github.com/sphinx-doc/sphinx/blob/669bcc0a190ce9921451bad28431886fbaad170b/sphinx/ext/autodoc/__init__.py#L2171-L2172

The inspection of a classmethod when the latter is wrapped in a `singledispatchmethod` does not detect it as a classmethod. In particular, I would suggest replacing this test by the following line:

```py
if inspect.isclassmethod(obj) or (
   inspect.is_singledispatch_method(obj)
   and inspect.isclassmethod(obj.func)
):
   ...
```


### How to Reproduce

### class.py

```py
from functools import singledispatchmethod

class MyClass:
    """My class."""

    @singledispatchmethod  
    @classmethod
    def dispatch(cls, x):
      """A singledispatch class method."""
      return 0

    @classmethod
    def method(cls):
        """A class method."""
        pass
```

### index.rst

```rst
Foo
===

.. autoclass:: class.MyClass
   :members:
```

### conf.py

```py
import os
import sys

sys.path.insert(0, os.path.abspath('.'))

project = 'Foo'
copyright = '2023, picnixz'
author = 'picnixz'

extensions = ['sphinx.ext.autodoc']

templates_path = ['_templates']

master_doc = 'index'

include_patterns = ['index.rst', 'class.py']
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']

html_theme = 'alabaster'
html_static_path = ['_static']
``` 


### Environment Information

```text
Platform:              linux; (Linux-5.3.18-lp152.106-default-x86_64-with-glibc2.26)
Python version:        3.10.3 (main, Jan 31 2023, 10:47:25) [GCC 7.5.0])
Python implementation: CPython
Sphinx version:        6.1.3
Docutils version:      0.18.1
Jinja2 version:        3.1.2
Pygments version:      2.14.0
```


### Sphinx extensions

```python
['sphinx.ext.autodoc']
```


### Additional context

The same issue seems to happen with `staticmethod`: https://github.com/sphinx-doc/sphinx/issues/10529#issuecomment-1153282798

Discussion:
picnixz: Since we are dropping 3.8 support, I'll clean this PR when I've got time.
picnixz: I wanted to rename some branch but it closed the PR. So I'll stick with an old name.
picnixz: I've had this PR for a long time and nothing has ever changed, so let's merge this one now.

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 7a0ebf759..cc0b0d26c 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -93,6 +93,9 @@ Bugs fixed
 * #11474: Fix doctrees caching causing files not be rebuilt in some cases,
   e.g., when :confval:`numfig` is ``True``.
   Patch by Bénédikt Tran.
+* #11278: autodoc: Fix rendering of :class:`functools.singledispatchmethod`
+  combined with :func:`@classmethod <classmethod>`.
+  Patch by Bénédikt Tran.
 
 Testing
 -------
diff --git a/sphinx/ext/autodoc/__init__.py b/sphinx/ext/autodoc/__init__.py
index 27058af5c..c57ecd8d5 100644
--- a/sphinx/ext/autodoc/__init__.py
+++ b/sphinx/ext/autodoc/__init__.py
@@ -2229,7 +2229,8 @@ def add_directive_header(self, sig: str) -> None:
             self.add_line('   :abstractmethod:', sourcename)
         if inspect.iscoroutinefunction(obj) or inspect.isasyncgenfunction(obj):
             self.add_line('   :async:', sourcename)
-        if inspect.isclassmethod(obj):
+        if (inspect.isclassmethod(obj) or
+                inspect.is_singledispatch_method(obj) and inspect.isclassmethod(obj.func)):
             self.add_line('   :classmethod:', sourcename)
         if inspect.isstaticmethod(obj, cls=self.parent, name=self.object_name):
             self.add_line('   :staticmethod:', sourcename)
@@ -2261,6 +2262,8 @@ def format_signature(self, **kwargs: Any) -> str:
                 if typ is object:
                     pass  # default implementation. skipped.
                 else:
+                    if inspect.isclassmethod(func):
+                        func = func.__func__
                     dispatchmeth = self.annotate_to_first_argument(func, typ)
                     if dispatchmeth:
                         documenter = MethodDocumenter(self.directive, '')


Test patch:
diff --git a/tests/roots/test-ext-autodoc/target/singledispatchmethod_classmethod.py b/tests/roots/test-ext-autodoc/target/singledispatchmethod_classmethod.py
new file mode 100644
index 000000000..039fada89
--- /dev/null
+++ b/tests/roots/test-ext-autodoc/target/singledispatchmethod_classmethod.py
@@ -0,0 +1,31 @@
+from functools import singledispatchmethod
+
+
+class Foo:
+    """docstring"""
+
+    @singledispatchmethod
+    @classmethod
+    def class_meth(cls, arg, kwarg=None):
+        """A class method for general use."""
+        pass
+
+    @class_meth.register(int)
+    @class_meth.register(float)
+    @classmethod
+    def _class_meth_int(cls, arg, kwarg=None):
+        """A class method for numbers."""
+        pass
+
+    @class_meth.register(str)
+    @classmethod
+    def _class_meth_str(cls, arg, kwarg=None):
+        """A class method for str."""
+        pass
+
+    @class_meth.register
+    @classmethod
+    def _class_meth_dict(cls, arg: dict, kwarg=None):
+        """A class method for dict."""
+        # This function tests for specifying type through annotations
+        pass
diff --git a/tests/test_extensions/test_ext_autodoc.py b/tests/test_extensions/test_ext_autodoc.py
index 627f2c9aa..203e5b439 100644
--- a/tests/test_extensions/test_ext_autodoc.py
+++ b/tests/test_extensions/test_ext_autodoc.py
@@ -2111,6 +2111,55 @@ def test_singledispatchmethod_automethod(app):
     ]
 
 
+@pytest.mark.sphinx('html', testroot='ext-autodoc')
+def test_singledispatchmethod_classmethod(app):
+    options = {"members": None}
+    actual = do_autodoc(app, 'module', 'target.singledispatchmethod_classmethod', options)
+
+    assert list(actual) == [
+        '',
+        '.. py:module:: target.singledispatchmethod_classmethod',
+        '',
+        '',
+        '.. py:class:: Foo()',
+        '   :module: target.singledispatchmethod_classmethod',
+        '',
+        '   docstring',
+        '',
+        '',
+        '   .. py:method:: Foo.class_meth(arg, kwarg=None)',
+        '                  Foo.class_meth(arg: float, kwarg=None)',
+        '                  Foo.class_meth(arg: int, kwarg=None)',
+        '                  Foo.class_meth(arg: str, kwarg=None)',
+        '                  Foo.class_meth(arg: dict, kwarg=None)',
+        '      :module: target.singledispatchmethod_classmethod',
+        '      :classmethod:',
+        '',
+        '      A class method for general use.',
+        '',
+    ]
+
+
+@pytest.mark.sphinx('html', testroot='ext-autodoc')
+def test_singledispatchmethod_classmethod_automethod(app):
+    options = {}
+    actual = do_autodoc(app, 'method', 'target.singledispatchmethod_classmethod.Foo.class_meth', options)
+
+    assert list(actual) == [
+        '',
+        '.. py:method:: Foo.class_meth(arg, kwarg=None)',
+        '               Foo.class_meth(arg: float, kwarg=None)',
+        '               Foo.class_meth(arg: int, kwarg=None)',
+        '               Foo.class_meth(arg: str, kwarg=None)',
+        '               Foo.class_meth(arg: dict, kwarg=None)',
+        '   :module: target.singledispatchmethod_classmethod',
+        '   :classmethod:',
+        '',
+        '   A class method for general use.',
+        '',
+    ]
+
+
 @pytest.mark.skipif(sys.version_info[:2] >= (3, 13),
                     reason='Cython does not support Python 3.13 yet.')
 @pytest.mark.skipif(pyximport is None, reason='cython is not installed')