From 1dc0f5044035630f9e50e83e31f2e5886ad5886f Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Fri, 26 Dec 2025 19:57:23 +0000 Subject: [PATCH] feat(autodoc): document classmethod properties --- sphinx/deprecation.py | 4 + sphinx/ext/autodoc/__init__.py | 87 ++++++++++++++++++- sphinx/ext/autosummary/__init__.py | 38 +++++++- .../target/classmethod_properties.py | 46 ++++++++++ .../test-ext-autosummary-classmethod/conf.py | 17 ++++ ...ssmethod_properties.AbstractBase.token.rst | 6 ++ ...get.classmethod_properties.Basic.value.rst | 6 ++ ....classmethod_properties.WithMeta.label.rst | 6 ++ .../index.rst | 9 ++ .../target/__init__.py | 1 + .../target/classmethod_properties.py | 44 ++++++++++ tests/test_ext_autodoc_autoproperty.py | 71 +++++++++++++++ tests/test_ext_autosummary.py | 30 ++++++- 13 files changed, 361 insertions(+), 4 deletions(-) create mode 100644 tests/roots/test-ext-autodoc/target/classmethod_properties.py create mode 100644 tests/roots/test-ext-autosummary-classmethod/conf.py create mode 100644 tests/roots/test-ext-autosummary-classmethod/generated/target.classmethod_properties.AbstractBase.token.rst create mode 100644 tests/roots/test-ext-autosummary-classmethod/generated/target.classmethod_properties.Basic.value.rst create mode 100644 tests/roots/test-ext-autosummary-classmethod/generated/target.classmethod_properties.WithMeta.label.rst create mode 100644 tests/roots/test-ext-autosummary-classmethod/index.rst create mode 100644 tests/roots/test-ext-autosummary-classmethod/target/__init__.py create mode 100644 tests/roots/test-ext-autosummary-classmethod/target/classmethod_properties.py diff --git a/sphinx/deprecation.py b/sphinx/deprecation.py index 3963e63615f..6b0bdd1588b 100644 --- a/sphinx/deprecation.py +++ b/sphinx/deprecation.py @@ -14,6 +14,10 @@ from typing import Any, Dict, Type +class RemovedInSphinx40Warning(DeprecationWarning): + pass + + class RemovedInSphinx50Warning(DeprecationWarning): pass diff --git a/sphinx/ext/autodoc/__init__.py b/sphinx/ext/autodoc/__init__.py index 1cecb1f797d..91cb531751c 100644 --- a/sphinx/ext/autodoc/__init__.py +++ b/sphinx/ext/autodoc/__init__.py @@ -2658,10 +2658,89 @@ class PropertyDocumenter(DocstringStripSignatureMixin, ClassLevelDocumenter): # # before AttributeDocumenter priority = AttributeDocumenter.priority + 1 + @staticmethod + def _find_declared_attribute(owner: Any, name: str) -> Any: + if not isinstance(owner, type): + return None + + for base in inspect.getmro(owner): + if name in base.__dict__: + return base.__dict__[name] + + return None + + @classmethod + def _get_property_descriptor(cls, owner: Any, name: str) -> Optional[property]: + if owner is None: + return None + + raw = cls._find_declared_attribute(owner, name) + descriptor = cls._unwrap_property(raw) + if descriptor: + return descriptor + + metaclass = getattr(owner, '__class__', None) + if isinstance(owner, type) and isinstance(metaclass, type): + raw = cls._find_declared_attribute(metaclass, name) + descriptor = cls._unwrap_property(raw) + if descriptor: + return descriptor + + return None + + @staticmethod + def _unwrap_property(candidate: Any) -> Optional[property]: + if inspect.isproperty(candidate): + return candidate + + if inspect.isclassmethod(candidate): + unwrapped = getattr(candidate, '__wrapped__', None) + if unwrapped is None: + unwrapped = getattr(candidate, '__func__', None) + if inspect.isproperty(unwrapped): + return unwrapped + + return None + + @staticmethod + def _is_abstract_property(descriptor: Any) -> bool: + if descriptor is None: + return False + + if inspect.isabstractmethod(descriptor): + return True + + fget = safe_getattr(descriptor, 'fget', None) + return bool(getattr(fget, '__isabstractmethod__', False)) + @classmethod def can_document_member(cls, member: Any, membername: str, isattr: bool, parent: Any ) -> bool: - return inspect.isproperty(member) and isinstance(parent, ClassDocumenter) + if not isinstance(parent, ClassDocumenter): + return False + + if inspect.isproperty(member): + return True + + owner = getattr(parent, 'object', None) + descriptor = cls._get_property_descriptor(owner, membername) + return descriptor is not None + + def import_object(self, raiseerror: bool = False) -> bool: + ret = super().import_object(raiseerror) + if not ret: + return ret + + name = self.object_name or (self.objpath[-1] if self.objpath else None) + descriptor = None + if name: + descriptor = self._get_property_descriptor(self.parent, name) + + if descriptor: + self.object = descriptor + + self._property_is_abstract = self._is_abstract_property(self.object) + return ret def document_members(self, all_members: bool = False) -> None: pass @@ -2673,7 +2752,11 @@ def get_real_modname(self) -> str: def add_directive_header(self, sig: str) -> None: super().add_directive_header(sig) sourcename = self.get_sourcename() - if inspect.isabstractmethod(self.object): + is_abstract = getattr(self, '_property_is_abstract', None) + if is_abstract is None: + is_abstract = inspect.isabstractmethod(self.object) + + if is_abstract: self.add_line(' :abstractmethod:', sourcename) if safe_getattr(self.object, 'fget', None) and self.config.autodoc_typehints != 'none': diff --git a/sphinx/ext/autosummary/__init__.py b/sphinx/ext/autosummary/__init__.py index 3d51beaa54e..ffe22257ab0 100644 --- a/sphinx/ext/autosummary/__init__.py +++ b/sphinx/ext/autosummary/__init__.py @@ -87,6 +87,7 @@ from sphinx.util import logging, rst from sphinx.util.docutils import (NullReporter, SphinxDirective, SphinxRole, new_document, switch_source_input) +from sphinx.util.inspect import safe_getattr from sphinx.util.matching import Matcher from sphinx.util.typing import OptionSpec from sphinx.writers.html import HTMLTranslator @@ -221,10 +222,45 @@ def get_documenter(app: Sphinx, obj: Any, parent: Any) -> Type[Documenter]: parent_doc = parent_doc_cls(FakeDirective(), parent.__name__) else: parent_doc = parent_doc_cls(FakeDirective(), "") + parent_doc.object = parent + + membername = '' + if parent is not None: + candidate_names = [] + try: + candidate_names.extend(dir(parent)) + except Exception: + candidate_names = [] + + if isinstance(parent, type): + for meta in inspect.getmro(parent.__class__): + candidate_names.extend(meta.__dict__.keys()) + + checked = set() + for attr in candidate_names: + if attr in checked: + continue + checked.add(attr) + + try: + value = safe_getattr(parent, attr) + except AttributeError: + continue + + if value is obj: + membername = attr + break + + try: + if value == obj: + membername = attr + break + except Exception: + continue # Get the corrent documenter class for *obj* classes = [cls for cls in app.registry.documenters.values() - if cls.can_document_member(obj, '', False, parent_doc)] + if cls.can_document_member(obj, membername, False, parent_doc)] if classes: classes.sort(key=lambda cls: cls.priority) return classes[-1] diff --git a/tests/roots/test-ext-autodoc/target/classmethod_properties.py b/tests/roots/test-ext-autodoc/target/classmethod_properties.py new file mode 100644 index 00000000000..6655f08b32e --- /dev/null +++ b/tests/roots/test-ext-autodoc/target/classmethod_properties.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from abc import ABCMeta, abstractmethod + + +class Basic: + """Container for basic class level descriptor.""" + + @classmethod + @property + def value(cls) -> int: + """Class-level counter.""" + return 1 + + +class Inherited(Basic): + """Subclass used to verify inherited descriptors.""" + + +class AbstractBase(metaclass=ABCMeta): + @classmethod + @property + @abstractmethod + def token(cls) -> str: + """Abstract identifier.""" + return NotImplemented + + +class AbstractImpl(AbstractBase): + @classmethod + @property + def token(cls) -> str: + """Real identifier.""" + return "impl" + + +class PropertyMeta(type): + @classmethod + @property + def label(mcls) -> str: + """Metaclass provided label.""" + return "meta" + + +class WithMeta(metaclass=PropertyMeta): + """Target class exposing metaclass property.""" diff --git a/tests/roots/test-ext-autosummary-classmethod/conf.py b/tests/roots/test-ext-autosummary-classmethod/conf.py new file mode 100644 index 00000000000..cc9fb958290 --- /dev/null +++ b/tests/roots/test-ext-autosummary-classmethod/conf.py @@ -0,0 +1,17 @@ +import os +import sys + + +sys.path.insert(0, os.path.abspath('.')) + + +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.autosummary', +] + +autosummary_generate = True +autosummary_generate_overwrite = True + +master_doc = 'index' +source_suffix = '.rst' diff --git a/tests/roots/test-ext-autosummary-classmethod/generated/target.classmethod_properties.AbstractBase.token.rst b/tests/roots/test-ext-autosummary-classmethod/generated/target.classmethod_properties.AbstractBase.token.rst new file mode 100644 index 00000000000..6ca6fd4d0cb --- /dev/null +++ b/tests/roots/test-ext-autosummary-classmethod/generated/target.classmethod_properties.AbstractBase.token.rst @@ -0,0 +1,6 @@ +target.classmethod\_properties.AbstractBase.token +================================================= + +.. currentmodule:: target.classmethod_properties + +.. autoproperty:: AbstractBase.token \ No newline at end of file diff --git a/tests/roots/test-ext-autosummary-classmethod/generated/target.classmethod_properties.Basic.value.rst b/tests/roots/test-ext-autosummary-classmethod/generated/target.classmethod_properties.Basic.value.rst new file mode 100644 index 00000000000..de25aac5b63 --- /dev/null +++ b/tests/roots/test-ext-autosummary-classmethod/generated/target.classmethod_properties.Basic.value.rst @@ -0,0 +1,6 @@ +target.classmethod\_properties.Basic.value +========================================== + +.. currentmodule:: target.classmethod_properties + +.. autoproperty:: Basic.value \ No newline at end of file diff --git a/tests/roots/test-ext-autosummary-classmethod/generated/target.classmethod_properties.WithMeta.label.rst b/tests/roots/test-ext-autosummary-classmethod/generated/target.classmethod_properties.WithMeta.label.rst new file mode 100644 index 00000000000..6e31d442fa4 --- /dev/null +++ b/tests/roots/test-ext-autosummary-classmethod/generated/target.classmethod_properties.WithMeta.label.rst @@ -0,0 +1,6 @@ +target.classmethod\_properties.WithMeta.label +============================================= + +.. currentmodule:: target.classmethod_properties + +.. autoproperty:: WithMeta.label \ No newline at end of file diff --git a/tests/roots/test-ext-autosummary-classmethod/index.rst b/tests/roots/test-ext-autosummary-classmethod/index.rst new file mode 100644 index 00000000000..a856de14c32 --- /dev/null +++ b/tests/roots/test-ext-autosummary-classmethod/index.rst @@ -0,0 +1,9 @@ +Classmethod property autosummary +================================ + +.. autosummary:: + :toctree: generated + + target.classmethod_properties.Basic.value + target.classmethod_properties.AbstractBase.token + target.classmethod_properties.WithMeta.label diff --git a/tests/roots/test-ext-autosummary-classmethod/target/__init__.py b/tests/roots/test-ext-autosummary-classmethod/target/__init__.py new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/tests/roots/test-ext-autosummary-classmethod/target/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/roots/test-ext-autosummary-classmethod/target/classmethod_properties.py b/tests/roots/test-ext-autosummary-classmethod/target/classmethod_properties.py new file mode 100644 index 00000000000..9d7a07e19cf --- /dev/null +++ b/tests/roots/test-ext-autosummary-classmethod/target/classmethod_properties.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from abc import ABCMeta, abstractmethod + + +class Basic: + @classmethod + @property + def value(cls) -> int: + """Class-level counter.""" + return 1 + + +class Inherited(Basic): + pass + + +class AbstractBase(metaclass=ABCMeta): + @classmethod + @property + @abstractmethod + def token(cls) -> str: + """Abstract identifier.""" + return NotImplemented + + +class AbstractImpl(AbstractBase): + @classmethod + @property + def token(cls) -> str: + """Real identifier.""" + return "impl" + + +class PropertyMeta(type): + @classmethod + @property + def label(mcls) -> str: + """Metaclass provided label.""" + return "meta" + + +class WithMeta(metaclass=PropertyMeta): + pass diff --git a/tests/test_ext_autodoc_autoproperty.py b/tests/test_ext_autodoc_autoproperty.py index ee25aa8b71b..3a2694245a3 100644 --- a/tests/test_ext_autodoc_autoproperty.py +++ b/tests/test_ext_autodoc_autoproperty.py @@ -26,3 +26,74 @@ def test_properties(app): ' docstring', '', ] + + +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_classmethod_property(app): + actual = do_autodoc(app, 'property', 'target.classmethod_properties.Basic.value') + assert list(actual) == [ + '', + '.. py:property:: Basic.value', + ' :module: target.classmethod_properties', + ' :type: int', + '', + ' Class-level counter.', + '', + ] + + +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_classmethod_property_inherited(app): + actual = do_autodoc(app, 'property', 'target.classmethod_properties.Inherited.value') + assert list(actual) == [ + '', + '.. py:property:: Inherited.value', + ' :module: target.classmethod_properties', + ' :type: int', + '', + ' Class-level counter.', + '', + ] + + +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_classmethod_property_abstract(app): + actual = do_autodoc(app, 'property', 'target.classmethod_properties.AbstractBase.token') + assert list(actual) == [ + '', + '.. py:property:: AbstractBase.token', + ' :module: target.classmethod_properties', + ' :abstractmethod:', + ' :type: str', + '', + ' Abstract identifier.', + '', + ] + + +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_classmethod_property_concrete(app): + actual = do_autodoc(app, 'property', 'target.classmethod_properties.AbstractImpl.token') + assert list(actual) == [ + '', + '.. py:property:: AbstractImpl.token', + ' :module: target.classmethod_properties', + ' :type: str', + '', + ' Real identifier.', + '', + ] + + +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_classmethod_property_metaclass(app): + actual = do_autodoc(app, 'property', 'target.classmethod_properties.WithMeta.label') + assert list(actual) == [ + '', + '.. py:property:: WithMeta.label', + ' :module: target.classmethod_properties', + ' :type: str', + '', + ' Metaclass provided label.', + '', + ] diff --git a/tests/test_ext_autosummary.py b/tests/test_ext_autosummary.py index 71868d4920f..af09f2b4677 100644 --- a/tests/test_ext_autosummary.py +++ b/tests/test_ext_autosummary.py @@ -358,9 +358,37 @@ def test_autosummary_generate_overwrite1(app_params, make_app): (srcdir / 'generated').makedirs(exist_ok=True) (srcdir / 'generated' / 'autosummary_dummy_module.rst').write_text('') - app = make_app(*args, **kwargs) + make_app(*args, **kwargs) content = (srcdir / 'generated' / 'autosummary_dummy_module.rst').read_text() assert content == '' + + +@pytest.mark.sphinx('dummy', testroot='ext-autosummary-classmethod') +def test_autosummary_classmethod_properties(app, status, warning): + app.builder.build_all() + + assert warning.getvalue() == '' + + doctree = app.env.get_doctree('index') + text = doctree.astext() + assert 'target.classmethod_properties.Basic.value' in text + assert 'target.classmethod_properties.AbstractBase.token' in text + assert 'target.classmethod_properties.WithMeta.label' in text + + basic = app.env.get_doctree('generated/target.classmethod_properties.Basic.value') + basic_text = basic.astext() + assert 'Class-level counter.' in basic_text + assert 'property Basic.value: int' in basic_text + + abstract = app.env.get_doctree('generated/target.classmethod_properties.AbstractBase.token') + abstract_text = abstract.astext() + assert 'Abstract identifier.' in abstract_text + assert 'abstract property AbstractBase.token: str' in abstract_text + + meta = app.env.get_doctree('generated/target.classmethod_properties.WithMeta.label') + meta_text = meta.astext() + assert 'Metaclass provided label.' in meta_text + assert 'property WithMeta.label: str' in meta_text assert 'autosummary_dummy_module.rst' not in app._warning.getvalue()