From 488a173904a6130c0d93cf65f58b5d6be506a81c Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Sat, 18 Jul 2020 03:36:03 +0900 Subject: [PATCH] Fix #1362: autodoc: Support private class attributes So far, autodoc treats a "private" class attribute as a mere attribute. But its name is mangled by python interpreter. This make it unmangled name to be documented expectedly. --- CHANGES | 1 + sphinx/ext/autodoc/importer.py | 41 +++++++++++++++-- .../test-ext-autodoc/target/name_mangling.py | 11 +++++ tests/test_ext_autodoc.py | 45 +++++++++++++++++++ 4 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 tests/roots/test-ext-autodoc/target/name_mangling.py diff --git a/CHANGES b/CHANGES index 3eeb93339..b099ee1d7 100644 --- a/CHANGES +++ b/CHANGES @@ -44,6 +44,7 @@ Bugs fixed parameter having ``inspect._empty`` as its default value * #7901: autodoc: type annotations for overloaded functions are not resolved * #904: autodoc: An instance attribute cause a crash of autofunction directive +* #1362: autodoc: ``private-members`` option does not work for class attributes * #7839: autosummary: cannot handle umlauts in function names * #7865: autosummary: Failed to extract summary line when abbreviations found * #7866: autosummary: Failed to extract correct summary line when docstring diff --git a/sphinx/ext/autodoc/importer.py b/sphinx/ext/autodoc/importer.py index b5d9ab8f6..031911de2 100644 --- a/sphinx/ext/autodoc/importer.py +++ b/sphinx/ext/autodoc/importer.py @@ -11,7 +11,7 @@ import importlib import traceback import warnings -from typing import Any, Callable, Dict, List, Mapping, NamedTuple, Tuple +from typing import Any, Callable, Dict, List, Mapping, NamedTuple, Optional, Tuple from sphinx.deprecation import RemovedInSphinx40Warning, deprecated_alias from sphinx.pycode import ModuleAnalyzer @@ -21,6 +21,36 @@ from sphinx.util.inspect import isclass, isenumclass, safe_getattr logger = logging.getLogger(__name__) +def mangle(subject: Any, name: str) -> str: + """mangle the given name.""" + try: + if isclass(subject) and name.startswith('__') and not name.endswith('__'): + return "_%s%s" % (subject.__name__, name) + except AttributeError: + pass + + return name + + +def unmangle(subject: Any, name: str) -> Optional[str]: + """unmangle the given name.""" + try: + if isclass(subject) and not name.endswith('__'): + prefix = "_%s__" % subject.__name__ + if name.startswith(prefix): + return name.replace(prefix, "__", 1) + else: + for cls in subject.__mro__: + prefix = "_%s__" % cls.__name__ + if name.startswith(prefix): + # mangled attribute defined in parent class + return None + except AttributeError: + pass + + return name + + def import_module(modname: str, warningiserror: bool = False) -> Any: """ Call importlib.import_module(modname), convert exceptions to ImportError @@ -68,7 +98,8 @@ def import_object(modname: str, objpath: List[str], objtype: str = '', for attrname in objpath: parent = obj logger.debug('[autodoc] getattr(_, %r)', attrname) - obj = attrgetter(obj, attrname) + mangled_name = mangle(obj, attrname) + obj = attrgetter(obj, mangled_name) logger.debug('[autodoc] => %r', obj) object_name = attrname return [module, parent, object_name, obj] @@ -161,7 +192,8 @@ def get_object_members(subject: Any, objpath: List[str], attrgetter: Callable, try: value = attrgetter(subject, name) directly_defined = name in obj_dict - if name not in members: + name = unmangle(subject, name) + if name and name not in members: members[name] = Attribute(name, directly_defined, value) except AttributeError: continue @@ -169,7 +201,8 @@ def get_object_members(subject: Any, objpath: List[str], attrgetter: Callable, # annotation only member (ex. attr: int) if hasattr(subject, '__annotations__') and isinstance(subject.__annotations__, Mapping): for name in subject.__annotations__: - if name not in members: + name = unmangle(subject, name) + if name and name not in members: members[name] = Attribute(name, True, INSTANCEATTR) if analyzer: diff --git a/tests/roots/test-ext-autodoc/target/name_mangling.py b/tests/roots/test-ext-autodoc/target/name_mangling.py new file mode 100644 index 000000000..269b51d93 --- /dev/null +++ b/tests/roots/test-ext-autodoc/target/name_mangling.py @@ -0,0 +1,11 @@ +class Foo: + #: name of Foo + __name = None + __age = None + + +class Bar(Foo): + __address = None + + #: a member having mangled-like name + _Baz__email = None diff --git a/tests/test_ext_autodoc.py b/tests/test_ext_autodoc.py index d3524ac9d..be46c1490 100644 --- a/tests/test_ext_autodoc.py +++ b/tests/test_ext_autodoc.py @@ -1973,3 +1973,48 @@ def test_name_conflict(app): ' docstring of target.name_conflict.foo::bar.', '', ] + + +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_name_mangling(app): + options = {"members": None, + "undoc-members": None, + "private-members": None} + actual = do_autodoc(app, 'module', 'target.name_mangling', options) + assert list(actual) == [ + '', + '.. py:module:: target.name_mangling', + '', + '', + '.. py:class:: Bar()', + ' :module: target.name_mangling', + '', + '', + ' .. py:attribute:: Bar._Baz__email', + ' :module: target.name_mangling', + ' :value: None', + '', + ' a member having mangled-like name', + '', + '', + ' .. py:attribute:: Bar.__address', + ' :module: target.name_mangling', + ' :value: None', + '', + '', + '.. py:class:: Foo()', + ' :module: target.name_mangling', + '', + '', + ' .. py:attribute:: Foo.__age', + ' :module: target.name_mangling', + ' :value: None', + '', + '', + ' .. py:attribute:: Foo.__name', + ' :module: target.name_mangling', + ' :value: None', + '', + ' name of Foo', + '', + ]