From 4767ad306d6f6b6c091461c998bcba441c67c6ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sun, 28 May 2023 14:13:00 +0200 Subject: [PATCH 01/29] stash --- sphinx/ext/autodoc/importer.py | 6 +- tests/roots/test-ext-autodoc/target/enums.py | 22 +++++++ tests/test_ext_autodoc.py | 63 ++++++++++++++++++++ 3 files changed, 89 insertions(+), 2 deletions(-) diff --git a/sphinx/ext/autodoc/importer.py b/sphinx/ext/autodoc/importer.py index 20ab4994bf8..746edb10449 100644 --- a/sphinx/ext/autodoc/importer.py +++ b/sphinx/ext/autodoc/importer.py @@ -169,7 +169,8 @@ def get_object_members( if name not in members: members[name] = Attribute(name, True, value) - superclass = subject.__mro__[1] + # enumerations are created as `EnumName([mixin_type, ...] [data_type,] enum_type)` + superclass = subject.__mro__[-2] for name in obj_dict: if name not in superclass.__dict__: value = safe_getattr(subject, name) @@ -230,7 +231,8 @@ def get_class_members(subject: Any, objpath: list[str], attrgetter: Callable, if name not in members: members[name] = ObjectMember(name, value, class_=subject) - superclass = subject.__mro__[1] + # enumerations are created as `EnumName([mixin_type, ...] [data_type,] enum_type)` + superclass = subject.__mro__[-2] for name in obj_dict: if name not in superclass.__dict__: value = safe_getattr(subject, name) diff --git a/tests/roots/test-ext-autodoc/target/enums.py b/tests/roots/test-ext-autodoc/target/enums.py index c69455fb7fb..44d6d3e1ae8 100644 --- a/tests/roots/test-ext-autodoc/target/enums.py +++ b/tests/roots/test-ext-autodoc/target/enums.py @@ -21,3 +21,25 @@ def say_hello(self): def say_goodbye(cls): """a classmethod says good-bye to you.""" pass + + +class EnumClassWithDataType(str, enum.Enum): + """ + this is enum class + """ + + #: doc for val1 + val1 = 'ab' + val2 = 'cd' #: doc for val2 + val3 = 'ef' + """doc for val3""" + val4 = 'ef' + + def say_hello(self): + """a method says hello to you.""" + pass + + @classmethod + def say_goodbye(cls): + """a classmethod says good-bye to you.""" + pass \ No newline at end of file diff --git a/tests/test_ext_autodoc.py b/tests/test_ext_autodoc.py index 1023323aa6a..45d958614f5 100644 --- a/tests/test_ext_autodoc.py +++ b/tests/test_ext_autodoc.py @@ -1473,6 +1473,69 @@ def test_enum_class(app): '', ] + +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_enum_class_with_data_type(app): + options = {"members": None, "undoc-members": None, "private-members": None} + actual = do_autodoc(app, 'class', 'target.enums.EnumClassWithDataType', options) + + if sys.version_info[:2] >= (3, 12): + args = ('(value, names=None, *values, module=None, ' + 'qualname=None, type=None, start=1, boundary=None)') + elif sys.version_info[:2] >= (3, 11): + args = ('(value, names=None, *, module=None, qualname=None, ' + 'type=None, start=1, boundary=None)') + else: + args = '(value)' + + assert list(actual) == [ + '', + '.. py:class:: EnumClassWithDataType' + args, + ' :module: target.enums', + '', + ' this is enum class', + '', + '', + ' .. py:method:: EnumClassWithDataType.say_goodbye()', + ' :module: target.enums', + ' :classmethod:', + '', + ' a classmethod says good-bye to you.', + '', + '', + ' .. py:method:: EnumClassWithDataType.say_hello()', + ' :module: target.enums', + '', + ' a method says hello to you.', + '', + '', + ' .. py:attribute:: EnumClassWithDataType.val1', + ' :module: target.enums', + ' :value: \'ab\'', + '', + ' doc for val1', + '', + '', + ' .. py:attribute:: EnumClassWithDataType.val2', + ' :module: target.enums', + ' :value: \'cd\'', + '', + ' doc for val2', + '', + '', + ' .. py:attribute:: EnumClassWithDataType.val3', + ' :module: target.enums', + ' :value: \'ef\'', + '', + ' doc for val3', + '', + '', + ' .. py:attribute:: EnumClassWithDataType.val4', + ' :module: target.enums', + ' :value: \'ef\'', + '', + ] + @pytest.mark.sphinx('html', testroot='ext-autodoc') def test_descriptor_class(app): From b711e6b60adda97fe245dc3db12226c8a7061012 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Tue, 15 Aug 2023 12:05:36 +0200 Subject: [PATCH 02/29] update tests --- tests/roots/test-ext-autodoc/target/enums.py | 4 ++-- tests/test_ext_autodoc.py | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/roots/test-ext-autodoc/target/enums.py b/tests/roots/test-ext-autodoc/target/enums.py index 44d6d3e1ae8..28265534916 100644 --- a/tests/roots/test-ext-autodoc/target/enums.py +++ b/tests/roots/test-ext-autodoc/target/enums.py @@ -33,7 +33,7 @@ class EnumClassWithDataType(str, enum.Enum): val2 = 'cd' #: doc for val2 val3 = 'ef' """doc for val3""" - val4 = 'ef' + val4 = 'gh' def say_hello(self): """a method says hello to you.""" @@ -42,4 +42,4 @@ def say_hello(self): @classmethod def say_goodbye(cls): """a classmethod says good-bye to you.""" - pass \ No newline at end of file + pass diff --git a/tests/test_ext_autodoc.py b/tests/test_ext_autodoc.py index 4d0e6efb2f8..1c957509035 100644 --- a/tests/test_ext_autodoc.py +++ b/tests/test_ext_autodoc.py @@ -1471,7 +1471,7 @@ def test_enum_class(app): '', ] - + @pytest.mark.sphinx('html', testroot='ext-autodoc') def test_enum_class_with_data_type(app): options = {"members": None, "undoc-members": None, "private-members": None} @@ -1509,28 +1509,28 @@ def test_enum_class_with_data_type(app): '', ' .. py:attribute:: EnumClassWithDataType.val1', ' :module: target.enums', - ' :value: \'ab\'', + f' :value: {"ab"!r}', '', ' doc for val1', '', '', ' .. py:attribute:: EnumClassWithDataType.val2', ' :module: target.enums', - ' :value: \'cd\'', + f' :value: {"cd"!r}', '', ' doc for val2', '', '', ' .. py:attribute:: EnumClassWithDataType.val3', ' :module: target.enums', - ' :value: \'ef\'', + f' :value: {"ef"!r}', '', ' doc for val3', '', '', ' .. py:attribute:: EnumClassWithDataType.val4', ' :module: target.enums', - ' :value: \'ef\'', + f' :value: {"gh"!r}', '', ] From b09c0a355e5161358319c8355c95f2e86761679b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Tue, 15 Aug 2023 12:10:10 +0200 Subject: [PATCH 03/29] add CHANGES --- CHANGES | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGES b/CHANGES index aa8cefcbcc6..e3e11992464 100644 --- a/CHANGES +++ b/CHANGES @@ -80,6 +80,8 @@ Bugs fixed * #11473: Type annotations containing :py:data:`~typing.Literal` enumeration values now render correctly. Patch by Bénédikt Tran. +* #11353: Support enumeration classes inheriting from mixin or data types. + Patch by Bénédikt Tran. Testing ------- From c87bbacf0aa8420661bb71d649d95ac51598465a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Tue, 15 Aug 2023 12:17:17 +0200 Subject: [PATCH 04/29] update tests --- tests/roots/test-ext-autodoc/target/enums.py | 28 +++++++++ tests/test_ext_autodoc.py | 63 ++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/tests/roots/test-ext-autodoc/target/enums.py b/tests/roots/test-ext-autodoc/target/enums.py index 28265534916..47f9084bd4b 100644 --- a/tests/roots/test-ext-autodoc/target/enums.py +++ b/tests/roots/test-ext-autodoc/target/enums.py @@ -43,3 +43,31 @@ def say_hello(self): def say_goodbye(cls): """a classmethod says good-bye to you.""" pass + + +class ToUpperCase(enum.Enum): + @property + def value(self): + return str(self._value_).upper() + + +class EnumClassWithMixinType(ToUpperCase, str, enum.Enum): + """ + this is enum class + """ + + #: doc for val1 + val1 = 'ab' + val2 = 'cd' #: doc for val2 + val3 = 'ef' + """doc for val3""" + val4 = 'gh' + + def say_hello(self): + """a method says hello to you.""" + pass + + @classmethod + def say_goodbye(cls): + """a classmethod says good-bye to you.""" + pass diff --git a/tests/test_ext_autodoc.py b/tests/test_ext_autodoc.py index 1c957509035..4d77c2505a5 100644 --- a/tests/test_ext_autodoc.py +++ b/tests/test_ext_autodoc.py @@ -1535,6 +1535,69 @@ def test_enum_class_with_data_type(app): ] +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_enum_class_with_mixin_type(app): + options = {"members": None, "undoc-members": None, "private-members": None} + actual = do_autodoc(app, 'class', 'target.enums.EnumClassWithMixinType', options) + + if sys.version_info[:2] >= (3, 12): + args = ('(value, names=None, *values, module=None, ' + 'qualname=None, type=None, start=1, boundary=None)') + elif sys.version_info[:2] >= (3, 11): + args = ('(value, names=None, *, module=None, qualname=None, ' + 'type=None, start=1, boundary=None)') + else: + args = '(value)' + + assert list(actual) == [ + '', + '.. py:class:: EnumClassWithMixinType' + args, + ' :module: target.enums', + '', + ' this is enum class', + '', + '', + ' .. py:method:: EnumClassWithMixinType.say_goodbye()', + ' :module: target.enums', + ' :classmethod:', + '', + ' a classmethod says good-bye to you.', + '', + '', + ' .. py:method:: EnumClassWithMixinType.say_hello()', + ' :module: target.enums', + '', + ' a method says hello to you.', + '', + '', + ' .. py:attribute:: EnumClassWithMixinType.val1', + ' :module: target.enums', + f' :value: {"AB"!r}', + '', + ' doc for val1', + '', + '', + ' .. py:attribute:: EnumClassWithMixinType.val2', + ' :module: target.enums', + f' :value: {"CD"!r}', + '', + ' doc for val2', + '', + '', + ' .. py:attribute:: EnumClassWithMixinType.val3', + ' :module: target.enums', + f' :value: {"EF"!r}', + '', + ' doc for val3', + '', + '', + ' .. py:attribute:: EnumClassWithMixinType.val4', + ' :module: target.enums', + f' :value: {"GH"!r}', + '', + ] + + @pytest.mark.sphinx('html', testroot='ext-autodoc') def test_descriptor_class(app): options = {"members": 'CustomDataDescriptor,CustomDataDescriptor2'} From 3e6c60297dc1ab4097fdad4ce41dd85829154dfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Thu, 21 Sep 2023 10:45:46 +0200 Subject: [PATCH 05/29] update CHANGES --- CHANGES.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 35088d2b8ee..ac9b086e5b7 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -20,6 +20,8 @@ Bugs fixed Patch by Vinay Sajip. * #11622: Ensure that the order of keys in ``searchindex.js`` is deterministic. Patch by Pietro Albini. +* #11353: Support enumeration classes inheriting from mixin or data types. + Patch by Bénédikt Tran. Testing ------- @@ -218,8 +220,6 @@ Bugs fixed * #11459: Fix support for async and lambda functions in ``sphinx.ext.autodoc.preserve_defaults``. Patch by Bénédikt Tran. -* #11353: Support enumeration classes inheriting from mixin or data types. - Patch by Bénédikt Tran. Testing ------- From 9a359d2cdcb9ad41a963b3831df28a61a5c4937e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Wed, 27 Sep 2023 13:25:45 +0200 Subject: [PATCH 06/29] update implementation --- sphinx/ext/autodoc/importer.py | 46 ++- tests/roots/test-ext-autodoc/target/enums.py | 69 ++++- tests/test_ext_autodoc.py | 307 ++++++++----------- 3 files changed, 233 insertions(+), 189 deletions(-) diff --git a/sphinx/ext/autodoc/importer.py b/sphinx/ext/autodoc/importer.py index e6cb0506417..c99773f3bf9 100644 --- a/sphinx/ext/autodoc/importer.py +++ b/sphinx/ext/autodoc/importer.py @@ -8,6 +8,7 @@ import sys import traceback import typing +from enum import Enum from typing import TYPE_CHECKING, Any, Callable, NamedTuple from sphinx.ext.autodoc.mock import ismock, undecorate @@ -23,6 +24,7 @@ ) if TYPE_CHECKING: + from collections.abc import Generator from types import ModuleType from sphinx.ext.autodoc import ObjectMember @@ -30,6 +32,34 @@ logger = logging.getLogger(__name__) +def _filter_enum_dict(cls: type[Enum], ns: dict[str, Any]) -> Generator[tuple[str, Any], None, None]: + # enumerations are created as ``EnumName([mixin_type, ...] [data_type,] enum_type)`` + member_type = getattr(cls, '_member_type_', object) + enum_mixins = {b for b in cls.__mro__[1:-1] if isenumclass(b)} + + for name in ns: + # Include attributes that are not from Enum or those that are + # from the data-type. Attributes that are from the mixin types + # will be discovered correctly, even if they are enum classes. + # + # We cannot rely on ``dir`` to get the members from mixin + # enumeration types because ``dir(MyMixinEnum)`` returns + # nothing (mixin enumerations must not have members). + # + # Now, by design, enumeration classes have by default *no* + # public methods (except properties name / value). As such, + # if a mixin class exposes an attribute that is overridden by + # the object being documented, then ``name in Enum.__dict__`` + # is always false. + # + # If the Enum API changes, then the filtering algorithm needs to + # be updated so that attributes declared on mixin or member types + # are correctly found. + if name not in Enum.__dict__ or name in member_type.__dict__: + value = safe_getattr(cls, name) + yield (name, value) + + def mangle(subject: Any, name: str) -> str: """Mangle the given name.""" try: @@ -198,12 +228,8 @@ def get_object_members( if name not in members: members[name] = Attribute(name, True, value) - # enumerations are created as `EnumName([mixin_type, ...] [data_type,] enum_type)` - superclass = subject.__mro__[-2] - for name in obj_dict: - if name not in superclass.__dict__: - value = safe_getattr(subject, name) - members[name] = Attribute(name, True, value) + for name, value in _filter_enum_dict(subject, obj_dict): + members[name] = Attribute(name, True, value) # members in __slots__ try: @@ -260,12 +286,8 @@ def get_class_members(subject: Any, objpath: Any, attrgetter: Callable, if name not in members: members[name] = ObjectMember(name, value, class_=subject) - # enumerations are created as `EnumName([mixin_type, ...] [data_type,] enum_type)` - superclass = subject.__mro__[-2] - for name in obj_dict: - if name not in superclass.__dict__: - value = safe_getattr(subject, name) - members[name] = ObjectMember(name, value, class_=subject) + for name, value in _filter_enum_dict(subject, obj_dict): + members[name] = ObjectMember(name, value, class_=subject) # members in __slots__ try: diff --git a/tests/roots/test-ext-autodoc/target/enums.py b/tests/roots/test-ext-autodoc/target/enums.py index 47f9084bd4b..00ef02690a5 100644 --- a/tests/roots/test-ext-autodoc/target/enums.py +++ b/tests/roots/test-ext-autodoc/target/enums.py @@ -45,13 +45,13 @@ def say_goodbye(cls): pass -class ToUpperCase(enum.Enum): +class ToUpperCase: # not inheriting from enum.Enum @property - def value(self): - return str(self._value_).upper() + def value(self): # bypass enum.Enum.value + return str(getattr(self, '_value_')).upper() -class EnumClassWithMixinType(ToUpperCase, str, enum.Enum): +class EnumClassWithMixinType(ToUpperCase, enum.Enum): """ this is enum class """ @@ -71,3 +71,64 @@ def say_hello(self): def say_goodbye(cls): """a classmethod says good-bye to you.""" pass + + +class MyMixinEnum(enum.Enum): + def foo(self): + return 1 + + +class EnumClassWithMixinEnumType(MyMixinEnum, enum.Enum): + """ + this is enum class + """ + + #: doc for val1 + val1 = 'ab' + val2 = 'cd' #: doc for val2 + val3 = 'ef' + """doc for val3""" + val4 = 'gh' + + def say_hello(self): + """a method says hello to you.""" + pass + + @classmethod + def say_goodbye(cls): + """a classmethod says good-bye to you.""" + pass + + def foo(self): + """new mixin method not found by ``dir``.""" + return 2 + + +class EnumClassWithMixinAndDataType(ToUpperCase, str, enum.Enum): + """ + this is enum class + """ + + #: doc for val1 + val1 = 'ab' + val2 = 'cd' #: doc for val2 + val3 = 'ef' + """doc for val3""" + val4 = 'gh' + + def say_hello(self): + """a method says hello to you.""" + pass + + @classmethod + def say_goodbye(cls): + """a classmethod says good-bye to you.""" + pass + + def isupper(self): + """New isupper method.""" + return False + + def __str__(self): + """New __str__ method.""" + pass diff --git a/tests/test_ext_autodoc.py b/tests/test_ext_autodoc.py index c90c7a4eda4..930b659f458 100644 --- a/tests/test_ext_autodoc.py +++ b/tests/test_ext_autodoc.py @@ -4,8 +4,11 @@ source file translated by test_build. """ +from __future__ import annotations + import sys from types import SimpleNamespace +from typing import TYPE_CHECKING from unittest.mock import Mock from warnings import catch_warnings @@ -24,6 +27,8 @@ except ImportError: pyximport = None +if TYPE_CHECKING: + from typing import Any def do_autodoc(app, objtype, name, options=None): if options is None: @@ -1402,200 +1407,156 @@ def test_slots(app): ] +class _EnumFormatter: + def __init__(self, name: str, module: str='target.enums'): + self.name = name + self.module = module + + def brief(self, doc: str, indent=0) -> list[str]: + prefix = indent * ' ' + if sys.version_info[:2] >= (3, 12): + args = ('(value, names=None, *values, module=None, ' + 'qualname=None, type=None, start=1, boundary=None)') + elif sys.version_info[:2] >= (3, 11): + args = ('(value, names=None, *, module=None, qualname=None, ' + 'type=None, start=1, boundary=None)') + else: + args = '(value)' + + return self._wrap_doc(prefix, [ + f'{prefix}.. py:class:: {self.name}{args}', + f'{prefix} :module: {self.module}', + ], doc) + + def method(self, name: str, doc: str, *options: str, indent=3) -> list[str]: + prefix = indent * ' ' + return self._wrap_doc(prefix, [ + f'{prefix}.. py:method:: {self.name}.{name}()', + f'{prefix} :module: {self.module}', + *[f'{prefix} :{option}:' for option in options], + ], doc) + + def member(self, name: str, value: Any, doc: str, indent=3) -> list[str]: + prefix = indent * ' ' + return self._wrap_doc(prefix, [ + f'{prefix}.. py:attribute:: {self.name}.{name}', + f'{prefix} :module: {self.module}', + f'{prefix} :value: {value!r}' + ], doc) + + def _wrap_doc(self, prefix: str, lines: list[str], doc: str) -> list[str]: + lines.insert(0, '') + if doc: + lines.extend(['', f'{prefix} {doc}']) + lines.append('') + return lines + + @pytest.mark.sphinx('html', testroot='ext-autodoc') def test_enum_class(app): + fmt = _EnumFormatter('EnumCls') options = {"members": None} - actual = do_autodoc(app, 'class', 'target.enums.EnumCls', options) - if sys.version_info[:2] >= (3, 12): - args = ('(value, names=None, *values, module=None, ' - 'qualname=None, type=None, start=1, boundary=None)') - elif sys.version_info[:2] >= (3, 11): - args = ('(value, names=None, *, module=None, qualname=None, ' - 'type=None, start=1, boundary=None)') - else: - args = '(value)' - - assert list(actual) == [ - '', - '.. py:class:: EnumCls' + args, - ' :module: target.enums', - '', - ' this is enum class', - '', - '', - ' .. py:method:: EnumCls.say_goodbye()', - ' :module: target.enums', - ' :classmethod:', - '', - ' a classmethod says good-bye to you.', - '', - '', - ' .. py:method:: EnumCls.say_hello()', - ' :module: target.enums', - '', - ' a method says hello to you.', - '', - '', - ' .. py:attribute:: EnumCls.val1', - ' :module: target.enums', - ' :value: 12', - '', - ' doc for val1', - '', - '', - ' .. py:attribute:: EnumCls.val2', - ' :module: target.enums', - ' :value: 23', - '', - ' doc for val2', - '', - '', - ' .. py:attribute:: EnumCls.val3', - ' :module: target.enums', - ' :value: 34', - '', - ' doc for val3', - '', - ] + actual = do_autodoc(app, 'class', 'target.enums.EnumCls', options) + assert list(actual) == sum(( + fmt.brief('this is enum class'), + fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), + fmt.method('say_hello', 'a method says hello to you.'), + fmt.member('val1', 12, 'doc for val1'), + fmt.member('val2', 23, 'doc for val2'), + fmt.member('val3', 34, 'doc for val3'), + ), []) # checks for an attribute of EnumClass actual = do_autodoc(app, 'attribute', 'target.enums.EnumCls.val1') - assert list(actual) == [ - '', - '.. py:attribute:: EnumCls.val1', - ' :module: target.enums', - ' :value: 12', - '', - ' doc for val1', - '', - ] + assert list(actual) == fmt.member('val1', 12, 'doc for val1', indent=0) @pytest.mark.sphinx('html', testroot='ext-autodoc') def test_enum_class_with_data_type(app): + fmt = _EnumFormatter('EnumClassWithDataType') options = {"members": None, "undoc-members": None, "private-members": None} - actual = do_autodoc(app, 'class', 'target.enums.EnumClassWithDataType', options) - if sys.version_info[:2] >= (3, 12): - args = ('(value, names=None, *values, module=None, ' - 'qualname=None, type=None, start=1, boundary=None)') - elif sys.version_info[:2] >= (3, 11): - args = ('(value, names=None, *, module=None, qualname=None, ' - 'type=None, start=1, boundary=None)') - else: - args = '(value)' - - assert list(actual) == [ - '', - '.. py:class:: EnumClassWithDataType' + args, - ' :module: target.enums', - '', - ' this is enum class', - '', - '', - ' .. py:method:: EnumClassWithDataType.say_goodbye()', - ' :module: target.enums', - ' :classmethod:', - '', - ' a classmethod says good-bye to you.', - '', - '', - ' .. py:method:: EnumClassWithDataType.say_hello()', - ' :module: target.enums', - '', - ' a method says hello to you.', - '', - '', - ' .. py:attribute:: EnumClassWithDataType.val1', - ' :module: target.enums', - f' :value: {"ab"!r}', - '', - ' doc for val1', - '', - '', - ' .. py:attribute:: EnumClassWithDataType.val2', - ' :module: target.enums', - f' :value: {"cd"!r}', - '', - ' doc for val2', - '', - '', - ' .. py:attribute:: EnumClassWithDataType.val3', - ' :module: target.enums', - f' :value: {"ef"!r}', - '', - ' doc for val3', - '', - '', - ' .. py:attribute:: EnumClassWithDataType.val4', - ' :module: target.enums', - f' :value: {"gh"!r}', - '', - ] + actual = do_autodoc(app, 'class', 'target.enums.EnumClassWithDataType', options) + assert list(actual) == sum(( + fmt.brief('this is enum class'), + fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), + fmt.method('say_hello', 'a method says hello to you.'), + fmt.member('val1', 'ab', 'doc for val1'), + fmt.member('val2', 'cd', 'doc for val2'), + fmt.member('val3', 'ef', 'doc for val3'), + fmt.member('val4', 'gh', ''), + ), []) @pytest.mark.sphinx('html', testroot='ext-autodoc') def test_enum_class_with_mixin_type(app): + fmt = _EnumFormatter('EnumClassWithMixinType') options = {"members": None, "undoc-members": None, "private-members": None} + actual = do_autodoc(app, 'class', 'target.enums.EnumClassWithMixinType', options) + assert list(actual) == sum(( + fmt.brief('this is enum class'), + fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), + fmt.method('say_hello', 'a method says hello to you.'), + fmt.member('val1', 'AB', 'doc for val1'), + fmt.member('val2', 'CD', 'doc for val2'), + fmt.member('val3', 'EF', 'doc for val3'), + fmt.member('val4', 'GH', ''), + ), []) - if sys.version_info[:2] >= (3, 12): - args = ('(value, names=None, *values, module=None, ' - 'qualname=None, type=None, start=1, boundary=None)') - elif sys.version_info[:2] >= (3, 11): - args = ('(value, names=None, *, module=None, qualname=None, ' - 'type=None, start=1, boundary=None)') - else: - args = '(value)' - assert list(actual) == [ - '', - '.. py:class:: EnumClassWithMixinType' + args, - ' :module: target.enums', - '', - ' this is enum class', - '', - '', - ' .. py:method:: EnumClassWithMixinType.say_goodbye()', - ' :module: target.enums', - ' :classmethod:', - '', - ' a classmethod says good-bye to you.', - '', - '', - ' .. py:method:: EnumClassWithMixinType.say_hello()', - ' :module: target.enums', - '', - ' a method says hello to you.', - '', - '', - ' .. py:attribute:: EnumClassWithMixinType.val1', - ' :module: target.enums', - f' :value: {"AB"!r}', - '', - ' doc for val1', - '', - '', - ' .. py:attribute:: EnumClassWithMixinType.val2', - ' :module: target.enums', - f' :value: {"CD"!r}', - '', - ' doc for val2', - '', - '', - ' .. py:attribute:: EnumClassWithMixinType.val3', - ' :module: target.enums', - f' :value: {"EF"!r}', - '', - ' doc for val3', - '', - '', - ' .. py:attribute:: EnumClassWithMixinType.val4', - ' :module: target.enums', - f' :value: {"GH"!r}', - '', - ] +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_enum_class_with_mixin_enum_type(app): + fmt = _EnumFormatter('EnumClassWithMixinEnumType') + options = {"members": None, "undoc-members": None, "private-members": None} + + actual = do_autodoc(app, 'class', 'target.enums.EnumClassWithMixinEnumType', options) + assert list(actual) == sum(( + fmt.brief('this is enum class'), + fmt.method('foo', 'new mixin method not found by ``dir``.'), + fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), + fmt.method('say_hello', 'a method says hello to you.'), + fmt.member('val1', 'ab', 'doc for val1'), + fmt.member('val2', 'cd', 'doc for val2'), + fmt.member('val3', 'ef', 'doc for val3'), + fmt.member('val4', 'gh', ''), + ), []) + + +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_enum_class_with_mixin_and_data_type(app): + fmt = _EnumFormatter('EnumClassWithMixinAndDataType') + base_options = {"members": None, "undoc-members": None, "private-members": None} + + # no special members + options1 = {"members": None, "undoc-members": None, "private-members": None} + actual = do_autodoc(app, 'class', 'target.enums.EnumClassWithMixinAndDataType', options1) + assert list(actual) == sum(( + fmt.brief('this is enum class'), + fmt.method('isupper', 'New isupper method.'), + fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), + fmt.method('say_hello', 'a method says hello to you.'), + fmt.member('val1', 'AB', 'doc for val1'), + fmt.member('val2', 'CD', 'doc for val2'), + fmt.member('val3', 'EF', 'doc for val3'), + fmt.member('val4', 'GH', ''), + ), []) + + # add the special member __str__ + options2 = options1 | {'special-members': '__str__'} + actual = do_autodoc(app, 'class', 'target.enums.EnumClassWithMixinAndDataType', options2) + assert list(actual) == sum(( + fmt.brief('this is enum class'), + fmt.method('__str__', 'New __str__ method.'), + fmt.method('isupper', 'New isupper method.'), + fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), + fmt.method('say_hello', 'a method says hello to you.'), + fmt.member('val1', 'AB', 'doc for val1'), + fmt.member('val2', 'CD', 'doc for val2'), + fmt.member('val3', 'EF', 'doc for val3'), + fmt.member('val4', 'GH', ''), + ), []) @pytest.mark.sphinx('html', testroot='ext-autodoc') From 1a160219aa449782038f09237a234044f4e38a0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Wed, 27 Sep 2023 13:28:41 +0200 Subject: [PATCH 07/29] fix lint --- sphinx/ext/autodoc/importer.py | 6 ++++-- tests/test_ext_autodoc.py | 5 ++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/sphinx/ext/autodoc/importer.py b/sphinx/ext/autodoc/importer.py index c99773f3bf9..dfbc87442b5 100644 --- a/sphinx/ext/autodoc/importer.py +++ b/sphinx/ext/autodoc/importer.py @@ -32,10 +32,12 @@ logger = logging.getLogger(__name__) -def _filter_enum_dict(cls: type[Enum], ns: dict[str, Any]) -> Generator[tuple[str, Any], None, None]: +def _filter_enum_dict( + cls: type[Enum], + ns: dict[str, Any], +) -> Generator[tuple[str, Any], None, None]: # enumerations are created as ``EnumName([mixin_type, ...] [data_type,] enum_type)`` member_type = getattr(cls, '_member_type_', object) - enum_mixins = {b for b in cls.__mro__[1:-1] if isenumclass(b)} for name in ns: # Include attributes that are not from Enum or those that are diff --git a/tests/test_ext_autodoc.py b/tests/test_ext_autodoc.py index 930b659f458..5a69cfc9f26 100644 --- a/tests/test_ext_autodoc.py +++ b/tests/test_ext_autodoc.py @@ -1408,7 +1408,7 @@ def test_slots(app): class _EnumFormatter: - def __init__(self, name: str, module: str='target.enums'): + def __init__(self, name: str, module: str = 'target.enums'): self.name = name self.module = module @@ -1441,7 +1441,7 @@ def member(self, name: str, value: Any, doc: str, indent=3) -> list[str]: return self._wrap_doc(prefix, [ f'{prefix}.. py:attribute:: {self.name}.{name}', f'{prefix} :module: {self.module}', - f'{prefix} :value: {value!r}' + f'{prefix} :value: {value!r}', ], doc) def _wrap_doc(self, prefix: str, lines: list[str], doc: str) -> list[str]: @@ -1527,7 +1527,6 @@ def test_enum_class_with_mixin_enum_type(app): @pytest.mark.sphinx('html', testroot='ext-autodoc') def test_enum_class_with_mixin_and_data_type(app): fmt = _EnumFormatter('EnumClassWithMixinAndDataType') - base_options = {"members": None, "undoc-members": None, "private-members": None} # no special members options1 = {"members": None, "undoc-members": None, "private-members": None} From 54853730ec66f80408ccb26edac61d83d4773393 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Wed, 27 Sep 2023 13:30:08 +0200 Subject: [PATCH 08/29] fix lint --- tests/test_ext_autodoc.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_ext_autodoc.py b/tests/test_ext_autodoc.py index 5a69cfc9f26..e63fecaef28 100644 --- a/tests/test_ext_autodoc.py +++ b/tests/test_ext_autodoc.py @@ -30,6 +30,7 @@ if TYPE_CHECKING: from typing import Any + def do_autodoc(app, objtype, name, options=None): if options is None: options = {} From ca770dfcb7a4c70da64f157a943b3a69ae063f4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 3 Feb 2024 11:16:53 +0100 Subject: [PATCH 09/29] safe guard `__dict__` access --- sphinx/ext/autodoc/importer.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sphinx/ext/autodoc/importer.py b/sphinx/ext/autodoc/importer.py index 870364fc531..9cfaf9b7b2f 100644 --- a/sphinx/ext/autodoc/importer.py +++ b/sphinx/ext/autodoc/importer.py @@ -38,6 +38,7 @@ def _filter_enum_dict( ) -> Generator[tuple[str, Any], None, None]: # enumerations are created as ``EnumName([mixin_type, ...] [data_type,] enum_type)`` member_type = getattr(cls, '_member_type_', object) + member_type_dict = safe_getattr(member_type, '__dict__', {}) for name in ns: # Include attributes that are not from Enum or those that are @@ -57,7 +58,7 @@ def _filter_enum_dict( # If the Enum API changes, then the filtering algorithm needs to # be updated so that attributes declared on mixin or member types # are correctly found. - if name not in Enum.__dict__ or name in member_type.__dict__: + if name not in Enum.__dict__ or name in member_type_dict: value = safe_getattr(cls, name) yield (name, value) From e4a7b7a0406f884fcecdeb4c08948637169b31bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 3 Feb 2024 11:19:23 +0100 Subject: [PATCH 10/29] fix lint --- tests/test_extensions/test_ext_autodoc.py | 114 +++++++++++----------- 1 file changed, 57 insertions(+), 57 deletions(-) diff --git a/tests/test_extensions/test_ext_autodoc.py b/tests/test_extensions/test_ext_autodoc.py index fdaa1f5e510..e5de8269261 100644 --- a/tests/test_extensions/test_ext_autodoc.py +++ b/tests/test_extensions/test_ext_autodoc.py @@ -1462,14 +1462,14 @@ def test_enum_class(app): options = {"members": None} actual = do_autodoc(app, 'class', 'target.enums.EnumCls', options) - assert list(actual) == sum(( - fmt.brief('this is enum class'), - fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), - fmt.method('say_hello', 'a method says hello to you.'), - fmt.member('val1', 12, 'doc for val1'), - fmt.member('val2', 23, 'doc for val2'), - fmt.member('val3', 34, 'doc for val3'), - ), []) + assert list(actual) == [ + *fmt.brief('this is enum class'), + *fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), + *fmt.method('say_hello', 'a method says hello to you.'), + *fmt.member('val1', 12, 'doc for val1'), + *fmt.member('val2', 23, 'doc for val2'), + *fmt.member('val3', 34, 'doc for val3'), + ] # checks for an attribute of EnumClass actual = do_autodoc(app, 'attribute', 'target.enums.EnumCls.val1') @@ -1482,15 +1482,15 @@ def test_enum_class_with_data_type(app): options = {"members": None, "undoc-members": None, "private-members": None} actual = do_autodoc(app, 'class', 'target.enums.EnumClassWithDataType', options) - assert list(actual) == sum(( - fmt.brief('this is enum class'), - fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), - fmt.method('say_hello', 'a method says hello to you.'), - fmt.member('val1', 'ab', 'doc for val1'), - fmt.member('val2', 'cd', 'doc for val2'), - fmt.member('val3', 'ef', 'doc for val3'), - fmt.member('val4', 'gh', ''), - ), []) + assert list(actual) == [ + *fmt.brief('this is enum class'), + *fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), + *fmt.method('say_hello', 'a method says hello to you.'), + *fmt.member('val1', 'ab', 'doc for val1'), + *fmt.member('val2', 'cd', 'doc for val2'), + *fmt.member('val3', 'ef', 'doc for val3'), + *fmt.member('val4', 'gh', ''), + ] @pytest.mark.sphinx('html', testroot='ext-autodoc') @@ -1499,15 +1499,15 @@ def test_enum_class_with_mixin_type(app): options = {"members": None, "undoc-members": None, "private-members": None} actual = do_autodoc(app, 'class', 'target.enums.EnumClassWithMixinType', options) - assert list(actual) == sum(( - fmt.brief('this is enum class'), - fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), - fmt.method('say_hello', 'a method says hello to you.'), - fmt.member('val1', 'AB', 'doc for val1'), - fmt.member('val2', 'CD', 'doc for val2'), - fmt.member('val3', 'EF', 'doc for val3'), - fmt.member('val4', 'GH', ''), - ), []) + assert list(actual) == [ + *fmt.brief('this is enum class'), + *fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), + *fmt.method('say_hello', 'a method says hello to you.'), + *fmt.member('val1', 'AB', 'doc for val1'), + *fmt.member('val2', 'CD', 'doc for val2'), + *fmt.member('val3', 'EF', 'doc for val3'), + *fmt.member('val4', 'GH', ''), + ] @pytest.mark.sphinx('html', testroot='ext-autodoc') @@ -1516,16 +1516,16 @@ def test_enum_class_with_mixin_enum_type(app): options = {"members": None, "undoc-members": None, "private-members": None} actual = do_autodoc(app, 'class', 'target.enums.EnumClassWithMixinEnumType', options) - assert list(actual) == sum(( - fmt.brief('this is enum class'), - fmt.method('foo', 'new mixin method not found by ``dir``.'), - fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), - fmt.method('say_hello', 'a method says hello to you.'), - fmt.member('val1', 'ab', 'doc for val1'), - fmt.member('val2', 'cd', 'doc for val2'), - fmt.member('val3', 'ef', 'doc for val3'), - fmt.member('val4', 'gh', ''), - ), []) + assert list(actual) == [ + *fmt.brief('this is enum class'), + *fmt.method('foo', 'new mixin method not found by ``dir``.'), + *fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), + *fmt.method('say_hello', 'a method says hello to you.'), + *fmt.member('val1', 'ab', 'doc for val1'), + *fmt.member('val2', 'cd', 'doc for val2'), + *fmt.member('val3', 'ef', 'doc for val3'), + *fmt.member('val4', 'gh', ''), + ] @pytest.mark.sphinx('html', testroot='ext-autodoc') @@ -1535,31 +1535,31 @@ def test_enum_class_with_mixin_and_data_type(app): # no special members options1 = {"members": None, "undoc-members": None, "private-members": None} actual = do_autodoc(app, 'class', 'target.enums.EnumClassWithMixinAndDataType', options1) - assert list(actual) == sum(( - fmt.brief('this is enum class'), - fmt.method('isupper', 'New isupper method.'), - fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), - fmt.method('say_hello', 'a method says hello to you.'), - fmt.member('val1', 'AB', 'doc for val1'), - fmt.member('val2', 'CD', 'doc for val2'), - fmt.member('val3', 'EF', 'doc for val3'), - fmt.member('val4', 'GH', ''), - ), []) + assert list(actual) == [ + *fmt.brief('this is enum class'), + *fmt.method('isupper', 'New isupper method.'), + *fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), + *fmt.method('say_hello', 'a method says hello to you.'), + *fmt.member('val1', 'AB', 'doc for val1'), + *fmt.member('val2', 'CD', 'doc for val2'), + *fmt.member('val3', 'EF', 'doc for val3'), + *fmt.member('val4', 'GH', ''), + ] # add the special member __str__ options2 = options1 | {'special-members': '__str__'} actual = do_autodoc(app, 'class', 'target.enums.EnumClassWithMixinAndDataType', options2) - assert list(actual) == sum(( - fmt.brief('this is enum class'), - fmt.method('__str__', 'New __str__ method.'), - fmt.method('isupper', 'New isupper method.'), - fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), - fmt.method('say_hello', 'a method says hello to you.'), - fmt.member('val1', 'AB', 'doc for val1'), - fmt.member('val2', 'CD', 'doc for val2'), - fmt.member('val3', 'EF', 'doc for val3'), - fmt.member('val4', 'GH', ''), - ), []) + assert list(actual) == [ + *fmt.brief('this is enum class'), + *fmt.method('__str__', 'New __str__ method.'), + *fmt.method('isupper', 'New isupper method.'), + *fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), + *fmt.method('say_hello', 'a method says hello to you.'), + *fmt.member('val1', 'AB', 'doc for val1'), + *fmt.member('val2', 'CD', 'doc for val2'), + *fmt.member('val3', 'EF', 'doc for val3'), + *fmt.member('val4', 'GH', ''), + ] @pytest.mark.sphinx('html', testroot='ext-autodoc') From a03f7f98f5309f35335991767714e321ca5e3d69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sun, 17 Mar 2024 15:59:03 +0100 Subject: [PATCH 11/29] Update test_ext_autodoc.py --- tests/test_extensions/test_ext_autodoc.py | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/tests/test_extensions/test_ext_autodoc.py b/tests/test_extensions/test_ext_autodoc.py index 3ddaa7a7215..d6e59b39154 100644 --- a/tests/test_extensions/test_ext_autodoc.py +++ b/tests/test_extensions/test_ext_autodoc.py @@ -1420,7 +1420,10 @@ def __init__(self, name: str, module: str = 'target.enums'): def brief(self, doc: str, indent=0) -> list[str]: prefix = indent * ' ' - if sys.version_info[:2] >= (3, 12): + if sys.version_info[:2] >= (3, 13): + args = ('(value, names=, *values, module=None, ' + 'qualname=None, type=None, start=1, boundary=None)') + elif sys.version_info[:2] >= (3, 12): args = ('(value, names=None, *values, module=None, ' 'qualname=None, type=None, start=1, boundary=None)') elif sys.version_info[:2] >= (3, 11): @@ -1462,19 +1465,6 @@ def _wrap_doc(self, prefix: str, lines: list[str], doc: str) -> list[str]: def test_enum_class(app): fmt = _EnumFormatter('EnumCls') options = {"members": None} - actual = do_autodoc(app, 'class', 'target.enums.EnumCls', options) - - if sys.version_info[:2] >= (3, 13): - args = ('(value, names=, *values, module=None, ' - 'qualname=None, type=None, start=1, boundary=None)') - elif sys.version_info[:2] >= (3, 12): - args = ('(value, names=None, *values, module=None, ' - 'qualname=None, type=None, start=1, boundary=None)') - elif sys.version_info[:2] >= (3, 11): - args = ('(value, names=None, *, module=None, qualname=None, ' - 'type=None, start=1, boundary=None)') - else: - args = '(value)' actual = do_autodoc(app, 'class', 'target.enums.EnumCls', options) assert list(actual) == [ From 098079a60b509484d2b7dd2482a2e4537565e79f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sun, 17 Mar 2024 16:00:48 +0100 Subject: [PATCH 12/29] Update test_ext_autodoc.py --- tests/test_extensions/test_ext_autodoc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_extensions/test_ext_autodoc.py b/tests/test_extensions/test_ext_autodoc.py index d6e59b39154..6587a39012b 100644 --- a/tests/test_extensions/test_ext_autodoc.py +++ b/tests/test_extensions/test_ext_autodoc.py @@ -1421,7 +1421,7 @@ def __init__(self, name: str, module: str = 'target.enums'): def brief(self, doc: str, indent=0) -> list[str]: prefix = indent * ' ' if sys.version_info[:2] >= (3, 13): - args = ('(value, names=, *values, module=None, ' + args = ('(value, names=, *values, module=None, ' 'qualname=None, type=None, start=1, boundary=None)') elif sys.version_info[:2] >= (3, 12): args = ('(value, names=None, *values, module=None, ' From d24392d97b1cb04a62551f3477c8ee915efe5ee6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Mon, 18 Mar 2024 14:33:31 +0100 Subject: [PATCH 13/29] patch implementation --- sphinx/ext/autodoc/importer.py | 158 +++++++++++++++---- tests/roots/test-ext-autodoc/target/enums.py | 95 ++++++----- tests/test_extensions/test_ext_autodoc.py | 122 ++++++++++++-- 3 files changed, 289 insertions(+), 86 deletions(-) diff --git a/sphinx/ext/autodoc/importer.py b/sphinx/ext/autodoc/importer.py index 9cfaf9b7b2f..daeb4821e77 100644 --- a/sphinx/ext/autodoc/importer.py +++ b/sphinx/ext/autodoc/importer.py @@ -9,7 +9,7 @@ import traceback import typing from enum import Enum -from typing import TYPE_CHECKING, Any, Callable, NamedTuple +from typing import TYPE_CHECKING, NamedTuple from sphinx.ext.autodoc.mock import ismock, undecorate from sphinx.pycode import ModuleAnalyzer, PycodeError @@ -24,43 +24,137 @@ ) if TYPE_CHECKING: - from collections.abc import Generator + from collections.abc import Callable, Collection, Generator from types import ModuleType + from typing import Any from sphinx.ext.autodoc import ObjectMember logger = logging.getLogger(__name__) +def _find_enum_member_type(enum_class: type[Enum]) -> type: + if hasattr(enum_class, '_member_type_'): + return enum_class._member_type_ + + data_types: set[type] = set() + # enumerations are created as ``EnumName([mixin_type, ...] [member_type,] enum_type)`` + for chain in enum_class.__mro__: + if chain in {object, enum_class}: + continue + + candidate = None + for base in chain.__mro__: + if base is object: + continue + if issubclass(base, Enum): + member_type = _find_enum_member_type(base) + if member_type is not object: + data_types.add(member_type) + break + elif '__new__' in base.__dict__: + if issubclass(base, Enum): + continue + data_types.add(candidate or base) + break + else: + candidate = candidate or base + + # because the enum class is a validated enum class from Python + assert len(data_types) <= 1, data_types + return data_types.pop() if data_types else object + + +def _find_mixin_attributes(enum_class: type[Enum]) -> dict[type, set[str]]: + """Find mixin attributes of an enum class. + + Include attributes that are not from Enum or those that are from the data + type or mixin types. The specifications guarantee that ``dir(enum_member)`` + contains the *inherited* and additional methods of the enum class. + + Example: + ------- + >>> import enum + + >>> class DataType(int): + ... def twice(self): + ... return 2 * self + + >>> class Mixin: + ... def foo(self): + ... return 'foo' + + >>> class MyOtherEnumMixin(DataType, enum.Enum): + ... pass + + >>> class MyEnumMixin(DataType, Mixin, enum.Enum): + ... pass + + >>> class MyEnum(MyEnumMixin, MyOtherEnumMixin, enum.Enum): + ... a = 1 + ... + ... def bar(self): + ... return 'bar' + + >>> assert _find_mixin_attributes(MyEnum).keys() == {Mixin, MyEnumMixin, MyOtherEnumMixin} + >>> 'foo' in _find_mixin_attributes(MyEnum)[Mixin] # doctest: +ELLIPSIS + {', 'foo': ...} + """ + mixin_attributes = {} + member_type = _find_enum_member_type(enum_class) + + def find_bases(cls: type, *, recursive_guard: frozenset[type] = frozenset()) -> set[type]: + if cls in recursive_guard: + return set() + + ret = set() + for base in cls.__bases__: + if base not in {object, cls, member_type, Enum}: + ret.add(base) + ret |= find_bases(base, recursive_guard=recursive_guard | {cls}) + return ret + + mixin_types = find_bases(enum_class) + + for base in enum_class.__mro__: + if base in mixin_types: + mixin_attributes[base] = set(safe_getattr(base, '__dict__', {})) + return mixin_attributes + + def _filter_enum_dict( - cls: type[Enum], - ns: dict[str, Any], -) -> Generator[tuple[str, Any], None, None]: - # enumerations are created as ``EnumName([mixin_type, ...] [data_type,] enum_type)`` - member_type = getattr(cls, '_member_type_', object) + enum_class: type[Enum], + enum_class_dict: Collection[str], +) -> Generator[tuple[str, type, Any], None, None]: + # enumerations are created as ``EnumName([mixin_type, ...] [member_type,] enum_type)`` + sentinel = object() + + def query(defining_class: type, name: str) -> tuple[str, type, Any] | None: + value = safe_getattr(enum_class, name, sentinel) + if value is not sentinel: + return (name, defining_class, value) + return None + + # attributes defined on a mixin type (they will be possibly shadowed by + # the attributes directly defined at the enum class level) + mixin_bases = _find_mixin_attributes(enum_class) + for mixin_type, mixin_attributes in mixin_bases.items(): + yield from filter(None, (query(mixin_type, name) for name in mixin_attributes + if name not in Enum.__dict__)) + + # attributes defined on the member type (data type) + # but only those that are overridden at the enum level + member_type = _find_enum_member_type(enum_class) member_type_dict = safe_getattr(member_type, '__dict__', {}) + yield from filter(None, (query(member_type, name) for name in member_type_dict + if name not in Enum.__dict__ or name in enum_class_dict)) + - for name in ns: - # Include attributes that are not from Enum or those that are - # from the data-type. Attributes that are from the mixin types - # will be discovered correctly, even if they are enum classes. - # - # We cannot rely on ``dir`` to get the members from mixin - # enumeration types because ``dir(MyMixinEnum)`` returns - # nothing (mixin enumerations must not have members). - # - # Now, by design, enumeration classes have by default *no* - # public methods (except properties name / value). As such, - # if a mixin class exposes an attribute that is overridden by - # the object being documented, then ``name in Enum.__dict__`` - # is always false. - # - # If the Enum API changes, then the filtering algorithm needs to - # be updated so that attributes declared on mixin or member types - # are correctly found. - if name not in Enum.__dict__ or name in member_type_dict: - value = safe_getattr(cls, name) - yield (name, value) + # attributes defined directly at the enumeration level, possibly + # shadowing any of the attributes that were on a mixin type or + # on the data type + yield from filter(None, (query(enum_class, name) for name in enum_class_dict + if name not in Enum.__dict__ or name in member_type_dict)) def mangle(subject: Any, name: str) -> str: @@ -229,8 +323,8 @@ def get_object_members( if name not in members: members[name] = Attribute(name, True, value) - for name, value in _filter_enum_dict(subject, obj_dict): - members[name] = Attribute(name, True, value) + for name, defining_class, value in _filter_enum_dict(subject, obj_dict): + members[name] = Attribute(name, defining_class is subject, value) # members in __slots__ try: @@ -287,8 +381,8 @@ def get_class_members(subject: Any, objpath: Any, attrgetter: Callable, if name not in members: members[name] = ObjectMember(name, value, class_=subject) - for name, value in _filter_enum_dict(subject, obj_dict): - members[name] = ObjectMember(name, value, class_=subject) + for name, defining_class, value in _filter_enum_dict(subject, obj_dict): + members[name] = ObjectMember(name, value, class_=defining_class) # members in __slots__ try: diff --git a/tests/roots/test-ext-autodoc/target/enums.py b/tests/roots/test-ext-autodoc/target/enums.py index 00ef02690a5..0b3c659ff9a 100644 --- a/tests/roots/test-ext-autodoc/target/enums.py +++ b/tests/roots/test-ext-autodoc/target/enums.py @@ -1,10 +1,8 @@ import enum -class EnumCls(enum.Enum): - """ - this is enum class - """ +class EnumClass(enum.Enum): + """this is enum class""" #: doc for val1 val1 = 12 @@ -15,18 +13,14 @@ class EnumCls(enum.Enum): def say_hello(self): """a method says hello to you.""" - pass @classmethod def say_goodbye(cls): """a classmethod says good-bye to you.""" - pass class EnumClassWithDataType(str, enum.Enum): - """ - this is enum class - """ + """this is enum class""" #: doc for val1 val1 = 'ab' @@ -37,12 +31,10 @@ class EnumClassWithDataType(str, enum.Enum): def say_hello(self): """a method says hello to you.""" - pass @classmethod def say_goodbye(cls): """a classmethod says good-bye to you.""" - pass class ToUpperCase: # not inheriting from enum.Enum @@ -51,10 +43,17 @@ def value(self): # bypass enum.Enum.value return str(getattr(self, '_value_')).upper() +class Greeter: + def say_hello(self): + """a method says hello to you.""" + + @classmethod + def say_goodbye(cls): + """a classmethod says good-bye to you.""" + + class EnumClassWithMixinType(ToUpperCase, enum.Enum): - """ - this is enum class - """ + """this is enum class""" #: doc for val1 val1 = 'ab' @@ -65,23 +64,30 @@ class EnumClassWithMixinType(ToUpperCase, enum.Enum): def say_hello(self): """a method says hello to you.""" - pass @classmethod def say_goodbye(cls): """a classmethod says good-bye to you.""" - pass -class MyMixinEnum(enum.Enum): - def foo(self): +class EnumClassWithMixinTypeInherit(Greeter, ToUpperCase, enum.Enum): + """this is enum class""" + + #: doc for val1 + val1 = 'ab' + val2 = 'cd' #: doc for val2 + val3 = 'ef' + """doc for val3""" + val4 = 'gh' + + +class Overridden(enum.Enum): + def override(self): return 1 -class EnumClassWithMixinEnumType(MyMixinEnum, enum.Enum): - """ - this is enum class - """ +class EnumClassWithMixinEnumType(Greeter, Overridden, enum.Enum): + """this is enum class""" #: doc for val1 val1 = 'ab' @@ -90,24 +96,13 @@ class EnumClassWithMixinEnumType(MyMixinEnum, enum.Enum): """doc for val3""" val4 = 'gh' - def say_hello(self): - """a method says hello to you.""" - pass - - @classmethod - def say_goodbye(cls): - """a classmethod says good-bye to you.""" - pass - - def foo(self): + def override(self): """new mixin method not found by ``dir``.""" return 2 -class EnumClassWithMixinAndDataType(ToUpperCase, str, enum.Enum): - """ - this is enum class - """ +class EnumClassWithMixinAndDataType(Greeter, ToUpperCase, str, enum.Enum): + """this is enum class""" #: doc for val1 val1 = 'ab' @@ -118,12 +113,10 @@ class EnumClassWithMixinAndDataType(ToUpperCase, str, enum.Enum): def say_hello(self): """a method says hello to you.""" - pass @classmethod def say_goodbye(cls): """a classmethod says good-bye to you.""" - pass def isupper(self): """New isupper method.""" @@ -131,4 +124,28 @@ def isupper(self): def __str__(self): """New __str__ method.""" - pass + + +class _EmptyMixinEnum(Greeter, Overridden, enum.Enum): + """empty mixin class""" + + +class ComplexEnumClass(_EmptyMixinEnum, ToUpperCase, str, enum.Enum): + """this is a complex enum class""" + #: doc for val1 + val1 = 'ab' + val2 = 'cd' #: doc for val2 + val3 = 'ef' + """doc for val3""" + val4 = 'gh' + + def isupper(self): + """New isupper method.""" + return False + + def __str__(self): + """New __str__ method.""" + + +class super_test(str): + pass \ No newline at end of file diff --git a/tests/test_extensions/test_ext_autodoc.py b/tests/test_extensions/test_ext_autodoc.py index 6587a39012b..eb9196e460c 100644 --- a/tests/test_extensions/test_ext_autodoc.py +++ b/tests/test_extensions/test_ext_autodoc.py @@ -111,7 +111,7 @@ def verify(objtype, name, result): verify('module', 'test_ext_autodoc', ('test_ext_autodoc', [], None, None)) verify('module', 'test.test_ext_autodoc', ('test.test_ext_autodoc', [], None, None)) verify('module', 'test(arg)', ('test', [], 'arg', None)) - assert 'signature arguments' in app._warning.getvalue() + assert 'signature arguments' in app.warning.getvalue() # for functions/classes verify('function', 'test_ext_autodoc.raises', @@ -1414,11 +1414,20 @@ def test_slots(app): class _EnumFormatter: - def __init__(self, name: str, module: str = 'target.enums'): + def __init__(self, name: str, *, module: str = 'target.enums') -> None: self.name = name self.module = module - def brief(self, doc: str, indent=0) -> list[str]: + @property + def target(self) -> str: + """The autodoc target class.""" + return f'{self.module}.{self.name}' + + def subtarget(self, name: str) -> str: + """The autodoc sub-target (an attribute, method, etc).""" + return f'{self.target}.{name}' + + def brief(self, doc: str, indent: int = 0) -> list[str]: prefix = indent * ' ' if sys.version_info[:2] >= (3, 13): args = ('(value, names=, *values, module=None, ' @@ -1437,7 +1446,7 @@ def brief(self, doc: str, indent=0) -> list[str]: f'{prefix} :module: {self.module}', ], doc) - def method(self, name: str, doc: str, *options: str, indent=3) -> list[str]: + def method(self, name: str, doc: str, *options: str, indent: int = 3) -> list[str]: prefix = indent * ' ' return self._wrap_doc(prefix, [ f'{prefix}.. py:method:: {self.name}.{name}()', @@ -1445,7 +1454,7 @@ def method(self, name: str, doc: str, *options: str, indent=3) -> list[str]: *[f'{prefix} :{option}:' for option in options], ], doc) - def member(self, name: str, value: Any, doc: str, indent=3) -> list[str]: + def member(self, name: str, value: Any, doc: str, *, indent: int = 3) -> list[str]: prefix = indent * ' ' return self._wrap_doc(prefix, [ f'{prefix}.. py:attribute:: {self.name}.{name}', @@ -1463,10 +1472,10 @@ def _wrap_doc(self, prefix: str, lines: list[str], doc: str) -> list[str]: @pytest.mark.sphinx('html', testroot='ext-autodoc') def test_enum_class(app): - fmt = _EnumFormatter('EnumCls') + fmt = _EnumFormatter('EnumClass') options = {"members": None} - actual = do_autodoc(app, 'class', 'target.enums.EnumCls', options) + actual = do_autodoc(app, 'class', fmt.target, options) assert list(actual) == [ *fmt.brief('this is enum class'), *fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), @@ -1474,10 +1483,11 @@ def test_enum_class(app): *fmt.member('val1', 12, 'doc for val1'), *fmt.member('val2', 23, 'doc for val2'), *fmt.member('val3', 34, 'doc for val3'), + # no val4 because it is not documented ] # checks for an attribute of EnumClass - actual = do_autodoc(app, 'attribute', 'target.enums.EnumCls.val1') + actual = do_autodoc(app, 'attribute', fmt.subtarget('val1')) assert list(actual) == fmt.member('val1', 12, 'doc for val1', indent=0) @@ -1486,7 +1496,7 @@ def test_enum_class_with_data_type(app): fmt = _EnumFormatter('EnumClassWithDataType') options = {"members": None, "undoc-members": None, "private-members": None} - actual = do_autodoc(app, 'class', 'target.enums.EnumClassWithDataType', options) + actual = do_autodoc(app, 'class', fmt.target, options) assert list(actual) == [ *fmt.brief('this is enum class'), *fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), @@ -1503,7 +1513,25 @@ def test_enum_class_with_mixin_type(app): fmt = _EnumFormatter('EnumClassWithMixinType') options = {"members": None, "undoc-members": None, "private-members": None} - actual = do_autodoc(app, 'class', 'target.enums.EnumClassWithMixinType', options) + actual = do_autodoc(app, 'class', fmt.target, options) + assert list(actual) == [ + *fmt.brief('this is enum class'), + *fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), + *fmt.method('say_hello', 'a method says hello to you.'), + *fmt.member('val1', 'AB', 'doc for val1'), + *fmt.member('val2', 'CD', 'doc for val2'), + *fmt.member('val3', 'EF', 'doc for val3'), + *fmt.member('val4', 'GH', ''), + ] + + +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_enum_class_with_mixin_type_and_inheritence(app): + fmt = _EnumFormatter('EnumClassWithMixinTypeInherit') + options = {"members": None, "inherited-members": None, + "undoc-members": None, "private-members": None} + + actual = do_autodoc(app, 'class', fmt.target, options) assert list(actual) == [ *fmt.brief('this is enum class'), *fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), @@ -1520,10 +1548,25 @@ def test_enum_class_with_mixin_enum_type(app): fmt = _EnumFormatter('EnumClassWithMixinEnumType') options = {"members": None, "undoc-members": None, "private-members": None} - actual = do_autodoc(app, 'class', 'target.enums.EnumClassWithMixinEnumType', options) + actual = do_autodoc(app, 'class', fmt.target, options) + + assert list(actual) == [ + *fmt.brief('this is enum class'), + # override() is overridden at the class level so it should be rendered + *fmt.method('override', 'new mixin method not found by ``dir``.'), + # say_goodbye() and say_hello() are not rendered since they are inherited + *fmt.member('val1', 'ab', 'doc for val1'), + *fmt.member('val2', 'cd', 'doc for val2'), + *fmt.member('val3', 'ef', 'doc for val3'), + *fmt.member('val4', 'gh', ''), + ] + + actual = do_autodoc(app, 'class', fmt.target, {"inherited-members": None} | options) assert list(actual) == [ *fmt.brief('this is enum class'), - *fmt.method('foo', 'new mixin method not found by ``dir``.'), + # override() is overridden at the class level so it should be rendered + *fmt.method('override', 'new mixin method not found by ``dir``.'), + # say_goodbye() and say_hello() are now rendered *fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), *fmt.method('say_hello', 'a method says hello to you.'), *fmt.member('val1', 'ab', 'doc for val1'), @@ -1539,11 +1582,14 @@ def test_enum_class_with_mixin_and_data_type(app): # no special members options1 = {"members": None, "undoc-members": None, "private-members": None} - actual = do_autodoc(app, 'class', 'target.enums.EnumClassWithMixinAndDataType', options1) + actual = do_autodoc(app, 'class', fmt.target, options1) assert list(actual) == [ *fmt.brief('this is enum class'), + # defined at the class level *fmt.method('isupper', 'New isupper method.'), + # redefined at the class level so always included *fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), + # redefined at the class level so always included *fmt.method('say_hello', 'a method says hello to you.'), *fmt.member('val1', 'AB', 'doc for val1'), *fmt.member('val2', 'CD', 'doc for val2'), @@ -1551,14 +1597,17 @@ def test_enum_class_with_mixin_and_data_type(app): *fmt.member('val4', 'GH', ''), ] - # add the special member __str__ + # # add the special member __str__ (but not the inherited members) options2 = options1 | {'special-members': '__str__'} - actual = do_autodoc(app, 'class', 'target.enums.EnumClassWithMixinAndDataType', options2) + actual = do_autodoc(app, 'class', fmt.target, options2) assert list(actual) == [ *fmt.brief('this is enum class'), *fmt.method('__str__', 'New __str__ method.'), + # defined at the class level *fmt.method('isupper', 'New isupper method.'), + # redefined at the class level so always included *fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), + # redefined at the class level so always included *fmt.method('say_hello', 'a method says hello to you.'), *fmt.member('val1', 'AB', 'doc for val1'), *fmt.member('val2', 'CD', 'doc for val2'), @@ -1567,6 +1616,49 @@ def test_enum_class_with_mixin_and_data_type(app): ] +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_enum_complex_enum_class(app): + fmt = _EnumFormatter('ComplexEnumClass') + + # no inherited or special members + options1 = {"members": None, "undoc-members": None, "private-members": None} + actual = do_autodoc(app, 'class', fmt.target, options1) + assert list(actual) == [ + *fmt.brief('this is a complex enum class'), + *fmt.method('isupper', 'New isupper method.'), + *fmt.member('val1', 'AB', 'doc for val1'), + *fmt.member('val2', 'CD', 'doc for val2'), + *fmt.member('val3', 'EF', 'doc for val3'), + *fmt.member('val4', 'GH', ''), + ] + + # add the special member __str__ (but not the inherited members) + options2 = options1 | {'special-members': '__str__'} + actual = do_autodoc(app, 'class', fmt.target, options2) + assert list(actual) == [ + *fmt.brief('this is a complex enum class'), + *fmt.method('__str__', 'New __str__ method.'), + *fmt.method('isupper', 'New isupper method.'), + *fmt.member('val1', 'AB', 'doc for val1'), + *fmt.member('val2', 'CD', 'doc for val2'), + *fmt.member('val3', 'EF', 'doc for val3'), + *fmt.member('val4', 'GH', ''), + ] + + # no special members + actual = do_autodoc(app, 'class', fmt.target, options1 | {'inherited-members': None}) + + lines = list(actual) + block_say_goodbye, block_say_hello = fmt.method( + 'say_goodbye', + 'a classmethod says good-bye to you.', + 'classmethod', + ), fmt.method('say_hello', 'a method says hello to you.') + + assert block_say_goodbye <= lines + assert block_say_hello <= lines + + @pytest.mark.sphinx('html', testroot='ext-autodoc') def test_descriptor_class(app): options = {"members": 'CustomDataDescriptor,CustomDataDescriptor2'} From 033681075f67e54999c99b086aca04b6410ab8aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Mon, 18 Mar 2024 17:36:42 +0100 Subject: [PATCH 14/29] patch implementation --- sphinx/ext/autodoc/importer.py | 128 +++++++++---------- tests/roots/test-ext-autodoc/target/enums.py | 40 +++++- tests/test_extensions/test_ext_autodoc.py | 125 ++++++++++++++---- 3 files changed, 195 insertions(+), 98 deletions(-) diff --git a/sphinx/ext/autodoc/importer.py b/sphinx/ext/autodoc/importer.py index daeb4821e77..5787a3a8352 100644 --- a/sphinx/ext/autodoc/importer.py +++ b/sphinx/ext/autodoc/importer.py @@ -24,7 +24,7 @@ ) if TYPE_CHECKING: - from collections.abc import Callable, Collection, Generator + from collections.abc import Callable, Generator, Mapping from types import ModuleType from typing import Any @@ -34,35 +34,7 @@ def _find_enum_member_type(enum_class: type[Enum]) -> type: - if hasattr(enum_class, '_member_type_'): - return enum_class._member_type_ - - data_types: set[type] = set() - # enumerations are created as ``EnumName([mixin_type, ...] [member_type,] enum_type)`` - for chain in enum_class.__mro__: - if chain in {object, enum_class}: - continue - - candidate = None - for base in chain.__mro__: - if base is object: - continue - if issubclass(base, Enum): - member_type = _find_enum_member_type(base) - if member_type is not object: - data_types.add(member_type) - break - elif '__new__' in base.__dict__: - if issubclass(base, Enum): - continue - data_types.add(candidate or base) - break - else: - candidate = candidate or base - - # because the enum class is a validated enum class from Python - assert len(data_types) <= 1, data_types - return data_types.pop() if data_types else object + return getattr(enum_class, '_member_type_', object) def _find_mixin_attributes(enum_class: type[Enum]) -> dict[type, set[str]]: @@ -71,34 +43,6 @@ def _find_mixin_attributes(enum_class: type[Enum]) -> dict[type, set[str]]: Include attributes that are not from Enum or those that are from the data type or mixin types. The specifications guarantee that ``dir(enum_member)`` contains the *inherited* and additional methods of the enum class. - - Example: - ------- - >>> import enum - - >>> class DataType(int): - ... def twice(self): - ... return 2 * self - - >>> class Mixin: - ... def foo(self): - ... return 'foo' - - >>> class MyOtherEnumMixin(DataType, enum.Enum): - ... pass - - >>> class MyEnumMixin(DataType, Mixin, enum.Enum): - ... pass - - >>> class MyEnum(MyEnumMixin, MyOtherEnumMixin, enum.Enum): - ... a = 1 - ... - ... def bar(self): - ... return 'bar' - - >>> assert _find_mixin_attributes(MyEnum).keys() == {Mixin, MyEnumMixin, MyOtherEnumMixin} - >>> 'foo' in _find_mixin_attributes(MyEnum)[Mixin] # doctest: +ELLIPSIS - {', 'foo': ...} """ mixin_attributes = {} member_type = _find_enum_member_type(enum_class) @@ -124,7 +68,7 @@ def find_bases(cls: type, *, recursive_guard: frozenset[type] = frozenset()) -> def _filter_enum_dict( enum_class: type[Enum], - enum_class_dict: Collection[str], + enum_class_dict: Mapping[str, object], ) -> Generator[tuple[str, type, Any], None, None]: # enumerations are created as ``EnumName([mixin_type, ...] [member_type,] enum_type)`` sentinel = object() @@ -135,26 +79,68 @@ def query(defining_class: type, name: str) -> tuple[str, type, Any] | None: return (name, defining_class, value) return None + # attributes that were found on a mixin type or the data type + candidate_in_mro: set[str] = set() + # attributes that can be picked up on a mixin type or the enum's data type + public_names = {'name', 'value', *object.__dict__} + # names that are ignored by default + ignore_names = Enum.__dict__.keys() - public_names + + # see: https://docs.python.org/3/howto/enum.html#supported-dunder-names + sunder_names = {'_name_', '_value_', '_missing_', '_order_', '_generate_next_value_'} + # sunder names that were picked up (and thereby allowed to be redefined) + can_override: set[str] = set() + + def should_ignore(name: str, klass_dict: Mapping[str, Any]) -> bool: + if name not in klass_dict: + return True + if name in sunder_names: + return klass_dict[name] is Enum.__dict__[name] + return name in ignore_names + # attributes defined on a mixin type (they will be possibly shadowed by # the attributes directly defined at the enum class level) - mixin_bases = _find_mixin_attributes(enum_class) - for mixin_type, mixin_attributes in mixin_bases.items(): - yield from filter(None, (query(mixin_type, name) for name in mixin_attributes - if name not in Enum.__dict__)) + for mixin_type, mixin_attributes in _find_mixin_attributes(enum_class).items(): + mixin_type_dict = safe_getattr(mixin_type, '__dict__', {}) + + for name in mixin_attributes: + if should_ignore(name, mixin_type_dict): + continue + + if name in sunder_names or name in public_names: + can_override.add(name) + + candidate_in_mro.add(name) + if (item := query(mixin_type, name)) is not None: + yield item - # attributes defined on the member type (data type) - # but only those that are overridden at the enum level + # get attributes defined on the member type (data type) member_type = _find_enum_member_type(enum_class) member_type_dict = safe_getattr(member_type, '__dict__', {}) - yield from filter(None, (query(member_type, name) for name in member_type_dict - if name not in Enum.__dict__ or name in enum_class_dict)) + for name in safe_getattr(member_type, '__dict__', {}): + if should_ignore(name, member_type_dict): + continue + + if name in sunder_names or name in public_names: + can_override.add(name) + candidate_in_mro.add(name) + if (item := query(member_type, name)) is not None: + yield item - # attributes defined directly at the enumeration level, possibly - # shadowing any of the attributes that were on a mixin type or - # on the data type + # exclude members coming from the native Enum unless + # they were redefined on a mixin type or the data type + excluded_members = Enum.__dict__.keys() - candidate_in_mro yield from filter(None, (query(enum_class, name) for name in enum_class_dict - if name not in Enum.__dict__ or name in member_type_dict)) + if name not in excluded_members)) + + # check if the inherited members were redefined at the enum level + for name in (sunder_names | public_names | can_override) & enum_class_dict.keys(): + if ( + enum_class_dict[name] is not Enum.__dict__[name] + and (item := query(enum_class, name)) is not None + ): + yield item def mangle(subject: Any, name: str) -> str: diff --git a/tests/roots/test-ext-autodoc/target/enums.py b/tests/roots/test-ext-autodoc/target/enums.py index 0b3c659ff9a..aee49c5cdc3 100644 --- a/tests/roots/test-ext-autodoc/target/enums.py +++ b/tests/roots/test-ext-autodoc/target/enums.py @@ -132,6 +132,7 @@ class _EmptyMixinEnum(Greeter, Overridden, enum.Enum): class ComplexEnumClass(_EmptyMixinEnum, ToUpperCase, str, enum.Enum): """this is a complex enum class""" + #: doc for val1 val1 = 'ab' val2 = 'cd' #: doc for val2 @@ -147,5 +148,40 @@ def __str__(self): """New __str__ method.""" -class super_test(str): - pass \ No newline at end of file +class EnumClassRedefineMixinConflict(ToUpperCase, enum.Enum): + """this is an enum class""" + + #: doc for val1 + val1 = 'ab' + val2 = 'cd' #: doc for val2 + val3 = 'ef' + """doc for val3""" + val4 = 'gh' + + +class _MissingRedefineInNonEnumMixin: + @classmethod + def _missing_(cls, value): + return super()._missing_(value) + + +class _MissingRedefineInEnumMixin(enum.Enum): + @classmethod + def _missing_(cls, value): + return super()._missing_(value) + + +class EnumRedefineMissingInNonEnumMixin(_MissingRedefineInNonEnumMixin, enum.Enum): + a = 1 + + +class EnumRedefineMissingInEnumMixin(_MissingRedefineInEnumMixin, enum.Enum): + a = 1 + + +class EnumRedefineMissingInClass(enum.Enum): + a = 1 + + @classmethod + def _missing_(cls, value): + return super()._missing_(value) diff --git a/tests/test_extensions/test_ext_autodoc.py b/tests/test_extensions/test_ext_autodoc.py index eb9196e460c..83c39261d9e 100644 --- a/tests/test_extensions/test_ext_autodoc.py +++ b/tests/test_extensions/test_ext_autodoc.py @@ -7,8 +7,10 @@ from __future__ import annotations import functools +import itertools import operator import sys +from enum import Enum from types import SimpleNamespace from typing import TYPE_CHECKING from unittest.mock import Mock @@ -1414,6 +1416,8 @@ def test_slots(app): class _EnumFormatter: + DEFAULT_ENUM_DOC = Enum('SimpleEnum', []).__doc__ + def __init__(self, name: str, *, module: str = 'target.enums') -> None: self.name = name self.module = module @@ -1427,8 +1431,44 @@ def subtarget(self, name: str) -> str: """The autodoc sub-target (an attribute, method, etc).""" return f'{self.target}.{name}' - def brief(self, doc: str, indent: int = 0) -> list[str]: + def _node( + self, role: str, name: str, doc: str, *, args: str, indent: int, **options: Any, + ) -> list[str]: prefix = indent * ' ' + tab = ' ' * 3 + + def rst_option(name: str, value: Any) -> str: + value = '' if value in {1, True} else value + return f'{prefix}{tab}:{name}: {value!s}'.rstrip() + + lines = [ + '', + f'{prefix}.. py:{role}:: {name}{args}', + f'{prefix}{tab}:module: {self.module}', + *itertools.starmap(rst_option, options.items()), + ] + if doc: + lines.extend(['', f'{prefix}{tab}{doc}']) + lines.append('') + return lines + + def entry( + self, + entry_name: str, + doc: str | None = None, + *, + role: str, + args: str = '', + indent: int = 3, + **rst_options: Any, + ) -> list[str]: + """Get the RST lines for a named attribute, method, etc.""" + qualname = f'{self.name}.{entry_name}' + return self._node(role, qualname, doc, args=args, indent=indent, **rst_options) + + def brief(self, doc: str | None = None, *, indent: int = 0) -> list[str]: + doc = self.DEFAULT_ENUM_DOC if doc is None else doc + if sys.version_info[:2] >= (3, 13): args = ('(value, names=, *values, module=None, ' 'qualname=None, type=None, start=1, boundary=None)') @@ -1441,33 +1481,17 @@ def brief(self, doc: str, indent: int = 0) -> list[str]: else: args = '(value)' - return self._wrap_doc(prefix, [ - f'{prefix}.. py:class:: {self.name}{args}', - f'{prefix} :module: {self.module}', - ], doc) + return self._node('class', self.name, doc, args=args, indent=indent) - def method(self, name: str, doc: str, *options: str, indent: int = 3) -> list[str]: - prefix = indent * ' ' - return self._wrap_doc(prefix, [ - f'{prefix}.. py:method:: {self.name}.{name}()', - f'{prefix} :module: {self.module}', - *[f'{prefix} :{option}:' for option in options], - ], doc) + def method( + self, name: str, doc: str, *flags: str, args: str = '()', indent: int = 3, + ) -> list[str]: + rst_options = dict.fromkeys(flags, '') + return self.entry(name, doc, role='method', args=args, indent=indent, **rst_options) def member(self, name: str, value: Any, doc: str, *, indent: int = 3) -> list[str]: - prefix = indent * ' ' - return self._wrap_doc(prefix, [ - f'{prefix}.. py:attribute:: {self.name}.{name}', - f'{prefix} :module: {self.module}', - f'{prefix} :value: {value!r}', - ], doc) - - def _wrap_doc(self, prefix: str, lines: list[str], doc: str) -> list[str]: - lines.insert(0, '') - if doc: - lines.extend(['', f'{prefix} {doc}']) - lines.append('') - return lines + rst_options = {'value': repr(value)} + return self.entry(name, doc, role='attribute', indent=indent, **rst_options) @pytest.mark.sphinx('html', testroot='ext-autodoc') @@ -1540,6 +1564,8 @@ def test_enum_class_with_mixin_type_and_inheritence(app): *fmt.member('val2', 'CD', 'doc for val2'), *fmt.member('val3', 'EF', 'doc for val3'), *fmt.member('val4', 'GH', ''), + # inherited from ToUpperCase + *fmt.entry('value', '', role='property'), ] @@ -1659,6 +1685,55 @@ def test_enum_complex_enum_class(app): assert block_say_hello <= lines +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_enum_redefine_native_method(app): + options = {"members": None, "undoc-members": None, "private-members": None} + + fmt = _EnumFormatter('EnumRedefineMissingInNonEnumMixin') + actual = do_autodoc(app, 'class', fmt.target, options) + assert list(actual) == [*fmt.brief(), *fmt.member('a', 1, '')] + + fmt = _EnumFormatter('EnumRedefineMissingInEnumMixin') + actual = do_autodoc(app, 'class', fmt.target, options) + assert list(actual) == [*fmt.brief(), *fmt.member('a', 1, '')] + + fmt = _EnumFormatter('EnumRedefineMissingInClass') + actual = do_autodoc(app, 'class', fmt.target, options) + assert list(actual) == [ + *fmt.brief(), + *fmt.method('_missing_', '', 'classmethod', args='(value)'), + *fmt.member('a', 1, ''), + ] + + +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_enum_redefine_enum_from_mixin(app): + fmt = _EnumFormatter('EnumClassRedefineMixinConflict') + + # no inherited or special members + options1 = {"members": None, "undoc-members": None, "private-members": None} + actual = do_autodoc(app, 'class', fmt.target, options1) + assert list(actual) == [ + *fmt.brief('this is an enum class'), + *fmt.member('val1', 'AB', 'doc for val1'), + *fmt.member('val2', 'CD', 'doc for val2'), + *fmt.member('val3', 'EF', 'doc for val3'), + *fmt.member('val4', 'GH', ''), + ] + + # inherited special 'value' + options2 = {'inherited-members': None} | options1 + actual = do_autodoc(app, 'class', fmt.target, options2) + assert list(actual) == [ + *fmt.brief('this is an enum class'), + *fmt.member('val1', 'AB', 'doc for val1'), + *fmt.member('val2', 'CD', 'doc for val2'), + *fmt.member('val3', 'EF', 'doc for val3'), + *fmt.member('val4', 'GH', ''), + *fmt.entry('value', '', role='property'), + ] + + @pytest.mark.sphinx('html', testroot='ext-autodoc') def test_descriptor_class(app): options = {"members": 'CustomDataDescriptor,CustomDataDescriptor2'} From 4f6a020fc12b8faf1d322ca81caf017256b7af0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Mon, 18 Mar 2024 17:49:53 +0100 Subject: [PATCH 15/29] use explicit default docstring for enum classes --- tests/roots/test-ext-autodoc/target/enums.py | 12 ++++++++++++ tests/test_extensions/test_ext_autodoc.py | 15 ++++++--------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/tests/roots/test-ext-autodoc/target/enums.py b/tests/roots/test-ext-autodoc/target/enums.py index aee49c5cdc3..3f1b86b5d84 100644 --- a/tests/roots/test-ext-autodoc/target/enums.py +++ b/tests/roots/test-ext-autodoc/target/enums.py @@ -160,26 +160,38 @@ class EnumClassRedefineMixinConflict(ToUpperCase, enum.Enum): class _MissingRedefineInNonEnumMixin: + """docstring""" + @classmethod def _missing_(cls, value): + """docstring""" return super()._missing_(value) class _MissingRedefineInEnumMixin(enum.Enum): + """docstring""" + @classmethod def _missing_(cls, value): + """docstring""" return super()._missing_(value) class EnumRedefineMissingInNonEnumMixin(_MissingRedefineInNonEnumMixin, enum.Enum): + """docstring""" + a = 1 class EnumRedefineMissingInEnumMixin(_MissingRedefineInEnumMixin, enum.Enum): + """docstring""" + a = 1 class EnumRedefineMissingInClass(enum.Enum): + """docstring""" + a = 1 @classmethod diff --git a/tests/test_extensions/test_ext_autodoc.py b/tests/test_extensions/test_ext_autodoc.py index 83c39261d9e..2e1e08961cd 100644 --- a/tests/test_extensions/test_ext_autodoc.py +++ b/tests/test_extensions/test_ext_autodoc.py @@ -10,7 +10,6 @@ import itertools import operator import sys -from enum import Enum from types import SimpleNamespace from typing import TYPE_CHECKING from unittest.mock import Mock @@ -1416,8 +1415,6 @@ def test_slots(app): class _EnumFormatter: - DEFAULT_ENUM_DOC = Enum('SimpleEnum', []).__doc__ - def __init__(self, name: str, *, module: str = 'target.enums') -> None: self.name = name self.module = module @@ -1455,7 +1452,7 @@ def rst_option(name: str, value: Any) -> str: def entry( self, entry_name: str, - doc: str | None = None, + doc: str = '', *, role: str, args: str = '', @@ -1466,8 +1463,8 @@ def entry( qualname = f'{self.name}.{entry_name}' return self._node(role, qualname, doc, args=args, indent=indent, **rst_options) - def brief(self, doc: str | None = None, *, indent: int = 0) -> list[str]: - doc = self.DEFAULT_ENUM_DOC if doc is None else doc + def brief(self, doc: str, *, indent: int = 0) -> list[str]: + assert doc, f'enumeration class {self.target!r} should have an explicit docstring' if sys.version_info[:2] >= (3, 13): args = ('(value, names=, *values, module=None, ' @@ -1691,16 +1688,16 @@ def test_enum_redefine_native_method(app): fmt = _EnumFormatter('EnumRedefineMissingInNonEnumMixin') actual = do_autodoc(app, 'class', fmt.target, options) - assert list(actual) == [*fmt.brief(), *fmt.member('a', 1, '')] + assert list(actual) == [*fmt.brief('docstring'), *fmt.member('a', 1, '')] fmt = _EnumFormatter('EnumRedefineMissingInEnumMixin') actual = do_autodoc(app, 'class', fmt.target, options) - assert list(actual) == [*fmt.brief(), *fmt.member('a', 1, '')] + assert list(actual) == [*fmt.brief('docstring'), *fmt.member('a', 1, '')] fmt = _EnumFormatter('EnumRedefineMissingInClass') actual = do_autodoc(app, 'class', fmt.target, options) assert list(actual) == [ - *fmt.brief(), + *fmt.brief('docstring'), *fmt.method('_missing_', '', 'classmethod', args='(value)'), *fmt.member('a', 1, ''), ] From 0b3ad9ea0c51ca19b9ca74a4c088f88e0f2f39e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Mon, 18 Mar 2024 18:35:08 +0100 Subject: [PATCH 16/29] cleanup --- sphinx/ext/autodoc/importer.py | 3 +- tests/roots/test-ext-autodoc/target/enums.py | 105 +++++---- tests/test_extensions/test_ext_autodoc.py | 229 +++++++++---------- 3 files changed, 167 insertions(+), 170 deletions(-) diff --git a/sphinx/ext/autodoc/importer.py b/sphinx/ext/autodoc/importer.py index 5787a3a8352..13be8624296 100644 --- a/sphinx/ext/autodoc/importer.py +++ b/sphinx/ext/autodoc/importer.py @@ -135,7 +135,8 @@ def should_ignore(name: str, klass_dict: Mapping[str, Any]) -> bool: if name not in excluded_members)) # check if the inherited members were redefined at the enum level - for name in (sunder_names | public_names | can_override) & enum_class_dict.keys(): + special_names = sunder_names | public_names | can_override + for name in special_names & enum_class_dict.keys() & Enum.__dict__.keys(): if ( enum_class_dict[name] is not Enum.__dict__[name] and (item := query(enum_class, name)) is not None diff --git a/tests/roots/test-ext-autodoc/target/enums.py b/tests/roots/test-ext-autodoc/target/enums.py index 3f1b86b5d84..3eed2ddfa81 100644 --- a/tests/roots/test-ext-autodoc/target/enums.py +++ b/tests/roots/test-ext-autodoc/target/enums.py @@ -22,12 +22,7 @@ def say_goodbye(cls): class EnumClassWithDataType(str, enum.Enum): """this is enum class""" - #: doc for val1 - val1 = 'ab' - val2 = 'cd' #: doc for val2 - val3 = 'ef' - """doc for val3""" - val4 = 'gh' + x = 'x' def say_hello(self): """a method says hello to you.""" @@ -55,12 +50,7 @@ def say_goodbye(cls): class EnumClassWithMixinType(ToUpperCase, enum.Enum): """this is enum class""" - #: doc for val1 - val1 = 'ab' - val2 = 'cd' #: doc for val2 - val3 = 'ef' - """doc for val3""" - val4 = 'gh' + x = 'x' def say_hello(self): """a method says hello to you.""" @@ -73,28 +63,19 @@ def say_goodbye(cls): class EnumClassWithMixinTypeInherit(Greeter, ToUpperCase, enum.Enum): """this is enum class""" - #: doc for val1 - val1 = 'ab' - val2 = 'cd' #: doc for val2 - val3 = 'ef' - """doc for val3""" - val4 = 'gh' + x = 'x' class Overridden(enum.Enum): def override(self): + """old override""" return 1 class EnumClassWithMixinEnumType(Greeter, Overridden, enum.Enum): """this is enum class""" - #: doc for val1 - val1 = 'ab' - val2 = 'cd' #: doc for val2 - val3 = 'ef' - """doc for val3""" - val4 = 'gh' + x = 'x' def override(self): """new mixin method not found by ``dir``.""" @@ -104,12 +85,7 @@ def override(self): class EnumClassWithMixinAndDataType(Greeter, ToUpperCase, str, enum.Enum): """this is enum class""" - #: doc for val1 - val1 = 'ab' - val2 = 'cd' #: doc for val2 - val3 = 'ef' - """doc for val3""" - val4 = 'gh' + x = 'x' def say_hello(self): """a method says hello to you.""" @@ -124,21 +100,17 @@ def isupper(self): def __str__(self): """New __str__ method.""" + return super().__str__() class _EmptyMixinEnum(Greeter, Overridden, enum.Enum): """empty mixin class""" -class ComplexEnumClass(_EmptyMixinEnum, ToUpperCase, str, enum.Enum): - """this is a complex enum class""" +class EnumClassWithParentEnum(_EmptyMixinEnum, ToUpperCase, str, enum.Enum): + """docstring""" - #: doc for val1 - val1 = 'ab' - val2 = 'cd' #: doc for val2 - val3 = 'ef' - """doc for val3""" - val4 = 'gh' + x = 'x' def isupper(self): """New isupper method.""" @@ -146,17 +118,11 @@ def isupper(self): def __str__(self): """New __str__ method.""" + return super().__str__() class EnumClassRedefineMixinConflict(ToUpperCase, enum.Enum): - """this is an enum class""" - - #: doc for val1 - val1 = 'ab' - val2 = 'cd' #: doc for val2 - val3 = 'ef' - """doc for val3""" - val4 = 'gh' + """docstring""" class _MissingRedefineInNonEnumMixin: @@ -164,7 +130,7 @@ class _MissingRedefineInNonEnumMixin: @classmethod def _missing_(cls, value): - """docstring""" + """base docstring""" return super()._missing_(value) @@ -173,27 +139,58 @@ class _MissingRedefineInEnumMixin(enum.Enum): @classmethod def _missing_(cls, value): - """docstring""" + """base docstring""" return super()._missing_(value) class EnumRedefineMissingInNonEnumMixin(_MissingRedefineInNonEnumMixin, enum.Enum): """docstring""" - a = 1 - class EnumRedefineMissingInEnumMixin(_MissingRedefineInEnumMixin, enum.Enum): """docstring""" - a = 1 - class EnumRedefineMissingInClass(enum.Enum): """docstring""" - a = 1 - @classmethod def _missing_(cls, value): + """docstring""" return super()._missing_(value) + + +class _NameRedefineInNonEnumMixin: + """docstring""" + + @property + def name(self): + """base docstring""" + return super().name + + +class _NameRedefineInEnumMixin(enum.Enum): + """docstring""" + + @property + def name(self): + """base docstring""" + return super().name + + +class EnumRedefineNameInNonEnumMixin(_NameRedefineInNonEnumMixin, enum.Enum): + """docstring""" + + +class EnumRedefineNameInEnumMixin(_NameRedefineInEnumMixin, enum.Enum): + """docstring""" + + +class EnumRedefineNameInClass(enum.Enum): + """docstring""" + + + @property + def name(self): + """docstring""" + return super().name diff --git a/tests/test_extensions/test_ext_autodoc.py b/tests/test_extensions/test_ext_autodoc.py index 2e1e08961cd..b75a7c0216c 100644 --- a/tests/test_extensions/test_ext_autodoc.py +++ b/tests/test_extensions/test_ext_autodoc.py @@ -35,8 +35,7 @@ def do_autodoc(app, objtype, name, options=None): - if options is None: - options = {} + options = {} if options is None else options.copy() app.env.temp_data.setdefault('docname', 'index') # set dummy docname doccls = app.registry.documenters[objtype] docoptions = process_documenter_options(doccls, app.config, options) @@ -1491,12 +1490,17 @@ def member(self, name: str, value: Any, doc: str, *, indent: int = 3) -> list[st return self.entry(name, doc, role='attribute', indent=indent, **rst_options) +@pytest.fixture(scope='module') +def autodoc_enum_options(): + # always include 'private-members' to be sure that we are documenting + # the expected fields + return {"members": None, "undoc-members": None, "private-members": None} + + @pytest.mark.sphinx('html', testroot='ext-autodoc') -def test_enum_class(app): +def test_enum_class(app, autodoc_enum_options): fmt = _EnumFormatter('EnumClass') - options = {"members": None} - - actual = do_autodoc(app, 'class', fmt.target, options) + actual = do_autodoc(app, 'class', fmt.target, autodoc_enum_options) assert list(actual) == [ *fmt.brief('this is enum class'), *fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), @@ -1504,87 +1508,66 @@ def test_enum_class(app): *fmt.member('val1', 12, 'doc for val1'), *fmt.member('val2', 23, 'doc for val2'), *fmt.member('val3', 34, 'doc for val3'), - # no val4 because it is not documented + *fmt.member('val4', 34, ''), # val4 is alias of val3 ] - # checks for an attribute of EnumClass actual = do_autodoc(app, 'attribute', fmt.subtarget('val1')) assert list(actual) == fmt.member('val1', 12, 'doc for val1', indent=0) @pytest.mark.sphinx('html', testroot='ext-autodoc') -def test_enum_class_with_data_type(app): +def test_enum_class_with_data_type(app, autodoc_enum_options): fmt = _EnumFormatter('EnumClassWithDataType') - options = {"members": None, "undoc-members": None, "private-members": None} - - actual = do_autodoc(app, 'class', fmt.target, options) + actual = do_autodoc(app, 'class', fmt.target, autodoc_enum_options) assert list(actual) == [ *fmt.brief('this is enum class'), *fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), *fmt.method('say_hello', 'a method says hello to you.'), - *fmt.member('val1', 'ab', 'doc for val1'), - *fmt.member('val2', 'cd', 'doc for val2'), - *fmt.member('val3', 'ef', 'doc for val3'), - *fmt.member('val4', 'gh', ''), + *fmt.member('x', 'x', ''), ] @pytest.mark.sphinx('html', testroot='ext-autodoc') -def test_enum_class_with_mixin_type(app): +def test_enum_class_with_mixin_type(app, autodoc_enum_options): fmt = _EnumFormatter('EnumClassWithMixinType') - options = {"members": None, "undoc-members": None, "private-members": None} - - actual = do_autodoc(app, 'class', fmt.target, options) + actual = do_autodoc(app, 'class', fmt.target, autodoc_enum_options) assert list(actual) == [ *fmt.brief('this is enum class'), *fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), *fmt.method('say_hello', 'a method says hello to you.'), - *fmt.member('val1', 'AB', 'doc for val1'), - *fmt.member('val2', 'CD', 'doc for val2'), - *fmt.member('val3', 'EF', 'doc for val3'), - *fmt.member('val4', 'GH', ''), + *fmt.member('x', 'X', ''), ] @pytest.mark.sphinx('html', testroot='ext-autodoc') -def test_enum_class_with_mixin_type_and_inheritence(app): +def test_enum_class_with_mixin_type_and_inheritence(app, autodoc_enum_options): fmt = _EnumFormatter('EnumClassWithMixinTypeInherit') - options = {"members": None, "inherited-members": None, - "undoc-members": None, "private-members": None} - + options = autodoc_enum_options | {'inherited-members': None} actual = do_autodoc(app, 'class', fmt.target, options) assert list(actual) == [ *fmt.brief('this is enum class'), *fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), *fmt.method('say_hello', 'a method says hello to you.'), - *fmt.member('val1', 'AB', 'doc for val1'), - *fmt.member('val2', 'CD', 'doc for val2'), - *fmt.member('val3', 'EF', 'doc for val3'), - *fmt.member('val4', 'GH', ''), - # inherited from ToUpperCase - *fmt.entry('value', '', role='property'), + *fmt.entry('value', '', role='property'), # inherited from ToUpperCase + *fmt.member('x', 'X', ''), ] @pytest.mark.sphinx('html', testroot='ext-autodoc') -def test_enum_class_with_mixin_enum_type(app): +def test_enum_class_with_mixin_enum_type(app, autodoc_enum_options): fmt = _EnumFormatter('EnumClassWithMixinEnumType') - options = {"members": None, "undoc-members": None, "private-members": None} - - actual = do_autodoc(app, 'class', fmt.target, options) + actual = do_autodoc(app, 'class', fmt.target, autodoc_enum_options) assert list(actual) == [ *fmt.brief('this is enum class'), # override() is overridden at the class level so it should be rendered *fmt.method('override', 'new mixin method not found by ``dir``.'), # say_goodbye() and say_hello() are not rendered since they are inherited - *fmt.member('val1', 'ab', 'doc for val1'), - *fmt.member('val2', 'cd', 'doc for val2'), - *fmt.member('val3', 'ef', 'doc for val3'), - *fmt.member('val4', 'gh', ''), + *fmt.member('x', 'x', ''), ] - actual = do_autodoc(app, 'class', fmt.target, {"inherited-members": None} | options) + options = autodoc_enum_options | {"inherited-members": None} + actual = do_autodoc(app, 'class', fmt.target, options) assert list(actual) == [ *fmt.brief('this is enum class'), # override() is overridden at the class level so it should be rendered @@ -1592,20 +1575,16 @@ def test_enum_class_with_mixin_enum_type(app): # say_goodbye() and say_hello() are now rendered *fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), *fmt.method('say_hello', 'a method says hello to you.'), - *fmt.member('val1', 'ab', 'doc for val1'), - *fmt.member('val2', 'cd', 'doc for val2'), - *fmt.member('val3', 'ef', 'doc for val3'), - *fmt.member('val4', 'gh', ''), + *fmt.member('x', 'x', ''), ] @pytest.mark.sphinx('html', testroot='ext-autodoc') -def test_enum_class_with_mixin_and_data_type(app): +def test_enum_class_with_mixin_and_data_type(app, autodoc_enum_options): fmt = _EnumFormatter('EnumClassWithMixinAndDataType') # no special members - options1 = {"members": None, "undoc-members": None, "private-members": None} - actual = do_autodoc(app, 'class', fmt.target, options1) + actual = do_autodoc(app, 'class', fmt.target, autodoc_enum_options) assert list(actual) == [ *fmt.brief('this is enum class'), # defined at the class level @@ -1614,15 +1593,12 @@ def test_enum_class_with_mixin_and_data_type(app): *fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), # redefined at the class level so always included *fmt.method('say_hello', 'a method says hello to you.'), - *fmt.member('val1', 'AB', 'doc for val1'), - *fmt.member('val2', 'CD', 'doc for val2'), - *fmt.member('val3', 'EF', 'doc for val3'), - *fmt.member('val4', 'GH', ''), + *fmt.member('x', 'X', ''), ] # # add the special member __str__ (but not the inherited members) - options2 = options1 | {'special-members': '__str__'} - actual = do_autodoc(app, 'class', fmt.target, options2) + options = autodoc_enum_options | {'special-members': '__str__'} + actual = do_autodoc(app, 'class', fmt.target, options) assert list(actual) == [ *fmt.brief('this is enum class'), *fmt.method('__str__', 'New __str__ method.'), @@ -1632,101 +1608,124 @@ def test_enum_class_with_mixin_and_data_type(app): *fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), # redefined at the class level so always included *fmt.method('say_hello', 'a method says hello to you.'), - *fmt.member('val1', 'AB', 'doc for val1'), - *fmt.member('val2', 'CD', 'doc for val2'), - *fmt.member('val3', 'EF', 'doc for val3'), - *fmt.member('val4', 'GH', ''), + *fmt.member('x', 'X', ''), ] @pytest.mark.sphinx('html', testroot='ext-autodoc') -def test_enum_complex_enum_class(app): - fmt = _EnumFormatter('ComplexEnumClass') +def test_enum_with_parent_enum(app, autodoc_enum_options): + fmt = _EnumFormatter('EnumClassWithParentEnum') # no inherited or special members - options1 = {"members": None, "undoc-members": None, "private-members": None} - actual = do_autodoc(app, 'class', fmt.target, options1) + actual = do_autodoc(app, 'class', fmt.target, autodoc_enum_options) assert list(actual) == [ - *fmt.brief('this is a complex enum class'), + *fmt.brief('docstring'), *fmt.method('isupper', 'New isupper method.'), - *fmt.member('val1', 'AB', 'doc for val1'), - *fmt.member('val2', 'CD', 'doc for val2'), - *fmt.member('val3', 'EF', 'doc for val3'), - *fmt.member('val4', 'GH', ''), + *fmt.member('x', 'X', ''), ] # add the special member __str__ (but not the inherited members) - options2 = options1 | {'special-members': '__str__'} - actual = do_autodoc(app, 'class', fmt.target, options2) + options = autodoc_enum_options | {'special-members': '__str__'} + actual = do_autodoc(app, 'class', fmt.target, options) assert list(actual) == [ - *fmt.brief('this is a complex enum class'), + *fmt.brief('docstring'), *fmt.method('__str__', 'New __str__ method.'), *fmt.method('isupper', 'New isupper method.'), - *fmt.member('val1', 'AB', 'doc for val1'), - *fmt.member('val2', 'CD', 'doc for val2'), - *fmt.member('val3', 'EF', 'doc for val3'), - *fmt.member('val4', 'GH', ''), + *fmt.member('x', 'X', ''), ] - # no special members - actual = do_autodoc(app, 'class', fmt.target, options1 | {'inherited-members': None}) - - lines = list(actual) - block_say_goodbye, block_say_hello = fmt.method( - 'say_goodbye', - 'a classmethod says good-bye to you.', - 'classmethod', - ), fmt.method('say_hello', 'a method says hello to you.') - - assert block_say_goodbye <= lines - assert block_say_hello <= lines + options = autodoc_enum_options | {'inherited-members': None} + actual = do_autodoc(app, 'class', fmt.target, options) + for block in [ + fmt.brief('docstring'), + fmt.method('__str__', 'New __str__ method.'), + fmt.method('isupper', 'New isupper method.'), + fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), + fmt.method('say_hello', 'a method says hello to you.'), + fmt.member('x', 'X', ''), + ]: + assert block <= actual @pytest.mark.sphinx('html', testroot='ext-autodoc') -def test_enum_redefine_native_method(app): - options = {"members": None, "undoc-members": None, "private-members": None} - +def test_enum_sunder_method(app, autodoc_enum_options): fmt = _EnumFormatter('EnumRedefineMissingInNonEnumMixin') - actual = do_autodoc(app, 'class', fmt.target, options) - assert list(actual) == [*fmt.brief('docstring'), *fmt.member('a', 1, '')] + actual = do_autodoc(app, 'class', fmt.target, autodoc_enum_options) + assert list(actual) == [*fmt.brief('docstring')] fmt = _EnumFormatter('EnumRedefineMissingInEnumMixin') - actual = do_autodoc(app, 'class', fmt.target, options) - assert list(actual) == [*fmt.brief('docstring'), *fmt.member('a', 1, '')] + actual = do_autodoc(app, 'class', fmt.target, autodoc_enum_options) + assert list(actual) == [*fmt.brief('docstring')] fmt = _EnumFormatter('EnumRedefineMissingInClass') + actual = do_autodoc(app, 'class', fmt.target, autodoc_enum_options) + assert list(actual) == [ + *fmt.brief('docstring'), + *fmt.method('_missing_', 'docstring', 'classmethod', args='(value)'), + ] + + +@pytest.mark.parametrize(('target_name', 'docstring'), [ + ('EnumRedefineMissingInNonEnumMixin', 'base docstring'), + ('EnumRedefineMissingInEnumMixin', 'base docstring'), + ('EnumRedefineMissingInClass', 'docstring'), +]) +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_enum_inherited_sunder_method(app, target_name, docstring, autodoc_enum_options): + fmt = _EnumFormatter(target_name) + options = autodoc_enum_options | {"inherited-members": None} actual = do_autodoc(app, 'class', fmt.target, options) assert list(actual) == [ *fmt.brief('docstring'), - *fmt.method('_missing_', '', 'classmethod', args='(value)'), - *fmt.member('a', 1, ''), + *fmt.method('_missing_', docstring, 'classmethod', args='(value)'), ] @pytest.mark.sphinx('html', testroot='ext-autodoc') -def test_enum_redefine_enum_from_mixin(app): - fmt = _EnumFormatter('EnumClassRedefineMixinConflict') +def test_enum_public_property(app, autodoc_enum_options): + fmt = _EnumFormatter('EnumRedefineNameInNonEnumMixin') + actual = do_autodoc(app, 'class', fmt.target, autodoc_enum_options) + assert list(actual) == [*fmt.brief('docstring')] - # no inherited or special members - options1 = {"members": None, "undoc-members": None, "private-members": None} - actual = do_autodoc(app, 'class', fmt.target, options1) + fmt = _EnumFormatter('EnumRedefineNameInEnumMixin') + actual = do_autodoc(app, 'class', fmt.target, autodoc_enum_options) + assert list(actual) == [*fmt.brief('docstring')] + + fmt = _EnumFormatter('EnumRedefineNameInClass') + actual = do_autodoc(app, 'class', fmt.target, autodoc_enum_options) assert list(actual) == [ - *fmt.brief('this is an enum class'), - *fmt.member('val1', 'AB', 'doc for val1'), - *fmt.member('val2', 'CD', 'doc for val2'), - *fmt.member('val3', 'EF', 'doc for val3'), - *fmt.member('val4', 'GH', ''), + *fmt.brief('docstring'), + *fmt.entry('name', 'docstring', role='property'), ] - # inherited special 'value' - options2 = {'inherited-members': None} | options1 - actual = do_autodoc(app, 'class', fmt.target, options2) + +@pytest.mark.parametrize(('target_name', 'docstring'), [ + ('EnumRedefineNameInNonEnumMixin', 'base docstring'), + ('EnumRedefineNameInEnumMixin', 'base docstring'), + ('EnumRedefineNameInClass', 'docstring'), +]) +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_enum_inherited_public_property(app, target_name, docstring, autodoc_enum_options): + fmt = _EnumFormatter(target_name) + options = autodoc_enum_options | {"inherited-members": None} + actual = do_autodoc(app, 'class', fmt.target, options) + assert list(actual) == [ + *fmt.brief('docstring'), + *fmt.entry('name', docstring, role='property'), + ] + + +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_enum_redefine_enum_from_mixin(app, autodoc_enum_options): + fmt = _EnumFormatter('EnumClassRedefineMixinConflict') + + actual = do_autodoc(app, 'class', fmt.target, autodoc_enum_options) + assert list(actual) == [*fmt.brief('docstring')] + + options = autodoc_enum_options | {"inherited-members": None} + actual = do_autodoc(app, 'class', fmt.target, options) assert list(actual) == [ - *fmt.brief('this is an enum class'), - *fmt.member('val1', 'AB', 'doc for val1'), - *fmt.member('val2', 'CD', 'doc for val2'), - *fmt.member('val3', 'EF', 'doc for val3'), - *fmt.member('val4', 'GH', ''), + *fmt.brief('docstring'), *fmt.entry('value', '', role='property'), ] From 8d3a208e43a961f3b318c842a8774f0c33d40f91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Mon, 18 Mar 2024 18:42:27 +0100 Subject: [PATCH 17/29] Fix class name --- tests/roots/test-ext-autodoc/target/enums.py | 2 +- tests/test_extensions/test_ext_autodoc.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/roots/test-ext-autodoc/target/enums.py b/tests/roots/test-ext-autodoc/target/enums.py index 3eed2ddfa81..8e91a4ec5f0 100644 --- a/tests/roots/test-ext-autodoc/target/enums.py +++ b/tests/roots/test-ext-autodoc/target/enums.py @@ -1,7 +1,7 @@ import enum -class EnumClass(enum.Enum): +class EnumCls(enum.Enum): """this is enum class""" #: doc for val1 diff --git a/tests/test_extensions/test_ext_autodoc.py b/tests/test_extensions/test_ext_autodoc.py index b75a7c0216c..00055cee466 100644 --- a/tests/test_extensions/test_ext_autodoc.py +++ b/tests/test_extensions/test_ext_autodoc.py @@ -1499,7 +1499,7 @@ def autodoc_enum_options(): @pytest.mark.sphinx('html', testroot='ext-autodoc') def test_enum_class(app, autodoc_enum_options): - fmt = _EnumFormatter('EnumClass') + fmt = _EnumFormatter('EnumCls') actual = do_autodoc(app, 'class', fmt.target, autodoc_enum_options) assert list(actual) == [ *fmt.brief('this is enum class'), @@ -1510,7 +1510,7 @@ def test_enum_class(app, autodoc_enum_options): *fmt.member('val3', 34, 'doc for val3'), *fmt.member('val4', 34, ''), # val4 is alias of val3 ] - # checks for an attribute of EnumClass + # checks for an attribute of EnumCls actual = do_autodoc(app, 'attribute', fmt.subtarget('val1')) assert list(actual) == fmt.member('val1', 12, 'doc for val1', indent=0) From eda9645a3358e6a6760d8ef7ed84d20177b9228b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Mon, 18 Mar 2024 18:51:36 +0100 Subject: [PATCH 18/29] fix for 3.12,3.13? --- sphinx/ext/autodoc/importer.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sphinx/ext/autodoc/importer.py b/sphinx/ext/autodoc/importer.py index 13be8624296..564cf792ec8 100644 --- a/sphinx/ext/autodoc/importer.py +++ b/sphinx/ext/autodoc/importer.py @@ -81,14 +81,14 @@ def query(defining_class: type, name: str) -> tuple[str, type, Any] | None: # attributes that were found on a mixin type or the data type candidate_in_mro: set[str] = set() + # see: https://docs.python.org/3/howto/enum.html#supported-dunder-names + sunder_names = {'_name_', '_value_', '_missing_', '_order_', '_generate_next_value_'} + # sunder names that were picked up (and thereby allowed to be redefined) # attributes that can be picked up on a mixin type or the enum's data type - public_names = {'name', 'value', *object.__dict__} + public_names = {'name', 'value', *object.__dict__, *sunder_names} # names that are ignored by default ignore_names = Enum.__dict__.keys() - public_names - # see: https://docs.python.org/3/howto/enum.html#supported-dunder-names - sunder_names = {'_name_', '_value_', '_missing_', '_order_', '_generate_next_value_'} - # sunder names that were picked up (and thereby allowed to be redefined) can_override: set[str] = set() def should_ignore(name: str, klass_dict: Mapping[str, Any]) -> bool: From 5e1f324bc1ecff88c23ca270dc9adbe94db379bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Tue, 19 Mar 2024 10:55:12 +0100 Subject: [PATCH 19/29] fixup python3.12 --- sphinx/ext/autodoc/importer.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/sphinx/ext/autodoc/importer.py b/sphinx/ext/autodoc/importer.py index 564cf792ec8..e545792b340 100644 --- a/sphinx/ext/autodoc/importer.py +++ b/sphinx/ext/autodoc/importer.py @@ -21,6 +21,7 @@ isclass, isenumclass, safe_getattr, + unwrap_all, ) if TYPE_CHECKING: @@ -91,11 +92,15 @@ def query(defining_class: type, name: str) -> tuple[str, type, Any] | None: can_override: set[str] = set() + def is_native_api(obj: object, name: str) -> bool: + """Check whether *obj* is the same as ``Enum.__dict__[name]``.""" + return unwrap_all(obj) is unwrap_all(Enum.__dict__[name]) + def should_ignore(name: str, klass_dict: Mapping[str, Any]) -> bool: if name not in klass_dict: return True if name in sunder_names: - return klass_dict[name] is Enum.__dict__[name] + return is_native_api(klass_dict[name], name) return name in ignore_names # attributes defined on a mixin type (they will be possibly shadowed by @@ -133,12 +138,14 @@ def should_ignore(name: str, klass_dict: Mapping[str, Any]) -> bool: excluded_members = Enum.__dict__.keys() - candidate_in_mro yield from filter(None, (query(enum_class, name) for name in enum_class_dict if name not in excluded_members)) + assert '_generate_next_value_' in excluded_members + assert '_generate_next_value_' not in candidate_in_mro # check if the inherited members were redefined at the enum level special_names = sunder_names | public_names | can_override for name in special_names & enum_class_dict.keys() & Enum.__dict__.keys(): if ( - enum_class_dict[name] is not Enum.__dict__[name] + not is_native_api(enum_class_dict[name], name) and (item := query(enum_class, name)) is not None ): yield item From 0e673dd92675d5d0b74c86f405bbed558f0d86ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Tue, 19 Mar 2024 11:23:59 +0100 Subject: [PATCH 20/29] simplify logic --- sphinx/ext/autodoc/importer.py | 70 +++++++++++----------------------- 1 file changed, 22 insertions(+), 48 deletions(-) diff --git a/sphinx/ext/autodoc/importer.py b/sphinx/ext/autodoc/importer.py index e545792b340..0102bd7036d 100644 --- a/sphinx/ext/autodoc/importer.py +++ b/sphinx/ext/autodoc/importer.py @@ -34,37 +34,29 @@ logger = logging.getLogger(__name__) -def _find_enum_member_type(enum_class: type[Enum]) -> type: - return getattr(enum_class, '_member_type_', object) +def _get_parent_attributes(enum_class: type[Enum]) -> dict[type, dict[str, Any]]: + """Get the mixin attributes of an enumeration class. - -def _find_mixin_attributes(enum_class: type[Enum]) -> dict[type, set[str]]: - """Find mixin attributes of an enum class. - - Include attributes that are not from Enum or those that are from the data - type or mixin types. The specifications guarantee that ``dir(enum_member)`` - contains the *inherited* and additional methods of the enum class. + Include attributes that are found on mixin types or the data (member) type. """ - mixin_attributes = {} - member_type = _find_enum_member_type(enum_class) - def find_bases(cls: type, *, recursive_guard: frozenset[type] = frozenset()) -> set[type]: + def getbases(cls: type, *, recursive_guard: frozenset[type] = frozenset()) -> set[type]: if cls in recursive_guard: return set() ret = set() for base in cls.__bases__: - if base not in {object, cls, member_type, Enum}: + if base not in {object, cls, Enum}: ret.add(base) - ret |= find_bases(base, recursive_guard=recursive_guard | {cls}) + ret |= getbases(base, recursive_guard=recursive_guard | {cls}) return ret - mixin_types = find_bases(enum_class) - + attributes = {} + base_types = getbases(enum_class) for base in enum_class.__mro__: - if base in mixin_types: - mixin_attributes[base] = set(safe_getattr(base, '__dict__', {})) - return mixin_attributes + if base in base_types: + attributes[base] = safe_getattr(base, '__dict__', {}) + return attributes def _filter_enum_dict( @@ -74,7 +66,7 @@ def _filter_enum_dict( # enumerations are created as ``EnumName([mixin_type, ...] [member_type,] enum_type)`` sentinel = object() - def query(defining_class: type, name: str) -> tuple[str, type, Any] | None: + def query(name: str, defining_class: type) -> tuple[str, type, Any] | None: value = safe_getattr(enum_class, name, sentinel) if value is not sentinel: return (name, defining_class, value) @@ -96,47 +88,29 @@ def is_native_api(obj: object, name: str) -> bool: """Check whether *obj* is the same as ``Enum.__dict__[name]``.""" return unwrap_all(obj) is unwrap_all(Enum.__dict__[name]) - def should_ignore(name: str, klass_dict: Mapping[str, Any]) -> bool: - if name not in klass_dict: - return True + def should_ignore(name: str, value: Any) -> bool: if name in sunder_names: - return is_native_api(klass_dict[name], name) + return is_native_api(value, name) return name in ignore_names - # attributes defined on a mixin type (they will be possibly shadowed by - # the attributes directly defined at the enum class level) - for mixin_type, mixin_attributes in _find_mixin_attributes(enum_class).items(): - mixin_type_dict = safe_getattr(mixin_type, '__dict__', {}) - - for name in mixin_attributes: - if should_ignore(name, mixin_type_dict): + # attributes defined on a parent type, possibly shadowed later by + # the attributes defined directly inside the enumeration class + for parent, parent_dict in _get_parent_attributes(enum_class).items(): + for name, value in parent_dict.items(): + if should_ignore(name, value): continue if name in sunder_names or name in public_names: can_override.add(name) candidate_in_mro.add(name) - if (item := query(mixin_type, name)) is not None: + if (item := query(name, parent)) is not None: yield item - # get attributes defined on the member type (data type) - member_type = _find_enum_member_type(enum_class) - member_type_dict = safe_getattr(member_type, '__dict__', {}) - for name in safe_getattr(member_type, '__dict__', {}): - if should_ignore(name, member_type_dict): - continue - - if name in sunder_names or name in public_names: - can_override.add(name) - - candidate_in_mro.add(name) - if (item := query(member_type, name)) is not None: - yield item - # exclude members coming from the native Enum unless # they were redefined on a mixin type or the data type excluded_members = Enum.__dict__.keys() - candidate_in_mro - yield from filter(None, (query(enum_class, name) for name in enum_class_dict + yield from filter(None, (query(name, enum_class) for name in enum_class_dict if name not in excluded_members)) assert '_generate_next_value_' in excluded_members assert '_generate_next_value_' not in candidate_in_mro @@ -146,7 +120,7 @@ def should_ignore(name: str, klass_dict: Mapping[str, Any]) -> bool: for name in special_names & enum_class_dict.keys() & Enum.__dict__.keys(): if ( not is_native_api(enum_class_dict[name], name) - and (item := query(enum_class, name)) is not None + and (item := query(name, enum_class)) is not None ): yield item From 8e115bed359765ccb36821ee46f3d369b543a8b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Tue, 19 Mar 2024 11:24:06 +0100 Subject: [PATCH 21/29] cleanup --- sphinx/ext/autodoc/importer.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/sphinx/ext/autodoc/importer.py b/sphinx/ext/autodoc/importer.py index 0102bd7036d..24965876165 100644 --- a/sphinx/ext/autodoc/importer.py +++ b/sphinx/ext/autodoc/importer.py @@ -112,8 +112,6 @@ def should_ignore(name: str, value: Any) -> bool: excluded_members = Enum.__dict__.keys() - candidate_in_mro yield from filter(None, (query(name, enum_class) for name in enum_class_dict if name not in excluded_members)) - assert '_generate_next_value_' in excluded_members - assert '_generate_next_value_' not in candidate_in_mro # check if the inherited members were redefined at the enum level special_names = sunder_names | public_names | can_override From f30cce6006acf22d7dd744e22046b2167eec5c2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Tue, 19 Mar 2024 11:27:03 +0100 Subject: [PATCH 22/29] cleanup --- sphinx/ext/autodoc/importer.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/sphinx/ext/autodoc/importer.py b/sphinx/ext/autodoc/importer.py index 24965876165..72c2c996661 100644 --- a/sphinx/ext/autodoc/importer.py +++ b/sphinx/ext/autodoc/importer.py @@ -82,8 +82,6 @@ def query(name: str, defining_class: type) -> tuple[str, type, Any] | None: # names that are ignored by default ignore_names = Enum.__dict__.keys() - public_names - can_override: set[str] = set() - def is_native_api(obj: object, name: str) -> bool: """Check whether *obj* is the same as ``Enum.__dict__[name]``.""" return unwrap_all(obj) is unwrap_all(Enum.__dict__[name]) @@ -100,9 +98,6 @@ def should_ignore(name: str, value: Any) -> bool: if should_ignore(name, value): continue - if name in sunder_names or name in public_names: - can_override.add(name) - candidate_in_mro.add(name) if (item := query(name, parent)) is not None: yield item @@ -114,8 +109,10 @@ def should_ignore(name: str, value: Any) -> bool: if name not in excluded_members)) # check if the inherited members were redefined at the enum level - special_names = sunder_names | public_names | can_override - for name in special_names & enum_class_dict.keys() & Enum.__dict__.keys(): + special_names = sunder_names | public_names + special_names &= enum_class_dict.keys() + special_names &= Enum.__dict__.keys() + for name in special_names: if ( not is_native_api(enum_class_dict[name], name) and (item := query(name, enum_class)) is not None From 89a2ae3c0a5913c4594f2a61bb2a7b2a9333ebc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Tue, 19 Mar 2024 11:28:10 +0100 Subject: [PATCH 23/29] cleanup --- sphinx/ext/autodoc/importer.py | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/sphinx/ext/autodoc/importer.py b/sphinx/ext/autodoc/importer.py index 72c2c996661..fcd64990679 100644 --- a/sphinx/ext/autodoc/importer.py +++ b/sphinx/ext/autodoc/importer.py @@ -39,22 +39,9 @@ def _get_parent_attributes(enum_class: type[Enum]) -> dict[type, dict[str, Any]] Include attributes that are found on mixin types or the data (member) type. """ - - def getbases(cls: type, *, recursive_guard: frozenset[type] = frozenset()) -> set[type]: - if cls in recursive_guard: - return set() - - ret = set() - for base in cls.__bases__: - if base not in {object, cls, Enum}: - ret.add(base) - ret |= getbases(base, recursive_guard=recursive_guard | {cls}) - return ret - attributes = {} - base_types = getbases(enum_class) for base in enum_class.__mro__: - if base in base_types: + if base not in {enum_class, Enum, object}: attributes[base] = safe_getattr(base, '__dict__', {}) return attributes From 55b07842e1774612ead372055b9fdf3741b38ac2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Tue, 19 Mar 2024 11:39:11 +0100 Subject: [PATCH 24/29] remove unnecessary private function --- sphinx/ext/autodoc/importer.py | 39 ++++++++++++++-------------------- 1 file changed, 16 insertions(+), 23 deletions(-) diff --git a/sphinx/ext/autodoc/importer.py b/sphinx/ext/autodoc/importer.py index fcd64990679..9e30d742e83 100644 --- a/sphinx/ext/autodoc/importer.py +++ b/sphinx/ext/autodoc/importer.py @@ -33,31 +33,12 @@ logger = logging.getLogger(__name__) - -def _get_parent_attributes(enum_class: type[Enum]) -> dict[type, dict[str, Any]]: - """Get the mixin attributes of an enumeration class. - - Include attributes that are found on mixin types or the data (member) type. - """ - attributes = {} - for base in enum_class.__mro__: - if base not in {enum_class, Enum, object}: - attributes[base] = safe_getattr(base, '__dict__', {}) - return attributes - - def _filter_enum_dict( enum_class: type[Enum], + attrgetter: Callable[[Any, str, Any], Any], enum_class_dict: Mapping[str, object], ) -> Generator[tuple[str, type, Any], None, None]: # enumerations are created as ``EnumName([mixin_type, ...] [member_type,] enum_type)`` - sentinel = object() - - def query(name: str, defining_class: type) -> tuple[str, type, Any] | None: - value = safe_getattr(enum_class, name, sentinel) - if value is not sentinel: - return (name, defining_class, value) - return None # attributes that were found on a mixin type or the data type candidate_in_mro: set[str] = set() @@ -78,9 +59,21 @@ def should_ignore(name: str, value: Any) -> bool: return is_native_api(value, name) return name in ignore_names + sentinel = object() + + def query(name: str, defining_class: type) -> tuple[str, type, Any] | None: + value = attrgetter(enum_class, name, sentinel) + if value is not sentinel: + return (name, defining_class, value) + return None + # attributes defined on a parent type, possibly shadowed later by # the attributes defined directly inside the enumeration class - for parent, parent_dict in _get_parent_attributes(enum_class).items(): + for parent in enum_class.__mro__: + if parent in {enum_class, Enum, object}: + continue + + parent_dict = attrgetter(parent, '__dict__', {}) for name, value in parent_dict.items(): if should_ignore(name, value): continue @@ -273,7 +266,7 @@ def get_object_members( if name not in members: members[name] = Attribute(name, True, value) - for name, defining_class, value in _filter_enum_dict(subject, obj_dict): + for name, defining_class, value in _filter_enum_dict(subject, attrgetter, obj_dict): members[name] = Attribute(name, defining_class is subject, value) # members in __slots__ @@ -331,7 +324,7 @@ def get_class_members(subject: Any, objpath: Any, attrgetter: Callable, if name not in members: members[name] = ObjectMember(name, value, class_=subject) - for name, defining_class, value in _filter_enum_dict(subject, obj_dict): + for name, defining_class, value in _filter_enum_dict(subject, attrgetter, obj_dict): members[name] = ObjectMember(name, value, class_=defining_class) # members in __slots__ From 3425c1a11d458ff8cf2b74fb25732192e373a9ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Tue, 19 Mar 2024 11:39:28 +0100 Subject: [PATCH 25/29] remove unnecessary private function --- sphinx/ext/autodoc/importer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/sphinx/ext/autodoc/importer.py b/sphinx/ext/autodoc/importer.py index 9e30d742e83..85a4b617f00 100644 --- a/sphinx/ext/autodoc/importer.py +++ b/sphinx/ext/autodoc/importer.py @@ -33,6 +33,7 @@ logger = logging.getLogger(__name__) + def _filter_enum_dict( enum_class: type[Enum], attrgetter: Callable[[Any, str, Any], Any], From e1f9639e73ce12fe2eea3c54cebff85d233587e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Wed, 20 Mar 2024 11:55:31 +0100 Subject: [PATCH 26/29] Handle mangled attributes. --- sphinx/ext/autodoc/importer.py | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/sphinx/ext/autodoc/importer.py b/sphinx/ext/autodoc/importer.py index 85a4b617f00..a7e023934ce 100644 --- a/sphinx/ext/autodoc/importer.py +++ b/sphinx/ext/autodoc/importer.py @@ -39,13 +39,18 @@ def _filter_enum_dict( attrgetter: Callable[[Any, str, Any], Any], enum_class_dict: Mapping[str, object], ) -> Generator[tuple[str, type, Any], None, None]: - # enumerations are created as ``EnumName([mixin_type, ...] [member_type,] enum_type)`` + """Find the attributes to document of an enumeration class. + The output consists of triplets ``(attribute name, defining class, value)`` + where the attribute name can appear more than once during the iteration + but with different defining class. The order of occurrence is guided by + the MRO of *enum_class*. + """ # attributes that were found on a mixin type or the data type candidate_in_mro: set[str] = set() + # sunder names that were picked up (and thereby allowed to be redefined) # see: https://docs.python.org/3/howto/enum.html#supported-dunder-names sunder_names = {'_name_', '_value_', '_missing_', '_order_', '_generate_next_value_'} - # sunder names that were picked up (and thereby allowed to be redefined) # attributes that can be picked up on a mixin type or the enum's data type public_names = {'name', 'value', *object.__dict__, *sunder_names} # names that are ignored by default @@ -89,7 +94,7 @@ def query(name: str, defining_class: type) -> tuple[str, type, Any] | None: yield from filter(None, (query(name, enum_class) for name in enum_class_dict if name not in excluded_members)) - # check if the inherited members were redefined at the enum level + # check if allowed members from ``Enum`` were redefined at the enum level special_names = sunder_names | public_names special_names &= enum_class_dict.keys() special_names &= Enum.__dict__.keys() @@ -263,12 +268,11 @@ def get_object_members( # enum members if isenumclass(subject): - for name, value in subject.__members__.items(): - if name not in members: - members[name] = Attribute(name, True, value) - for name, defining_class, value in _filter_enum_dict(subject, attrgetter, obj_dict): - members[name] = Attribute(name, defining_class is subject, value) + # the order of occurrence of *name* matches the subject's MRO, + # allowing inherited attributes to be shadowed correctly + if name := unmangle(defining_class, name): + members[name] = Attribute(name, defining_class is subject, value) # members in __slots__ try: @@ -293,11 +297,11 @@ def get_object_members( continue # annotation only member (ex. attr: int) - for i, cls in enumerate(getmro(subject)): + for cls in getmro(subject): for name in getannotations(cls): name = unmangle(cls, name) if name and name not in members: - members[name] = Attribute(name, i == 0, INSTANCEATTR) + members[name] = Attribute(name, cls is subject, INSTANCEATTR) if analyzer: # append instance attributes (cf. self.attr1) if analyzer knows @@ -321,12 +325,11 @@ def get_class_members(subject: Any, objpath: Any, attrgetter: Callable, # enum members if isenumclass(subject): - for name, value in subject.__members__.items(): - if name not in members: - members[name] = ObjectMember(name, value, class_=subject) - for name, defining_class, value in _filter_enum_dict(subject, attrgetter, obj_dict): - members[name] = ObjectMember(name, value, class_=defining_class) + # the order of occurrence of *name* matches the subject's MRO, + # allowing inherited attributes to be shadowed correctly + if name := unmangle(defining_class, name): + members[name] = ObjectMember(name, value, class_=defining_class) # members in __slots__ try: From d8c1dc8ba2ca18ce2f318eb842bd6f5b8def6cdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Wed, 20 Mar 2024 11:55:38 +0100 Subject: [PATCH 27/29] Improve tests --- tests/roots/test-ext-autodoc/target/enums.py | 152 ++++++---- tests/test_extensions/test_ext_autodoc.py | 278 ++++++++++++------- 2 files changed, 285 insertions(+), 145 deletions(-) diff --git a/tests/roots/test-ext-autodoc/target/enums.py b/tests/roots/test-ext-autodoc/target/enums.py index 8e91a4ec5f0..6b2731672d2 100644 --- a/tests/roots/test-ext-autodoc/target/enums.py +++ b/tests/roots/test-ext-autodoc/target/enums.py @@ -1,4 +1,42 @@ +# ruff: NoQA: D403, PIE796 import enum +from typing import final + + +class MemberType: + """Custom data type with a simple API.""" + + # this mangled attribute will never be shown on subclasses + # even if :inherited-members: and :private-members: are set + __slots__ = ('__data',) + + def __new__(cls, value): + self = object.__new__(cls) + self.__data = value + return self + + def __str__(self): + """inherited""" + return self.__data + + def __repr__(self): + return repr(self.__data) + + def __reduce__(self): + # data types must be pickable, otherwise enum classes using this data + # type will be forced to be non-pickable and have their __module__ set + # to '' instead of, for instance, '__main__' + return self.__class__, (self.__data,) + + @final + @property + def dtype(self): + """docstring""" + return 'str' + + def isupper(self): + """inherited""" + return self.__data.isupper() class EnumCls(enum.Enum): @@ -19,32 +57,33 @@ def say_goodbye(cls): """a classmethod says good-bye to you.""" -class EnumClassWithDataType(str, enum.Enum): +class EnumClassWithDataType(MemberType, enum.Enum): """this is enum class""" x = 'x' def say_hello(self): - """a method says hello to you.""" + """docstring""" @classmethod def say_goodbye(cls): - """a classmethod says good-bye to you.""" + """docstring""" class ToUpperCase: # not inheriting from enum.Enum @property def value(self): # bypass enum.Enum.value - return str(getattr(self, '_value_')).upper() + """uppercased""" + return str(self._value_).upper() # type: ignore[attr-defined] class Greeter: def say_hello(self): - """a method says hello to you.""" + """inherited""" @classmethod def say_goodbye(cls): - """a classmethod says good-bye to you.""" + """inherited""" class EnumClassWithMixinType(ToUpperCase, enum.Enum): @@ -53,11 +92,11 @@ class EnumClassWithMixinType(ToUpperCase, enum.Enum): x = 'x' def say_hello(self): - """a method says hello to you.""" + """docstring""" @classmethod def say_goodbye(cls): - """a classmethod says good-bye to you.""" + """docstring""" class EnumClassWithMixinTypeInherit(Greeter, ToUpperCase, enum.Enum): @@ -68,7 +107,7 @@ class EnumClassWithMixinTypeInherit(Greeter, ToUpperCase, enum.Enum): class Overridden(enum.Enum): def override(self): - """old override""" + """inherited""" return 1 @@ -78,81 +117,84 @@ class EnumClassWithMixinEnumType(Greeter, Overridden, enum.Enum): x = 'x' def override(self): - """new mixin method not found by ``dir``.""" + """overridden""" return 2 -class EnumClassWithMixinAndDataType(Greeter, ToUpperCase, str, enum.Enum): +class EnumClassWithMixinAndDataType(Greeter, ToUpperCase, MemberType, enum.Enum): """this is enum class""" x = 'x' def say_hello(self): - """a method says hello to you.""" + """overridden""" @classmethod def say_goodbye(cls): - """a classmethod says good-bye to you.""" + """overridden""" def isupper(self): - """New isupper method.""" + """overridden""" return False def __str__(self): - """New __str__ method.""" + """overridden""" return super().__str__() -class _EmptyMixinEnum(Greeter, Overridden, enum.Enum): - """empty mixin class""" +class _ParentEnum(Greeter, Overridden, enum.Enum): + """docstring""" -class EnumClassWithParentEnum(_EmptyMixinEnum, ToUpperCase, str, enum.Enum): - """docstring""" +class EnumClassWithParentEnum(ToUpperCase, MemberType, _ParentEnum, enum.Enum): + """this is enum class""" x = 'x' def isupper(self): - """New isupper method.""" + """overridden""" return False def __str__(self): - """New __str__ method.""" + """overridden""" return super().__str__() -class EnumClassRedefineMixinConflict(ToUpperCase, enum.Enum): - """docstring""" - +class _SunderMissingInNonEnumMixin: + @classmethod + def _missing_(cls, value): + """inherited""" + return super()._missing_(value) # type: ignore[misc] -class _MissingRedefineInNonEnumMixin: - """docstring""" +class _SunderMissingInEnumMixin(enum.Enum): @classmethod def _missing_(cls, value): - """base docstring""" + """inherited""" return super()._missing_(value) -class _MissingRedefineInEnumMixin(enum.Enum): - """docstring""" - +class _SunderMissingInDataType(MemberType): @classmethod def _missing_(cls, value): - """base docstring""" - return super()._missing_(value) + """inherited""" + return super()._missing_(value) # type: ignore[misc] -class EnumRedefineMissingInNonEnumMixin(_MissingRedefineInNonEnumMixin, enum.Enum): - """docstring""" +class EnumSunderMissingInNonEnumMixin(_SunderMissingInNonEnumMixin, enum.Enum): + """this is enum class""" -class EnumRedefineMissingInEnumMixin(_MissingRedefineInEnumMixin, enum.Enum): - """docstring""" +class EnumSunderMissingInEnumMixin(_SunderMissingInEnumMixin, enum.Enum): + """this is enum class""" -class EnumRedefineMissingInClass(enum.Enum): - """docstring""" +class EnumSunderMissingInDataType(_SunderMissingInDataType, enum.Enum): + """this is enum class""" + + +class EnumSunderMissingInClass(enum.Enum): + """this is enum class""" @classmethod def _missing_(cls, value): @@ -160,35 +202,41 @@ def _missing_(cls, value): return super()._missing_(value) -class _NameRedefineInNonEnumMixin: - """docstring""" +class _NamePropertyInNonEnumMixin: + @property + def name(self): + """inherited""" + return super().name # type: ignore[misc] + +class _NamePropertyInEnumMixin(enum.Enum): @property def name(self): - """base docstring""" + """inherited""" return super().name -class _NameRedefineInEnumMixin(enum.Enum): - """docstring""" - +class _NamePropertyInDataType(MemberType): @property def name(self): - """base docstring""" - return super().name + """inherited""" + return super().name # type: ignore[misc] -class EnumRedefineNameInNonEnumMixin(_NameRedefineInNonEnumMixin, enum.Enum): - """docstring""" +class EnumNamePropertyInNonEnumMixin(_NamePropertyInNonEnumMixin, enum.Enum): + """this is enum class""" -class EnumRedefineNameInEnumMixin(_NameRedefineInEnumMixin, enum.Enum): - """docstring""" +class EnumNamePropertyInEnumMixin(_NamePropertyInEnumMixin, enum.Enum): + """this is enum class""" -class EnumRedefineNameInClass(enum.Enum): - """docstring""" +class EnumNamePropertyInDataType(_NamePropertyInDataType, enum.Enum): + """this is enum class""" + +class EnumNamePropertyInClass(enum.Enum): + """this is enum class""" @property def name(self): diff --git a/tests/test_extensions/test_ext_autodoc.py b/tests/test_extensions/test_ext_autodoc.py index 00055cee466..3b0af82a24a 100644 --- a/tests/test_extensions/test_ext_autodoc.py +++ b/tests/test_extensions/test_ext_autodoc.py @@ -1462,7 +1462,8 @@ def entry( qualname = f'{self.name}.{entry_name}' return self._node(role, qualname, doc, args=args, indent=indent, **rst_options) - def brief(self, doc: str, *, indent: int = 0) -> list[str]: + def brief(self, doc: str, *, indent: int = 0, **options: Any) -> list[str]: + """Generate the brief part of the class being documented.""" assert doc, f'enumeration class {self.target!r} should have an explicit docstring' if sys.version_info[:2] >= (3, 13): @@ -1477,7 +1478,7 @@ def brief(self, doc: str, *, indent: int = 0) -> list[str]: else: args = '(value)' - return self._node('class', self.name, doc, args=args, indent=indent) + return self._node('class', self.name, doc, args=args, indent=indent, **options) def method( self, name: str, doc: str, *flags: str, args: str = '()', indent: int = 3, @@ -1490,17 +1491,32 @@ def member(self, name: str, value: Any, doc: str, *, indent: int = 3) -> list[st return self.entry(name, doc, role='attribute', indent=indent, **rst_options) -@pytest.fixture(scope='module') -def autodoc_enum_options(): - # always include 'private-members' to be sure that we are documenting - # the expected fields - return {"members": None, "undoc-members": None, "private-members": None} +@pytest.fixture() +def autodoc_enum_options() -> dict[str, object]: + """Default autodoc options to use when testing enum's documentation.""" + return {"members": None, "undoc-members": None} @pytest.mark.sphinx('html', testroot='ext-autodoc') def test_enum_class(app, autodoc_enum_options): fmt = _EnumFormatter('EnumCls') - actual = do_autodoc(app, 'class', fmt.target, autodoc_enum_options) + options = autodoc_enum_options | {'private-members': None} + + actual = do_autodoc(app, 'class', fmt.target, options) + assert list(actual) == [ + *fmt.brief('this is enum class'), + *fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), + *fmt.method('say_hello', 'a method says hello to you.'), + *fmt.member('val1', 12, 'doc for val1'), + *fmt.member('val2', 23, 'doc for val2'), + *fmt.member('val3', 34, 'doc for val3'), + *fmt.member('val4', 34, ''), # val4 is alias of val3 + ] + + # Inherited members exclude the native Enum API (in particular + # the 'name' and 'value' properties), unless they were explicitly + # redefined by the user in one of the bases. + actual = do_autodoc(app, 'class', fmt.target, options | {'inherited-members': None}) assert list(actual) == [ *fmt.brief('this is enum class'), *fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), @@ -1510,6 +1526,7 @@ def test_enum_class(app, autodoc_enum_options): *fmt.member('val3', 34, 'doc for val3'), *fmt.member('val4', 34, ''), # val4 is alias of val3 ] + # checks for an attribute of EnumCls actual = do_autodoc(app, 'attribute', fmt.subtarget('val1')) assert list(actual) == fmt.member('val1', 12, 'doc for val1', indent=0) @@ -1518,11 +1535,23 @@ def test_enum_class(app, autodoc_enum_options): @pytest.mark.sphinx('html', testroot='ext-autodoc') def test_enum_class_with_data_type(app, autodoc_enum_options): fmt = _EnumFormatter('EnumClassWithDataType') + actual = do_autodoc(app, 'class', fmt.target, autodoc_enum_options) assert list(actual) == [ *fmt.brief('this is enum class'), - *fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), - *fmt.method('say_hello', 'a method says hello to you.'), + *fmt.method('say_goodbye', 'docstring', 'classmethod'), + *fmt.method('say_hello', 'docstring'), + *fmt.member('x', 'x', ''), + ] + + options = autodoc_enum_options | {'inherited-members': None} + actual = do_autodoc(app, 'class', fmt.target, options) + assert list(actual) == [ + *fmt.brief('this is enum class'), + *fmt.entry('dtype', 'docstring', role='property'), + *fmt.method('isupper', 'inherited'), + *fmt.method('say_goodbye', 'docstring', 'classmethod'), + *fmt.method('say_hello', 'docstring'), *fmt.member('x', 'x', ''), ] @@ -1530,11 +1559,22 @@ def test_enum_class_with_data_type(app, autodoc_enum_options): @pytest.mark.sphinx('html', testroot='ext-autodoc') def test_enum_class_with_mixin_type(app, autodoc_enum_options): fmt = _EnumFormatter('EnumClassWithMixinType') + actual = do_autodoc(app, 'class', fmt.target, autodoc_enum_options) assert list(actual) == [ *fmt.brief('this is enum class'), - *fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), - *fmt.method('say_hello', 'a method says hello to you.'), + *fmt.method('say_goodbye', 'docstring', 'classmethod'), + *fmt.method('say_hello', 'docstring'), + *fmt.member('x', 'X', ''), + ] + + options = autodoc_enum_options | {'inherited-members': None} + actual = do_autodoc(app, 'class', fmt.target, options) + assert list(actual) == [ + *fmt.brief('this is enum class'), + *fmt.method('say_goodbye', 'docstring', 'classmethod'), + *fmt.method('say_hello', 'docstring'), + *fmt.entry('value', 'uppercased', role='property'), *fmt.member('x', 'X', ''), ] @@ -1542,13 +1582,20 @@ def test_enum_class_with_mixin_type(app, autodoc_enum_options): @pytest.mark.sphinx('html', testroot='ext-autodoc') def test_enum_class_with_mixin_type_and_inheritence(app, autodoc_enum_options): fmt = _EnumFormatter('EnumClassWithMixinTypeInherit') + + actual = do_autodoc(app, 'class', fmt.target, autodoc_enum_options) + assert list(actual) == [ + *fmt.brief('this is enum class'), + *fmt.member('x', 'X', ''), + ] + options = autodoc_enum_options | {'inherited-members': None} actual = do_autodoc(app, 'class', fmt.target, options) assert list(actual) == [ *fmt.brief('this is enum class'), - *fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), - *fmt.method('say_hello', 'a method says hello to you.'), - *fmt.entry('value', '', role='property'), # inherited from ToUpperCase + *fmt.method('say_goodbye', 'inherited', 'classmethod'), + *fmt.method('say_hello', 'inherited'), + *fmt.entry('value', 'uppercased', role='property'), *fmt.member('x', 'X', ''), ] @@ -1561,20 +1608,18 @@ def test_enum_class_with_mixin_enum_type(app, autodoc_enum_options): assert list(actual) == [ *fmt.brief('this is enum class'), # override() is overridden at the class level so it should be rendered - *fmt.method('override', 'new mixin method not found by ``dir``.'), + *fmt.method('override', 'overridden'), # say_goodbye() and say_hello() are not rendered since they are inherited *fmt.member('x', 'x', ''), ] - options = autodoc_enum_options | {"inherited-members": None} + options = autodoc_enum_options | {'inherited-members': None} actual = do_autodoc(app, 'class', fmt.target, options) assert list(actual) == [ *fmt.brief('this is enum class'), - # override() is overridden at the class level so it should be rendered - *fmt.method('override', 'new mixin method not found by ``dir``.'), - # say_goodbye() and say_hello() are now rendered - *fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), - *fmt.method('say_hello', 'a method says hello to you.'), + *fmt.method('override', 'overridden'), + *fmt.method('say_goodbye', 'inherited', 'classmethod'), + *fmt.method('say_hello', 'inherited'), *fmt.member('x', 'x', ''), ] @@ -1583,31 +1628,36 @@ def test_enum_class_with_mixin_enum_type(app, autodoc_enum_options): def test_enum_class_with_mixin_and_data_type(app, autodoc_enum_options): fmt = _EnumFormatter('EnumClassWithMixinAndDataType') - # no special members actual = do_autodoc(app, 'class', fmt.target, autodoc_enum_options) assert list(actual) == [ *fmt.brief('this is enum class'), - # defined at the class level - *fmt.method('isupper', 'New isupper method.'), - # redefined at the class level so always included - *fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), - # redefined at the class level so always included - *fmt.method('say_hello', 'a method says hello to you.'), + *fmt.method('isupper', 'overridden'), + *fmt.method('say_goodbye', 'overridden', 'classmethod'), + *fmt.method('say_hello', 'overridden'), *fmt.member('x', 'X', ''), ] - # # add the special member __str__ (but not the inherited members) + # add the special member __str__ (but not the inherited members) options = autodoc_enum_options | {'special-members': '__str__'} actual = do_autodoc(app, 'class', fmt.target, options) assert list(actual) == [ *fmt.brief('this is enum class'), - *fmt.method('__str__', 'New __str__ method.'), - # defined at the class level - *fmt.method('isupper', 'New isupper method.'), - # redefined at the class level so always included - *fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), - # redefined at the class level so always included - *fmt.method('say_hello', 'a method says hello to you.'), + *fmt.method('__str__', 'overridden'), + *fmt.method('isupper', 'overridden'), + *fmt.method('say_goodbye', 'overridden', 'classmethod'), + *fmt.method('say_hello', 'overridden'), + *fmt.member('x', 'X', ''), + ] + + options = autodoc_enum_options | {'inherited-members': None} + actual = do_autodoc(app, 'class', fmt.target, options) + assert list(actual) == [ + *fmt.brief('this is enum class'), + *fmt.entry('dtype', 'docstring', role='property'), + *fmt.method('isupper', 'overridden'), + *fmt.method('say_goodbye', 'overridden', 'classmethod'), + *fmt.method('say_hello', 'overridden'), + *fmt.entry('value', 'uppercased', role='property'), *fmt.member('x', 'X', ''), ] @@ -1616,11 +1666,10 @@ def test_enum_class_with_mixin_and_data_type(app, autodoc_enum_options): def test_enum_with_parent_enum(app, autodoc_enum_options): fmt = _EnumFormatter('EnumClassWithParentEnum') - # no inherited or special members actual = do_autodoc(app, 'class', fmt.target, autodoc_enum_options) assert list(actual) == [ - *fmt.brief('docstring'), - *fmt.method('isupper', 'New isupper method.'), + *fmt.brief('this is enum class'), + *fmt.method('isupper', 'overridden'), *fmt.member('x', 'X', ''), ] @@ -1628,105 +1677,148 @@ def test_enum_with_parent_enum(app, autodoc_enum_options): options = autodoc_enum_options | {'special-members': '__str__'} actual = do_autodoc(app, 'class', fmt.target, options) assert list(actual) == [ - *fmt.brief('docstring'), - *fmt.method('__str__', 'New __str__ method.'), - *fmt.method('isupper', 'New isupper method.'), + *fmt.brief('this is enum class'), + *fmt.method('__str__', 'overridden'), + *fmt.method('isupper', 'overridden'), *fmt.member('x', 'X', ''), ] options = autodoc_enum_options | {'inherited-members': None} actual = do_autodoc(app, 'class', fmt.target, options) - for block in [ - fmt.brief('docstring'), - fmt.method('__str__', 'New __str__ method.'), - fmt.method('isupper', 'New isupper method.'), - fmt.method('say_goodbye', 'a classmethod says good-bye to you.', 'classmethod'), - fmt.method('say_hello', 'a method says hello to you.'), - fmt.member('x', 'X', ''), - ]: - assert block <= actual + assert list(actual) == [ + *fmt.brief('this is enum class'), + *fmt.entry('dtype', 'docstring', role='property'), + *fmt.method('isupper', 'overridden'), + *fmt.method('override', 'inherited'), + *fmt.method('say_goodbye', 'inherited', 'classmethod'), + *fmt.method('say_hello', 'inherited'), + *fmt.entry('value', 'uppercased', role='property'), + *fmt.member('x', 'X', ''), + ] @pytest.mark.sphinx('html', testroot='ext-autodoc') def test_enum_sunder_method(app, autodoc_enum_options): - fmt = _EnumFormatter('EnumRedefineMissingInNonEnumMixin') + PRIVATE = {'private-members': None} # sunder methods are recognized as private + + fmt = _EnumFormatter('EnumSunderMissingInNonEnumMixin') actual = do_autodoc(app, 'class', fmt.target, autodoc_enum_options) - assert list(actual) == [*fmt.brief('docstring')] + assert list(actual) == [*fmt.brief('this is enum class')] + actual = do_autodoc(app, 'class', fmt.target, autodoc_enum_options | PRIVATE) + assert list(actual) == [*fmt.brief('this is enum class')] - fmt = _EnumFormatter('EnumRedefineMissingInEnumMixin') + fmt = _EnumFormatter('EnumSunderMissingInEnumMixin') actual = do_autodoc(app, 'class', fmt.target, autodoc_enum_options) - assert list(actual) == [*fmt.brief('docstring')] + assert list(actual) == [*fmt.brief('this is enum class')] + actual = do_autodoc(app, 'class', fmt.target, autodoc_enum_options | PRIVATE) + assert list(actual) == [*fmt.brief('this is enum class')] - fmt = _EnumFormatter('EnumRedefineMissingInClass') + fmt = _EnumFormatter('EnumSunderMissingInDataType') actual = do_autodoc(app, 'class', fmt.target, autodoc_enum_options) + assert list(actual) == [*fmt.brief('this is enum class')] + actual = do_autodoc(app, 'class', fmt.target, autodoc_enum_options | PRIVATE) + assert list(actual) == [*fmt.brief('this is enum class')] + + fmt = _EnumFormatter('EnumSunderMissingInClass') + actual = do_autodoc(app, 'class', fmt.target, autodoc_enum_options) + assert list(actual) == [*fmt.brief('this is enum class')] + options = autodoc_enum_options | {'private-members': None} + actual = do_autodoc(app, 'class', fmt.target, autodoc_enum_options | PRIVATE) assert list(actual) == [ - *fmt.brief('docstring'), + *fmt.brief('this is enum class'), *fmt.method('_missing_', 'docstring', 'classmethod', args='(value)'), ] -@pytest.mark.parametrize(('target_name', 'docstring'), [ - ('EnumRedefineMissingInNonEnumMixin', 'base docstring'), - ('EnumRedefineMissingInEnumMixin', 'base docstring'), - ('EnumRedefineMissingInClass', 'docstring'), -]) @pytest.mark.sphinx('html', testroot='ext-autodoc') -def test_enum_inherited_sunder_method(app, target_name, docstring, autodoc_enum_options): - fmt = _EnumFormatter(target_name) - options = autodoc_enum_options | {"inherited-members": None} +def test_enum_inherited_sunder_method(app, autodoc_enum_options): + options = autodoc_enum_options | {'private-members': None, 'inherited-members': None} + + fmt = _EnumFormatter('EnumSunderMissingInNonEnumMixin') + actual = do_autodoc(app, 'class', fmt.target, options) + assert list(actual) == [ + *fmt.brief('this is enum class'), + *fmt.method('_missing_', 'inherited', 'classmethod', args='(value)'), + ] + + fmt = _EnumFormatter('EnumSunderMissingInEnumMixin') + actual = do_autodoc(app, 'class', fmt.target, options) + assert list(actual) == [ + *fmt.brief('this is enum class'), + *fmt.method('_missing_', 'inherited', 'classmethod', args='(value)'), + ] + + fmt = _EnumFormatter('EnumSunderMissingInDataType') actual = do_autodoc(app, 'class', fmt.target, options) assert list(actual) == [ - *fmt.brief('docstring'), - *fmt.method('_missing_', docstring, 'classmethod', args='(value)'), + *fmt.brief('this is enum class'), + *fmt.method('_missing_', 'inherited', 'classmethod', args='(value)'), + *fmt.entry('dtype', 'docstring', role='property'), + *fmt.method('isupper', 'inherited'), + ] + + fmt = _EnumFormatter('EnumSunderMissingInClass') + actual = do_autodoc(app, 'class', fmt.target, options) + assert list(actual) == [ + *fmt.brief('this is enum class'), + *fmt.method('_missing_', 'docstring', 'classmethod', args='(value)'), ] @pytest.mark.sphinx('html', testroot='ext-autodoc') -def test_enum_public_property(app, autodoc_enum_options): - fmt = _EnumFormatter('EnumRedefineNameInNonEnumMixin') +def test_enum_custom_name_property(app, autodoc_enum_options): + fmt = _EnumFormatter('EnumNamePropertyInNonEnumMixin') + actual = do_autodoc(app, 'class', fmt.target, autodoc_enum_options) + assert list(actual) == [*fmt.brief('this is enum class')] + + fmt = _EnumFormatter('EnumNamePropertyInEnumMixin') actual = do_autodoc(app, 'class', fmt.target, autodoc_enum_options) - assert list(actual) == [*fmt.brief('docstring')] + assert list(actual) == [*fmt.brief('this is enum class')] - fmt = _EnumFormatter('EnumRedefineNameInEnumMixin') + fmt = _EnumFormatter('EnumNamePropertyInDataType') actual = do_autodoc(app, 'class', fmt.target, autodoc_enum_options) - assert list(actual) == [*fmt.brief('docstring')] + assert list(actual) == [*fmt.brief('this is enum class')] - fmt = _EnumFormatter('EnumRedefineNameInClass') + fmt = _EnumFormatter('EnumNamePropertyInClass') actual = do_autodoc(app, 'class', fmt.target, autodoc_enum_options) assert list(actual) == [ - *fmt.brief('docstring'), + *fmt.brief('this is enum class'), *fmt.entry('name', 'docstring', role='property'), ] -@pytest.mark.parametrize(('target_name', 'docstring'), [ - ('EnumRedefineNameInNonEnumMixin', 'base docstring'), - ('EnumRedefineNameInEnumMixin', 'base docstring'), - ('EnumRedefineNameInClass', 'docstring'), -]) @pytest.mark.sphinx('html', testroot='ext-autodoc') -def test_enum_inherited_public_property(app, target_name, docstring, autodoc_enum_options): - fmt = _EnumFormatter(target_name) +def test_enum_inherited_custom_name_property(app, autodoc_enum_options): options = autodoc_enum_options | {"inherited-members": None} + + fmt = _EnumFormatter('EnumNamePropertyInNonEnumMixin') actual = do_autodoc(app, 'class', fmt.target, options) assert list(actual) == [ - *fmt.brief('docstring'), - *fmt.entry('name', docstring, role='property'), + *fmt.brief('this is enum class'), + *fmt.entry('name', 'inherited', role='property'), ] + fmt = _EnumFormatter('EnumNamePropertyInEnumMixin') + actual = do_autodoc(app, 'class', fmt.target, options) + assert list(actual) == [ + *fmt.brief('this is enum class'), + *fmt.entry('name', 'inherited', role='property'), + ] -@pytest.mark.sphinx('html', testroot='ext-autodoc') -def test_enum_redefine_enum_from_mixin(app, autodoc_enum_options): - fmt = _EnumFormatter('EnumClassRedefineMixinConflict') - - actual = do_autodoc(app, 'class', fmt.target, autodoc_enum_options) - assert list(actual) == [*fmt.brief('docstring')] + fmt = _EnumFormatter('EnumNamePropertyInDataType') + actual = do_autodoc(app, 'class', fmt.target, options) + assert list(actual) == [ + *fmt.brief('this is enum class'), + *fmt.entry('dtype', 'docstring', role='property'), + *fmt.method('isupper', 'inherited'), + *fmt.entry('name', 'inherited', role='property'), + ] - options = autodoc_enum_options | {"inherited-members": None} + fmt = _EnumFormatter('EnumNamePropertyInClass') actual = do_autodoc(app, 'class', fmt.target, options) assert list(actual) == [ - *fmt.brief('docstring'), - *fmt.entry('value', '', role='property'), + *fmt.brief('this is enum class'), + *fmt.entry('name', 'docstring', role='property'), ] From 901eaeada65300859702f369f081eb8b4653e14b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Wed, 20 Mar 2024 12:00:29 +0100 Subject: [PATCH 28/29] flake8 --- tests/test_extensions/test_ext_autodoc.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_extensions/test_ext_autodoc.py b/tests/test_extensions/test_ext_autodoc.py index 3b0af82a24a..723e91dfb5e 100644 --- a/tests/test_extensions/test_ext_autodoc.py +++ b/tests/test_extensions/test_ext_autodoc.py @@ -1692,7 +1692,7 @@ def test_enum_with_parent_enum(app, autodoc_enum_options): *fmt.method('override', 'inherited'), *fmt.method('say_goodbye', 'inherited', 'classmethod'), *fmt.method('say_hello', 'inherited'), - *fmt.entry('value', 'uppercased', role='property'), + *fmt.entry('value', 'uppercased', role='property'), *fmt.member('x', 'X', ''), ] @@ -1722,7 +1722,6 @@ def test_enum_sunder_method(app, autodoc_enum_options): fmt = _EnumFormatter('EnumSunderMissingInClass') actual = do_autodoc(app, 'class', fmt.target, autodoc_enum_options) assert list(actual) == [*fmt.brief('this is enum class')] - options = autodoc_enum_options | {'private-members': None} actual = do_autodoc(app, 'class', fmt.target, autodoc_enum_options | PRIVATE) assert list(actual) == [ *fmt.brief('this is enum class'), From f06e5ab2ca014f85e9eea996bc6a9d6ac208aacd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Wed, 20 Mar 2024 12:04:28 +0100 Subject: [PATCH 29/29] mypy --- sphinx/ext/autodoc/importer.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/sphinx/ext/autodoc/importer.py b/sphinx/ext/autodoc/importer.py index a7e023934ce..60457c53021 100644 --- a/sphinx/ext/autodoc/importer.py +++ b/sphinx/ext/autodoc/importer.py @@ -271,8 +271,8 @@ def get_object_members( for name, defining_class, value in _filter_enum_dict(subject, attrgetter, obj_dict): # the order of occurrence of *name* matches the subject's MRO, # allowing inherited attributes to be shadowed correctly - if name := unmangle(defining_class, name): - members[name] = Attribute(name, defining_class is subject, value) + if unmangled := unmangle(defining_class, name): + members[unmangled] = Attribute(unmangled, defining_class is subject, value) # members in __slots__ try: @@ -290,18 +290,18 @@ def get_object_members( try: value = attrgetter(subject, name) directly_defined = name in obj_dict - name = unmangle(subject, name) - if name and name not in members: - members[name] = Attribute(name, directly_defined, value) + unmangled = unmangle(subject, name) + if unmangled and unmangled not in members: + members[unmangled] = Attribute(unmangled, directly_defined, value) except AttributeError: continue # annotation only member (ex. attr: int) for cls in getmro(subject): for name in getannotations(cls): - name = unmangle(cls, name) - if name and name not in members: - members[name] = Attribute(name, cls is subject, INSTANCEATTR) + unmangled = unmangle(cls, name) + if unmangled and unmangled not in members: + members[unmangled] = Attribute(unmangled, cls is subject, INSTANCEATTR) if analyzer: # append instance attributes (cf. self.attr1) if analyzer knows @@ -328,8 +328,8 @@ def get_class_members(subject: Any, objpath: Any, attrgetter: Callable, for name, defining_class, value in _filter_enum_dict(subject, attrgetter, obj_dict): # the order of occurrence of *name* matches the subject's MRO, # allowing inherited attributes to be shadowed correctly - if name := unmangle(defining_class, name): - members[name] = ObjectMember(name, value, class_=defining_class) + if unmangled := unmangle(defining_class, name): + members[unmangled] = ObjectMember(unmangled, value, class_=defining_class) # members in __slots__ try: @@ -374,15 +374,15 @@ def get_class_members(subject: Any, objpath: Any, attrgetter: Callable, # annotation only member (ex. attr: int) for name in getannotations(cls): - name = unmangle(cls, name) - if name and name not in members: - if analyzer and (qualname, name) in analyzer.attr_docs: - docstring = '\n'.join(analyzer.attr_docs[qualname, name]) + unmangled = unmangle(cls, name) + if unmangled and unmangled not in members: + if analyzer and (qualname, unmangled) in analyzer.attr_docs: + docstring = '\n'.join(analyzer.attr_docs[qualname, unmangled]) else: docstring = None - members[name] = ObjectMember(name, INSTANCEATTR, class_=cls, - docstring=docstring) + members[unmangled] = ObjectMember(unmangled, INSTANCEATTR, class_=cls, + docstring=docstring) # append or complete instance attributes (cf. self.attr1) if analyzer knows if analyzer: