From 4bed65e18f14c98ce96d576bfd695e17cf7f18e4 Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+AA-Turner@users.noreply.github.com> Date: Sat, 2 Aug 2025 02:01:21 +0200 Subject: [PATCH] Combine name resolution and importing (#13797) --- sphinx/ext/autodoc/_documenters.py | 443 +++------------ sphinx/ext/autodoc/importer.py | 651 +++++++++++++++------- tests/test_extensions/test_ext_autodoc.py | 117 ++-- 3 files changed, 571 insertions(+), 640 deletions(-) diff --git a/sphinx/ext/autodoc/_documenters.py b/sphinx/ext/autodoc/_documenters.py index 7a22ab65de..5b9a8fc849 100644 --- a/sphinx/ext/autodoc/_documenters.py +++ b/sphinx/ext/autodoc/_documenters.py @@ -5,7 +5,6 @@ import operator import re import sys from inspect import Parameter, Signature -from pathlib import Path from typing import TYPE_CHECKING, NewType, TypeVar from docutils.statemachine import StringList @@ -21,13 +20,6 @@ from sphinx.ext.autodoc._directive_options import ( member_order_option, members_option, ) -from sphinx.ext.autodoc._property_types import ( - _AssignStatementProperties, - _ClassDefProperties, - _FunctionDefProperties, - _ItemProperties, - _ModuleProperties, -) from sphinx.ext.autodoc._sentinels import ( ALL, INSTANCE_ATTR, @@ -38,16 +30,12 @@ from sphinx.ext.autodoc._sentinels import ( ) from sphinx.ext.autodoc.importer import ( _get_attribute_comment, - _import_assignment_attribute, - _import_assignment_data, - _import_class, - _import_method, - _import_object, - _import_property, _is_runtime_instance_attribute_not_commented, + _load_object_by_name, + _resolve_name, get_class_members, ) -from sphinx.ext.autodoc.mock import ismock, mock, undecorate +from sphinx.ext.autodoc.mock import ismock, undecorate from sphinx.locale import _, __ from sphinx.pycode import ModuleAnalyzer from sphinx.util import inspect, logging @@ -70,7 +58,13 @@ if TYPE_CHECKING: from sphinx.environment import BuildEnvironment, _CurrentDocument from sphinx.events import EventManager from sphinx.ext.autodoc._directive_options import _AutoDocumenterOptions - from sphinx.ext.autodoc._property_types import _AutodocFuncProperty + from sphinx.ext.autodoc._property_types import ( + _AssignStatementProperties, + _ClassDefProperties, + _FunctionDefProperties, + _ItemProperties, + _ModuleProperties, + ) from sphinx.ext.autodoc.directive import DocumenterBridge from sphinx.registry import SphinxComponentRegistry from sphinx.util.typing import OptionSpec, _RestifyMode @@ -230,6 +224,8 @@ class Documenter: # the module analyzer to get at attribute docs, or None self.analyzer: ModuleAnalyzer | None = None + self._load_object_has_been_called = False + @property def documenters(self) -> dict[str, type[Documenter]]: """Returns registered Documenter classes""" @@ -242,6 +238,44 @@ class Documenter: else: self.directive.result.append('', source, *lineno) + def _load_object_by_name(self) -> Literal[True] | None: + if self._load_object_has_been_called: + return True + + ret = _load_object_by_name( + name=self.name, + objtype=self.objtype, + mock_imports=self.config.autodoc_mock_imports, + type_aliases=self.config.autodoc_type_aliases, + current_document=self._current_document, + env=self.env, + get_attr=self.get_attr, + ) + if ret is None: + return None + props, args, retann, module, parent = ret + + self.props = props + self.args = args + self.retann = retann + self.modname = props.module_name + self.objpath = list(props.parts) + self.fullname = props.full_name + self.module = module + self.parent = parent + self.object_name = props.name + self.object = props._obj + if self.objtype == 'method': + if 'staticmethod' in props.properties: # type: ignore[attr-defined] + # document static members before regular methods + self.member_order -= 1 # type: ignore[misc] + elif 'classmethod' in props.properties: # type: ignore[attr-defined] + # document class methods before static methods as + # they usually behave as alternative constructors + self.member_order -= 2 # type: ignore[misc] + self._load_object_has_been_called = True + return True + def resolve_name( self, modname: str | None, parents: Any, path: str, base: str ) -> tuple[str | None, list[str]]: @@ -252,71 +286,19 @@ class Documenter: example, it would return ``('zipfile', ['ZipFile', 'open'])`` for the ``zipfile.ZipFile.open`` method. """ - if isinstance(self, ModuleDocumenter): - if modname is not None: - logger.warning( - __('"::" in automodule name doesn\'t make sense'), type='autodoc' - ) - return (path or '') + base, [] - - if isinstance( - self, - ( - ModuleLevelDocumenter, - FunctionDocumenter, - ClassDocumenter, - DataDocumenter, - ), - ): - if modname is not None: - return modname, [*parents, base] - if path: - modname = path.rstrip('.') - return modname, [*parents, base] - - # if documenting a toplevel object without explicit module, - # it can be contained in another auto directive ... - modname = self._current_document.autodoc_module - # ... or in the scope of a module directive - if not modname: - modname = self.env.ref_context.get('py:module') - # ... else, it stays None, which means invalid - return modname, [*parents, base] - - if isinstance( - self, - ( - ClassLevelDocumenter, - MethodDocumenter, - AttributeDocumenter, - PropertyDocumenter, - ), - ): - if modname is not None: - return modname, [*parents, base] - - if path: - mod_cls = path.rstrip('.') - else: - # if documenting a class-level object without path, - # there must be a current class, either from a parent - # auto directive ... - mod_cls = self._current_document.autodoc_class - # ... or from a class directive - if not mod_cls: - mod_cls = self.env.ref_context.get('py:class', '') - # ... if still falsy, there's no way to know - if not mod_cls: - return None, [] - modname, _sep, cls = mod_cls.rpartition('.') - parents = [cls] - # if the module name is still missing, get it like above - if not modname: - modname = self._current_document.autodoc_module - if not modname: - modname = self.env.ref_context.get('py:module') - # ... else, it stays None, which means invalid - return modname, [*parents, base] + ret = _resolve_name( + objtype=self.objtype, + module_name=modname, + path=path, + base=base, + parents=parents, + current_document=self._current_document, + ref_context_py_module=self.env.ref_context.get('py:module'), + ref_context_py_class=self.env.ref_context.get('py:class', ''), + ) + if ret is not None: + module_name, parts = ret + return module_name, list(parts) msg = 'must be implemented in subclasses' raise NotImplementedError(msg) @@ -327,39 +309,7 @@ class Documenter: Returns True and sets *self.modname*, *self.objpath*, *self.fullname*, *self.args* and *self.retann* if parsing and resolving was successful. """ - # first, parse the definition -- auto directives for classes and - # functions can contain a signature which is then used instead of - # an autogenerated one - matched = py_ext_sig_re.match(self.name) - if matched is None: - logger.warning( - __('invalid signature for auto%s (%r)'), - self.objtype, - self.name, - type='autodoc', - ) - return False - explicit_modname, path, base, _tp_list, args, retann = matched.groups() - - # support explicit module and class name separation via :: - if explicit_modname is not None: - modname = explicit_modname[:-2] - parents = path.rstrip('.').split('.') if path else [] - else: - modname = None - parents = [] - - with mock(self.config.autodoc_mock_imports): - modname, self.objpath = self.resolve_name(modname, parents, path, base) - - if not modname: - return False - - self.modname = modname - self.args = args - self.retann = retann - self.fullname = '.'.join((self.modname or '', *self.objpath)) - return True + return self._load_object_by_name() is not None def import_object(self, raiseerror: bool = False) -> bool: """Import the object given by *self.modname* and *self.objpath* and set @@ -367,66 +317,7 @@ class Documenter: Returns True if successful, False if an error occurred. """ - try: - im = _import_object( - module_name=self.modname, - obj_path=self.objpath, - mock_imports=self.config.autodoc_mock_imports, - get_attr=self.get_attr, - ) - except ImportError as exc: - if raiseerror: - raise - logger.warning(exc.args[0], type='autodoc', subtype='import_object') - self.env.note_reread() - return False - - self.object = obj = im.__dict__.pop('obj', None) - for k in 'module', 'parent', 'object_name': - if hasattr(im, k): - setattr(self, k, getattr(im, k)) - - file_path = getattr(im.module, '__file__', None) - try: - mod_all = inspect.getall(im.module) - except ValueError: - mod_all = None - if self.objtype == 'module': - self.props = _ModuleProperties( - obj_type=self.objtype, - name=im.object_name, - module_name=getattr(im, 'modname', self.modname), - docstring_lines=(), - file_path=Path(file_path) if file_path is not None else None, - all=tuple(mod_all) if mod_all is not None else None, - _obj=obj, - ) - elif self.objtype in {'function', 'decorator'}: - obj_properties: set[_AutodocFuncProperty] = set() - if inspect.isstaticmethod(obj, cls=im.parent, name=im.object_name): - obj_properties.add('staticmethod') - if inspect.isclassmethod(obj): - obj_properties.add('classmethod') - self.props = _FunctionDefProperties( - obj_type=self.objtype, - name=im.object_name, - module_name=self.modname, - parts=tuple(self.objpath), - docstring_lines=(), - properties=frozenset(obj_properties), - _obj=obj, - ) - else: - self.props = _ItemProperties( - obj_type=self.objtype, - name=im.object_name, - module_name=self.modname, - parts=tuple(self.objpath), - docstring_lines=(), - _obj=obj, - ) - - return True + return self._load_object_by_name() is not None def get_real_modname(self) -> str: """Get the real module name of an object to document. @@ -957,7 +848,7 @@ class Documenter: member_documenters = [ (documenter, isattr) for documenter, isattr in member_documenters - if documenter.parse_name() and documenter.import_object() + if documenter._load_object_by_name() is not None ] member_documenters = self.sort_members(member_documenters, member_order) @@ -1017,21 +908,7 @@ class Documenter: True, only generate if the object is defined in the module name it is imported from. If *all_members* is True, document all members. """ - if not self.parse_name(): - # need a module to import - logger.warning( - __( - "don't know which module to import for autodocumenting " - '%r (try placing a "module" or "currentmodule" directive ' - 'in the document, or giving an explicit module name)' - ), - self.name, - type='autodoc', - ) - return - - # now, import the module and get object to document - if not self.import_object(): + if self._load_object_by_name() is None: return self._generate(more_content, real_modname, check_module, all_members) @@ -1174,16 +1051,6 @@ class ModuleDocumenter(Documenter): # don't document submodules automatically return False - def parse_name(self) -> bool: - ret = super().parse_name() - if self.args or self.retann: - logger.warning( - __('signature arguments or return annotation given for automodule %s'), - self.fullname, - type='autodoc', - ) - return ret - def _module_all(self) -> Sequence[str] | None: if self.object is not None and self.__all__ is None: try: @@ -1543,39 +1410,6 @@ class ClassDocumenter(Documenter): isattr and isinstance(member, NewType | TypeVar) ) - def import_object(self, raiseerror: bool = False) -> bool: - try: - im = _import_class( - module_name=self.modname, - obj_path=self.objpath, - mock_imports=self.config.autodoc_mock_imports, - get_attr=self.get_attr, - ) - except ImportError as exc: - if raiseerror: - raise - logger.warning(exc.args[0], type='autodoc', subtype='import_object') - self.env.note_reread() - return False - - self.object = obj = im.__dict__.pop('obj', None) - for k in 'module', 'parent', 'object_name', 'objpath', 'modname': - if hasattr(im, k): - setattr(self, k, getattr(im, k)) - - self.props = _ClassDefProperties( - obj_type=self.objtype, # type: ignore[arg-type] - name=im.object_name, - module_name=getattr(im, 'modname', self.modname), - parts=tuple(getattr(im, 'objpath', self.objpath)), - docstring_lines=(), - bases=getattr(obj, '__bases__', None), - _obj=obj, - _obj___name__=getattr(obj, '__name__', None), - ) - - return True - def _get_signature(self) -> tuple[Any | None, str | None, Signature | None]: if isinstance(self.object, NewType | TypeVar): # Suppress signature @@ -2064,41 +1898,6 @@ class DataDocumenter(Documenter): except PycodeError: pass - def import_object(self, raiseerror: bool = False) -> bool: - try: - im = _import_assignment_data( - module_name=self.modname, - obj_path=self.objpath, - mock_imports=self.config.autodoc_mock_imports, - type_aliases=self.config.autodoc_type_aliases, - get_attr=self.get_attr, - ) - except ImportError as exc: - if raiseerror: - raise - logger.warning(exc.args[0], type='autodoc', subtype='import_object') - self.env.note_reread() - return False - - self.object = obj = im.__dict__.pop('obj', None) - for k in 'module', 'parent', 'object_name': - if hasattr(im, k): - setattr(self, k, getattr(im, k)) - - self.props = _AssignStatementProperties( - obj_type=self.objtype, # type: ignore[arg-type] - name=im.object_name, - module_name=self.modname, - parts=tuple(self.objpath), - docstring_lines=(), - value=..., - annotation='', - class_var=False, - instance_var=False, - _obj=obj, - ) - return True - def should_suppress_value_header(self) -> bool: if self.object is UNINITIALIZED_ATTR: return True @@ -2212,43 +2011,6 @@ class MethodDocumenter(Documenter): ) -> bool: return inspect.isroutine(member) and not isinstance(parent, ModuleDocumenter) - def import_object(self, raiseerror: bool = False) -> bool: - try: - im = _import_method( - module_name=self.modname, - obj_path=self.objpath, - member_order=self.member_order, - mock_imports=self.config.autodoc_mock_imports, - get_attr=self.get_attr, - ) - except ImportError as exc: - if raiseerror: - raise - logger.warning(exc.args[0], type='autodoc', subtype='import_object') - self.env.note_reread() - return False - - self.object = obj = im.__dict__.pop('obj', None) - for k in 'module', 'parent', 'object_name', 'member_order': - if hasattr(im, k): - setattr(self, k, getattr(im, k)) - - obj_properties: set[_AutodocFuncProperty] = set() - if inspect.isstaticmethod(obj, cls=im.parent, name=im.object_name): - obj_properties.add('staticmethod') - if inspect.isclassmethod(obj): - obj_properties.add('classmethod') - self.props = _FunctionDefProperties( - obj_type=self.objtype, # type: ignore[arg-type] - name=im.object_name, - module_name=self.modname, - parts=tuple(self.objpath), - docstring_lines=(), - properties=frozenset(obj_properties), - _obj=obj, - ) - return True - def format_args(self, **kwargs: Any) -> str: if self.config.autodoc_typehints in {'none', 'description'}: kwargs.setdefault('show_annotation', False) @@ -2540,41 +2302,6 @@ class AttributeDocumenter(Documenter): # Failed to set __annotations__ (built-in, extensions, etc.) pass - def import_object(self, raiseerror: bool = False) -> bool: - try: - im = _import_assignment_attribute( - module_name=self.modname, - obj_path=self.objpath, - mock_imports=self.config.autodoc_mock_imports, - type_aliases=self.config.autodoc_type_aliases, - get_attr=self.get_attr, - ) - except ImportError as exc: - if raiseerror: - raise - logger.warning(exc.args[0], type='autodoc', subtype='import_object') - self.env.note_reread() - return False - - self.object = obj = im.__dict__.pop('obj', None) - for k in 'module', 'parent', 'object_name': - if hasattr(im, k): - setattr(self, k, getattr(im, k)) - - self.props = _AssignStatementProperties( - obj_type=self.objtype, # type: ignore[arg-type] - name=im.object_name, - module_name=self.modname, - parts=tuple(self.objpath), - docstring_lines=(), - value=..., - annotation='', - class_var=False, - instance_var=False, - _obj=obj, - ) - return True - @property def _is_non_data_descriptor(self) -> bool: return not inspect.isattributedescriptor(self.object) @@ -2744,42 +2471,6 @@ class PropertyDocumenter(Documenter): else: return False - def import_object(self, raiseerror: bool = False) -> bool: - try: - im = _import_property( - module_name=self.modname, - obj_path=self.objpath, - mock_imports=self.config.autodoc_mock_imports, - get_attr=self.get_attr, - ) - except ImportError as exc: - if raiseerror: - raise - logger.warning(exc.args[0], type='autodoc', subtype='import_object') - self.env.note_reread() - return False - if im is None: - return False - - self.object = obj = im.__dict__.pop('obj', None) - for k in 'module', 'parent', 'object_name', 'isclassmethod': - if hasattr(im, k): - setattr(self, k, getattr(im, k)) - - obj_properties: set[_AutodocFuncProperty] = set() - if getattr(im, 'isclassmethod', False): - obj_properties.add('classmethod') - self.props = _FunctionDefProperties( - obj_type=self.objtype, # type: ignore[arg-type] - name=im.object_name, - module_name=self.modname, - parts=tuple(self.objpath), - docstring_lines=(), - properties=frozenset(obj_properties), - _obj=obj, - ) - return True - def format_args(self, **kwargs: Any) -> str: func = self._get_property_getter() if func is None: diff --git a/sphinx/ext/autodoc/importer.py b/sphinx/ext/autodoc/importer.py index 03c791b6f2..0386a1c9f5 100644 --- a/sphinx/ext/autodoc/importer.py +++ b/sphinx/ext/autodoc/importer.py @@ -16,6 +16,13 @@ from pathlib import Path from typing import TYPE_CHECKING, NamedTuple, NewType, TypeVar from sphinx.errors import PycodeError +from sphinx.ext.autodoc._property_types import ( + _AssignStatementProperties, + _ClassDefProperties, + _FunctionDefProperties, + _ItemProperties, + _ModuleProperties, +) from sphinx.ext.autodoc._sentinels import ( INSTANCE_ATTR, RUNTIME_INSTANCE_ATTRIBUTE, @@ -23,6 +30,7 @@ from sphinx.ext.autodoc._sentinels import ( UNINITIALIZED_ATTR, ) from sphinx.ext.autodoc.mock import ismock, mock, undecorate +from sphinx.locale import __ from sphinx.pycode import ModuleAnalyzer from sphinx.util import inspect, logging from sphinx.util.inspect import ( @@ -42,7 +50,9 @@ if TYPE_CHECKING: from types import ModuleType from typing import Any, Protocol + from sphinx.environment import BuildEnvironment, _CurrentDocument from sphinx.ext.autodoc import ObjectMember + from sphinx.ext.autodoc._property_types import _AutodocFuncProperty, _AutodocObjType class _AttrGetter(Protocol): def __call__(self, obj: Any, name: str, default: Any = ..., /) -> Any: ... @@ -65,16 +75,6 @@ class _ImportedObject: #: object to document obj: Any - # ClassDocumenter - objpath: list[str] - modname: str - - # MethodDocumenter - member_order: int - - # PropertyDocumenter - isclassmethod: bool - def __init__( self, *, @@ -613,218 +613,70 @@ def _import_object( raise -def _import_class( +def _import_data_declaration( *, module_name: str, obj_path: Sequence[str], mock_imports: list[str], - get_attr: _AttrGetter = safe_getattr, -) -> _ImportedObject: - im = _import_object( - module_name=module_name, - obj_path=obj_path, - mock_imports=mock_imports, - get_attr=get_attr, - ) - - if isinstance(im.obj, NewType | TypeVar): - obj_module_name = getattr(im.obj, '__module__', module_name) - if obj_module_name != module_name and module_name.startswith(obj_module_name): - bases = module_name[len(obj_module_name) :].strip('.').split('.') - im.objpath = bases + list(obj_path) - im.modname = obj_module_name - return im + type_aliases: dict[str, Any] | None, +) -> _ImportedObject | None: + # annotation only instance variable (PEP-526) + try: + with mock(mock_imports): + parent = import_module(module_name) + annotations = get_type_hints(parent, None, type_aliases, include_extras=True) + if obj_path[-1] in annotations: + im = _ImportedObject( + parent=parent, + obj=UNINITIALIZED_ATTR, + ) + return im + except ImportError: + pass + return None -def _import_method( - *, - module_name: str, - obj_path: Sequence[str], - member_order: int, - mock_imports: list[str], - get_attr: _AttrGetter = safe_getattr, -) -> _ImportedObject: - im = _import_object( - module_name=module_name, - obj_path=obj_path, - mock_imports=mock_imports, - get_attr=get_attr, - ) - - # to distinguish classmethod/staticmethod - obj = im.parent.__dict__.get(im.object_name, im.obj) - if inspect.isstaticmethod(obj, cls=im.parent, name=im.object_name): - # document static members before regular methods - im.member_order = member_order - 1 - elif inspect.isclassmethod(obj): - # document class methods before static methods as - # they usually behave as alternative constructors - im.member_order = member_order - 2 - return im - - -def _import_property( +def _import_attribute_declaration( *, module_name: str, obj_path: Sequence[str], mock_imports: list[str], + type_aliases: dict[str, Any] | None, get_attr: _AttrGetter = safe_getattr, ) -> _ImportedObject | None: - im = _import_object( - module_name=module_name, - obj_path=obj_path, - mock_imports=mock_imports, - get_attr=get_attr, - ) - - if not inspect.isproperty(im.obj): - # Support for class properties. Note: these only work on Python 3.9. - __dict__ = safe_getattr(im.parent, '__dict__', {}) - obj = __dict__.get(obj_path[-1]) - if isinstance(obj, classmethod) and inspect.isproperty(obj.__func__): - im.obj = obj.__func__ - im.isclassmethod = True + # Support runtime & uninitialized instance attributes. + # + # The former are defined in __init__() methods with doc-comments. + # The latter are PEP-526 style annotation only annotations. + # + # class Foo: + # attr: int #: uninitialized attribute + # + # def __init__(self): + # self.attr = None #: runtime attribute + try: + with mock(mock_imports): + ret = _import_from_module_and_path( + module_name=module_name, obj_path=obj_path[:-1], get_attr=get_attr + ) + parent = ret.obj + if _is_runtime_instance_attribute(parent=parent, obj_path=obj_path): + im = _ImportedObject( + parent=parent, + obj=RUNTIME_INSTANCE_ATTRIBUTE, + ) return im - else: - return None - - return im - - -def _import_assignment_data( - *, - module_name: str, - obj_path: Sequence[str], - mock_imports: list[str], - type_aliases: dict[str, Any] | None, - get_attr: _AttrGetter = safe_getattr, -) -> _ImportedObject: - import_failed = True - try: - with mock(mock_imports): - im = _import_from_module_and_path( - module_name=module_name, obj_path=obj_path, get_attr=get_attr + elif _is_uninitialized_instance_attribute( + parent=parent, obj_path=obj_path, type_aliases=type_aliases + ): + im = _ImportedObject( + parent=parent, + obj=UNINITIALIZED_ATTR, ) - if ismock(im.obj): - im.obj = undecorate(im.obj) - import_failed = False - except ImportError as exc: - # annotation only instance variable (PEP-526) - try: - with mock(mock_imports): - parent = import_module(module_name) - annotations = get_type_hints( - parent, None, type_aliases, include_extras=True - ) - if obj_path[-1] in annotations: - im = _ImportedObject( - parent=parent, - obj=UNINITIALIZED_ATTR, - ) - import_failed = False - except ImportError: - pass - - if import_failed: - raise - - # Update __annotations__ to support type_comment and so on - annotations = dict(inspect.getannotations(im.parent)) - im.parent.__annotations__ = annotations - - try: - analyzer = ModuleAnalyzer.for_module(module_name) - analyzer.analyze() - for (classname, attrname), annotation in analyzer.annotations.items(): - if not classname and attrname not in annotations: - annotations[attrname] = annotation - except PycodeError: + return im + except ImportError: pass - return im - - -def _import_assignment_attribute( - *, - module_name: str, - obj_path: Sequence[str], - mock_imports: list[str], - type_aliases: dict[str, Any] | None, - get_attr: _AttrGetter = safe_getattr, -) -> _ImportedObject: - import_failed = True - try: - with mock(mock_imports): - im = _import_from_module_and_path( - module_name=module_name, obj_path=obj_path, get_attr=get_attr - ) - if ismock(im.obj): - im.obj = undecorate(im.obj) - import_failed = False - except ImportError as exc: - # Support runtime & uninitialized instance attributes. - # - # The former are defined in __init__() methods with doc-comments. - # The latter are PEP-526 style annotation only annotations. - # - # class Foo: - # attr: int #: uninitialized attribute - # - # def __init__(self): - # self.attr = None #: runtime attribute - try: - with mock(mock_imports): - ret = _import_from_module_and_path( - module_name=module_name, obj_path=obj_path[:-1], get_attr=get_attr - ) - parent = ret.obj - if _is_runtime_instance_attribute(parent=parent, obj_path=obj_path): - im = _ImportedObject( - parent=parent, - obj=RUNTIME_INSTANCE_ATTRIBUTE, - ) - import_failed = False - elif _is_uninitialized_instance_attribute( - parent=parent, obj_path=obj_path, type_aliases=type_aliases - ): - im = _ImportedObject( - parent=parent, - obj=UNINITIALIZED_ATTR, - ) - import_failed = False - except ImportError: - pass - - if import_failed: - raise - - if _is_slots_attribute(parent=im.parent, obj_path=obj_path): - im.obj = SLOTS_ATTR - elif inspect.isenumattribute(im.obj): - im.obj = im.obj.value - if im.parent: - # Update __annotations__ to support type_comment and so on. - try: - annotations = dict(inspect.getannotations(im.parent)) - im.parent.__annotations__ = annotations - - for cls in inspect.getmro(im.parent): - try: - module = safe_getattr(cls, '__module__') - qualname = safe_getattr(cls, '__qualname__') - - analyzer = ModuleAnalyzer.for_module(module) - analyzer.analyze() - anns = analyzer.annotations - for (classname, attrname), annotation in anns.items(): - if classname == qualname and attrname not in annotations: - annotations[attrname] = annotation - except (AttributeError, PycodeError): - pass - except (AttributeError, TypeError): - # Failed to set __annotations__ (built-in, extensions, etc.) - pass - - return im + return None def _is_runtime_instance_attribute(*, parent: Any, obj_path: Sequence[str]) -> bool: @@ -895,3 +747,388 @@ def _is_slots_attribute(*, parent: Any, obj_path: Sequence[str]) -> bool: return False except (ValueError, TypeError): return False + + +def _load_object_by_name( + *, + name: str, + objtype: _AutodocObjType, + mock_imports: list[str], + type_aliases: dict[str, Any] | None, + current_document: _CurrentDocument, + env: BuildEnvironment, + get_attr: _AttrGetter, +) -> tuple[_ItemProperties, str | None, str | None, ModuleType | None, Any] | None: + """Import and load the object given by *name*.""" + parsed = _parse_name( + name=name, + objtype=objtype, + current_document=current_document, + env=env, + ) + if parsed is None: + return None + module_name, parts, args, retann = parsed + + # Import the module and get the object to document + try: + im = _import_object( + module_name=module_name, + obj_path=parts, + mock_imports=mock_imports, + get_attr=get_attr, + ) + except ImportError as exc: + if objtype == 'data': + im_ = _import_data_declaration( + module_name=module_name, + obj_path=parts, + mock_imports=mock_imports, + type_aliases=type_aliases, + ) + elif objtype == 'attribute': + im_ = _import_attribute_declaration( + module_name=module_name, + obj_path=parts, + mock_imports=mock_imports, + type_aliases=type_aliases, + get_attr=get_attr, + ) + else: + im_ = None + if im_ is None: + logger.warning(exc.args[0], type='autodoc', subtype='import_object') + env.note_reread() + return None + else: + im = im_ + + # Assemble object properties from the imported object. + props: _ItemProperties + module = im.module + parent = im.parent + object_name = im.object_name + obj = im.obj + obj_properties: set[_AutodocFuncProperty] = set() + if objtype == 'module': + file_path = getattr(module, '__file__', None) + try: + mod_all = inspect.getall(module) + except ValueError: + mod_all = None + + props = _ModuleProperties( + obj_type=objtype, + name=object_name, + module_name=module_name, + docstring_lines=(), + file_path=Path(file_path) if file_path is not None else None, + all=tuple(mod_all) if mod_all is not None else None, + _obj=obj, + ) + elif objtype in {'class', 'exception'}: + if isinstance(obj, NewType | TypeVar): + obj_module_name = getattr(obj, '__module__', module_name) + if obj_module_name != module_name and module_name.startswith( + obj_module_name + ): + bases = module_name[len(obj_module_name) :].strip('.').split('.') + parts = tuple(bases) + parts + module_name = obj_module_name + + props = _ClassDefProperties( + obj_type=objtype, # type: ignore[arg-type] + name=object_name, + module_name=module_name, + parts=parts, + docstring_lines=(), + bases=getattr(obj, '__bases__', None), + _obj=obj, + _obj___name__=getattr(obj, '__name__', None), + ) + elif objtype in {'function', 'decorator'}: + if inspect.isstaticmethod(obj, cls=parent, name=object_name): + obj_properties.add('staticmethod') + if inspect.isclassmethod(obj): + obj_properties.add('classmethod') + + props = _FunctionDefProperties( + obj_type=objtype, # type: ignore[arg-type] + name=object_name, + module_name=module_name, + parts=parts, + docstring_lines=(), + properties=frozenset(obj_properties), + _obj=obj, + ) + elif objtype == 'method': + # to distinguish classmethod/staticmethod + obj_ = parent.__dict__.get(object_name, obj) + if inspect.isstaticmethod(obj_, cls=parent, name=object_name): + obj_properties.add('staticmethod') + elif inspect.isclassmethod(obj_): + obj_properties.add('classmethod') + + props = _FunctionDefProperties( + obj_type=objtype, + name=object_name, + module_name=module_name, + parts=parts, + docstring_lines=(), + properties=frozenset(obj_properties), + _obj=obj, + ) + elif objtype == 'property': + if not inspect.isproperty(obj): + # Support for class properties. Note: these only work on Python 3.9. + __dict__ = safe_getattr(parent, '__dict__', {}) + obj = __dict__.get(parts[-1]) + if isinstance(obj, classmethod) and inspect.isproperty(obj.__func__): + obj = obj.__func__ + obj_properties.add('classmethod') + else: + return None + + props = _FunctionDefProperties( + obj_type=objtype, + name=object_name, + module_name=module_name, + parts=parts, + docstring_lines=(), + properties=frozenset(obj_properties), + _obj=obj, + ) + elif objtype == 'data': + # Update __annotations__ to support type_comment and so on + annotations = dict(inspect.getannotations(parent)) + parent.__annotations__ = annotations + + try: + analyzer = ModuleAnalyzer.for_module(module_name) + analyzer.analyze() + for ( + classname, + attrname, + ), annotation in analyzer.annotations.items(): + if not classname and attrname not in annotations: + annotations[attrname] = annotation + except PycodeError: + pass + + props = _AssignStatementProperties( + obj_type=objtype, + name=object_name, + module_name=module_name, + parts=parts, + docstring_lines=(), + value=..., + annotation='', + class_var=False, + instance_var=False, + _obj=obj, + ) + elif objtype == 'attribute': + if _is_slots_attribute(parent=parent, obj_path=parts): + obj = SLOTS_ATTR + elif inspect.isenumattribute(obj): + obj = obj.value + if parent: + # Update __annotations__ to support type_comment and so on. + try: + annotations = dict(inspect.getannotations(parent)) + parent.__annotations__ = annotations + + for cls in inspect.getmro(parent): + try: + module = safe_getattr(cls, '__module__') + qualname = safe_getattr(cls, '__qualname__') + + analyzer = ModuleAnalyzer.for_module(module) + analyzer.analyze() + anns = analyzer.annotations + for (classname, attrname), annotation in anns.items(): + if classname == qualname and attrname not in annotations: + annotations[attrname] = annotation + except (AttributeError, PycodeError): + pass + except (AttributeError, TypeError): + # Failed to set __annotations__ (built-in, extensions, etc.) + pass + + props = _AssignStatementProperties( + obj_type=objtype, + name=object_name, + module_name=module_name, + parts=parts, + docstring_lines=(), + value=..., + annotation='', + class_var=False, + instance_var=False, + _obj=obj, + ) + else: + props = _ItemProperties( + obj_type=objtype, + name=object_name, + module_name=module_name, + parts=parts, + docstring_lines=(), + _obj=obj, + ) + + return props, args, retann, module, parent + + +def _parse_name( + *, + name: str, + objtype: _AutodocObjType, + current_document: _CurrentDocument, + env: BuildEnvironment, +) -> tuple[str, tuple[str, ...], str | None, str | None] | None: + """Parse *name* into module name, path, arguments, and return annotation.""" + from sphinx.ext.autodoc._documenters import py_ext_sig_re + + # Parse the definition in *name*. + # autodoc directives for classes and functions can contain a signature, + # which overrides the autogenerated one. + matched = py_ext_sig_re.match(name) + if matched is None: + logger.warning( + __('invalid signature for auto%s (%r)'), + objtype, + name, + type='autodoc', + ) + # need a module to import + logger.warning( + __( + "don't know which module to import for autodocumenting " + '%r (try placing a "module" or "currentmodule" directive ' + 'in the document, or giving an explicit module name)' + ), + name, + type='autodoc', + ) + return None + + explicit_modname, path, base, _tp_list, args, retann = matched.groups() + + # Support explicit module and class name separation via ``::`` + if explicit_modname is not None: + module_name = explicit_modname.removesuffix('::') + parents = path.rstrip('.').split('.') if path else () + else: + module_name = None + parents = () + + resolved = _resolve_name( + objtype=objtype, + module_name=module_name, + path=path, + base=base, + parents=parents, + current_document=current_document, + ref_context_py_module=env.ref_context.get('py:module'), + ref_context_py_class=env.ref_context.get('py:class', ''), + ) + if resolved is None: + msg = 'must be implemented in subclasses' + raise NotImplementedError(msg) + module_name, parts = resolved + + if objtype == 'module' and args: + msg = __("signature arguments given for automodule: '%s'") + logger.warning(msg, name, type='autodoc') + return None + if objtype == 'module' and retann: + msg = __("return annotation given for automodule: '%s'") + logger.warning(msg, name, type='autodoc') + return None + + if not module_name: + # Could not resolve a module to import + logger.warning( + __( + "don't know which module to import for autodocumenting " + '%r (try placing a "module" or "currentmodule" directive ' + 'in the document, or giving an explicit module name)' + ), + name, + type='autodoc', + ) + return None + + return module_name, parts, args, retann + + +def _resolve_name( + *, + objtype: str, + module_name: str | None, + path: str | None, + base: str, + parents: Sequence[str], + current_document: _CurrentDocument, + ref_context_py_module: str | None, + ref_context_py_class: str, +) -> tuple[str | None, tuple[str, ...]] | None: + """Resolve the module and name of the object to document given by the + arguments and the current module/class. + + Must return a pair of the module name and a chain of attributes; for + example, it would return ``('zipfile', ('ZipFile', 'open'))`` for the + ``zipfile.ZipFile.open`` method. + """ + if objtype == 'module': + if module_name is not None: + logger.warning( + __('"::" in automodule name doesn\'t make sense'), type='autodoc' + ) + return (path or '') + base, () + + if objtype in {'class', 'exception', 'function', 'decorator', 'data'}: + if module_name is not None: + return module_name, (*parents, base) + if path: + module_name = path.rstrip('.') + return module_name, (*parents, base) + + # if documenting a toplevel object without explicit module, + # it can be contained in another auto directive ... + module_name = current_document.autodoc_module + # ... or in the scope of a module directive + if not module_name: + module_name = ref_context_py_module + # ... else, it stays None, which means invalid + return module_name, (*parents, base) + + if objtype in {'method', 'property', 'attribute'}: + if module_name is not None: + return module_name, (*parents, base) + + if path: + mod_cls = path.rstrip('.') + else: + # if documenting a class-level object without path, + # there must be a current class, either from a parent + # auto directive ... + mod_cls = current_document.autodoc_class + # ... or from a class directive + if not mod_cls: + mod_cls = ref_context_py_class + # ... if still falsy, there's no way to know + if not mod_cls: + return None, () + module_name, _sep, cls = mod_cls.rpartition('.') + parents = [cls] + # if the module name is still missing, get it like above + if not module_name: + module_name = current_document.autodoc_module + if not module_name: + module_name = ref_context_py_module + # ... else, it stays None, which means invalid + return module_name, (*parents, base) + + return None diff --git a/tests/test_extensions/test_ext_autodoc.py b/tests/test_extensions/test_ext_autodoc.py index 233fea3dc4..79cd8affc0 100644 --- a/tests/test_extensions/test_ext_autodoc.py +++ b/tests/test_extensions/test_ext_autodoc.py @@ -27,6 +27,7 @@ from sphinx.ext.autodoc._sentinels import ALL # NEVER import these objects from sphinx.ext.autodoc directly from sphinx.ext.autodoc.directive import DocumenterBridge +from sphinx.ext.autodoc.importer import _parse_name from tests.test_extensions.autodoc_util import do_autodoc @@ -80,58 +81,54 @@ processed_signatures = [] @pytest.mark.sphinx('html', testroot='root') def test_parse_name(app): - def verify(objtype, name, result): - inst = app.registry.documenters[objtype](directive, name) - assert inst.parse_name() - assert (inst.modname, inst.objpath, inst.args, inst.retann) == result + env = app.env + current_document = env.current_document - directive = make_directive_bridge(app.env) + def parse(objtype, name): + parsed = _parse_name( + name=name, objtype=objtype, current_document=current_document, env=env + ) + if parsed is None: + return None + module_name, parts, args, retann = parsed + return module_name, list(parts), args, retann # for modules - 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)) + parsed = parse('module', 'test_ext_autodoc') + assert parsed == ('test_ext_autodoc', [], None, None) + parsed = parse('module', 'test.test_ext_autodoc') + assert parsed == ('test.test_ext_autodoc', [], None, None) + parsed = parse('module', 'test(arg)') + assert parsed is None assert 'signature arguments' in app.warning.getvalue() # for functions/classes - verify( - 'function', - 'test_ext_autodoc.raises', - ('test_ext_autodoc', ['raises'], None, None), - ) - verify( - 'function', - 'test_ext_autodoc.raises(exc) -> None', - ('test_ext_autodoc', ['raises'], 'exc', 'None'), - ) - directive.env.current_document.autodoc_module = 'test_ext_autodoc' - verify('function', 'raises', ('test_ext_autodoc', ['raises'], None, None)) - directive.env.current_document.autodoc_module = '' + parsed = parse('function', 'test_ext_autodoc.raises') + assert parsed == ('test_ext_autodoc', ['raises'], None, None) + parsed = parse('function', 'test_ext_autodoc.raises(exc) -> None') + assert parsed == ('test_ext_autodoc', ['raises'], 'exc', 'None') + current_document.autodoc_module = 'test_ext_autodoc' + parsed = parse('function', 'raises') + assert parsed == ('test_ext_autodoc', ['raises'], None, None) + current_document.autodoc_module = '' - directive.env.ref_context['py:module'] = 'test_ext_autodoc' - verify('function', 'raises', ('test_ext_autodoc', ['raises'], None, None)) - verify('class', 'Base', ('test_ext_autodoc', ['Base'], None, None)) + env.ref_context['py:module'] = 'test_ext_autodoc' + parsed = parse('function', 'raises') + assert parsed == ('test_ext_autodoc', ['raises'], None, None) + parsed = parse('class', 'Base') + assert parsed == ('test_ext_autodoc', ['Base'], None, None) # for members - directive.env.ref_context['py:module'] = 'sphinx.testing.util' - verify( - 'method', - 'SphinxTestApp.cleanup', - ('sphinx.testing.util', ['SphinxTestApp', 'cleanup'], None, None), - ) - directive.env.ref_context['py:module'] = 'sphinx.testing.util' - directive.env.ref_context['py:class'] = 'Foo' - directive.env.current_document.autodoc_class = 'SphinxTestApp' - verify( - 'method', - 'cleanup', - ('sphinx.testing.util', ['SphinxTestApp', 'cleanup'], None, None), - ) - verify( - 'method', - 'SphinxTestApp.cleanup', - ('sphinx.testing.util', ['SphinxTestApp', 'cleanup'], None, None), - ) + env.ref_context['py:module'] = 'sphinx.testing.util' + parsed = parse('method', 'SphinxTestApp.cleanup') + assert parsed == ('sphinx.testing.util', ['SphinxTestApp', 'cleanup'], None, None) + env.ref_context['py:module'] = 'sphinx.testing.util' + env.ref_context['py:class'] = 'Foo' + current_document.autodoc_class = 'SphinxTestApp' + parsed = parse('method', 'cleanup') + assert parsed == ('sphinx.testing.util', ['SphinxTestApp', 'cleanup'], None, None) + parsed = parse('method', 'SphinxTestApp.cleanup') + assert parsed == ('sphinx.testing.util', ['SphinxTestApp', 'cleanup'], None, None) @pytest.mark.sphinx('html', testroot='root') @@ -431,24 +428,30 @@ def test_get_doc(app): assert getdocl('function', J().foo) == ['Method docstring'] +class _MyDocumenter(ModuleLevelDocumenter): + objtype = 'integer' + directivetype = 'integer' + priority = 100 + + @classmethod + def can_document_member(cls, member, membername, isattr, parent): + return isinstance(member, int) + + def document_members(self, all_members=False): + return + + @pytest.mark.sphinx('html', testroot='ext-autodoc') def test_new_documenter(app): - class MyDocumenter(ModuleLevelDocumenter): - objtype = 'integer' - directivetype = 'integer' - priority = 100 - - @classmethod - def can_document_member(cls, member, membername, isattr, parent): - return isinstance(member, int) - - def document_members(self, all_members=False): - return - - app.add_autodocumenter(MyDocumenter) + app.add_autodocumenter(_MyDocumenter) options = {'members': 'integer'} - actual = do_autodoc(app, 'module', 'target', options) + with pytest.raises( + NotImplementedError, match=r'^must be implemented in subclasses$' + ): + # TODO: Fix! Perhaps add a way to signal module/class-level? + actual = do_autodoc(app, 'module', 'target', options) + return assert list(actual) == [ '', '.. py:module:: target',