Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 162 additions & 7 deletions sphinx/ext/autodoc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,24 @@
from typing import (TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional, Sequence,
Set, Tuple, Type, TypeVar, Union)

try: # Python 3.10+
from typing import TypeAlias # type: ignore[attr-defined]
except ImportError: # pragma: no cover - unavailable on older runtimes
try: # Fallback for environments providing typing_extensions
from typing_extensions import TypeAlias # type: ignore
except ImportError: # pragma: no cover - typing_extensions not installed
TypeAlias = None # type: ignore[assignment]

try: # Python 3.12+
from typing import TypeAliasType # type: ignore[attr-defined]
except ImportError: # pragma: no cover - unavailable on older runtimes
TypeAliasType = None # type: ignore[assignment]

try: # Fallback for environments providing typing_extensions
from typing_extensions import TypeAliasType as ExtTypeAliasType # type: ignore
except ImportError: # pragma: no cover - typing_extensions not installed
ExtTypeAliasType = None # type: ignore[assignment]

from docutils.statemachine import StringList

import sphinx
Expand Down Expand Up @@ -86,6 +104,7 @@ def __contains__(self, item: Any) -> bool:
UNINITIALIZED_ATTR = object()
INSTANCEATTR = object()
SLOTSATTR = object()
TYPE_ALIAS_STRINGS = {'TypeAlias', 'typing.TypeAlias', 'typing_extensions.TypeAlias'}


def members_option(arg: Any) -> Union[object, List[str]]:
Expand Down Expand Up @@ -842,12 +861,13 @@ def document_members(self, all_members: bool = False) -> None:
if not classes:
# don't know how to document this member
continue
# prefer the documenter with the highest priority
classes.sort(key=lambda cls: cls.priority)
documenter_class = self.select_member_documenter(classes, member, mname, isattr)
if not documenter_class:
continue
# give explicitly separated module name, so that members
# of inner classes can be documented
full_mname = self.modname + '::' + '.'.join(self.objpath + [mname])
documenter = classes[-1](self.directive, full_mname, self.indent)
documenter = documenter_class(self.directive, full_mname, self.indent)
memberdocumenters.append((documenter, isattr))

member_order = self.options.member_order or self.config.autodoc_member_order
Expand Down Expand Up @@ -887,6 +907,16 @@ def keyfunc(entry: Tuple[Documenter, bool]) -> int:

return documenters

def select_member_documenter(self, documenters: List[Type["Documenter"]],
member: Any, membername: str, isattr: bool
) -> Optional[Type["Documenter"]]:
"""Pick the documenter class responsible for a member."""
documenters.sort(key=lambda cls: cls.priority)
if documenters:
return documenters[-1]
else:
return None

def generate(self, more_content: Optional[StringList] = None, real_modname: str = None,
check_module: bool = False, all_members: bool = False) -> None:
"""Generate reST for the object given by *self.name*, and possibly for
Expand Down Expand Up @@ -998,6 +1028,7 @@ def __init__(self, *args: Any) -> None:
super().__init__(*args)
merge_members_option(self.options)
self.__all__: Optional[Sequence[str]] = None
self._module_annotations_cache: Optional[Dict[str, Any]] = None

@classmethod
def can_document_member(cls, member: Any, membername: str, isattr: bool, parent: Any
Expand Down Expand Up @@ -1100,6 +1131,95 @@ def get_object_members(self, want_all: bool) -> Tuple[bool, ObjectMembers]:
type='autodoc')
return False, ret

def select_member_documenter(self, documenters: List[Type["Documenter"]],
member: Any, membername: str, isattr: bool
) -> Optional[Type["Documenter"]]:
documenters.sort(key=lambda cls: cls.priority)
preferred = self._prefer_data_documenter(documenters, member, membername, isattr)
if preferred:
return preferred
elif documenters:
return documenters[-1]
else:
return None

def _prefer_data_documenter(self, documenters: List[Type["Documenter"]], member: Any,
membername: str, isattr: bool) -> Optional[Type["Documenter"]]:
if not isattr:
return None

data_documenter = None
for candidate in documenters:
if issubclass(candidate, DataDocumenter):
data_documenter = candidate

if not data_documenter:
return None
if not isinstance(member, type):
return None

member_type_name = getattr(member, '__name__', None)
if not member_type_name or member_type_name == membername:
return None

if self._has_module_attribute_doc(membername):
return data_documenter

if self._annotation_is_type_alias(membername):
return data_documenter

return None

def _has_module_attribute_doc(self, membername: str) -> bool:
if not self.analyzer:
return False

doc = self.analyzer.attr_docs.get(('', membername))
if not doc:
return False

return any(line.strip() for line in doc)

def _annotation_is_type_alias(self, membername: str) -> bool:
annotations = self._get_module_annotations()
if not annotations:
return False

annotation = annotations.get(membername)
if annotation is None:
return False

if TypeAlias is not None and annotation is TypeAlias:
return True

alias_types = tuple(alias for alias in (TypeAliasType, ExtTypeAliasType) if alias)
if alias_types and isinstance(annotation, alias_types):
return True

if isinstance(annotation, str):
target = annotation.strip()
if target in TYPE_ALIAS_STRINGS or target.split('.')[-1] == 'TypeAlias':
return True

annotation_class = getattr(annotation, '__class__', None)
if annotation_class and annotation_class.__name__ == 'TypeAliasType':
return True

return False

def _get_module_annotations(self) -> Dict[str, Any]:
if self._module_annotations_cache is None:
try:
annotations = inspect.getannotations(self.object)
except Exception:
annotations = {}
else:
annotations = dict(annotations)

self._module_annotations_cache = annotations

return self._module_annotations_cache

def sort_members(self, documenters: List[Tuple["Documenter", bool]],
order: str) -> List[Tuple["Documenter", bool]]:
if order == 'bysource' and self.__all__:
Expand Down Expand Up @@ -1723,19 +1843,44 @@ def get_doc(self, ignore: int = None) -> Optional[List[List[str]]]:

def add_content(self, more_content: Optional[StringList], no_docstring: bool = False
) -> None:
alias_analyzer: Optional[ModuleAnalyzer] = None
original_analyzer: Optional[ModuleAnalyzer] = None

if self.doc_as_attr:
original_analyzer = self.analyzer
alias_analyzer = self._load_alias_module_analyzer()
if alias_analyzer:
self.analyzer = alias_analyzer

alias_content = StringList()
try:
more_content = StringList([_('alias of %s') % restify(self.object)], source='')
alias_content.append(_('alias of %s') % restify(self.object), '')
except AttributeError:
pass # Invalid class object is passed.

super().add_content(more_content)
if more_content:
alias_content.extend(more_content)
more_content = alias_content

try:
super().add_content(more_content)
finally:
if alias_analyzer is not None:
self.analyzer = original_analyzer

def document_members(self, all_members: bool = False) -> None:
if self.doc_as_attr:
return
super().document_members(all_members)

def _load_alias_module_analyzer(self) -> Optional[ModuleAnalyzer]:
try:
analyzer = ModuleAnalyzer.for_module(self.modname)
analyzer.analyze()
return analyzer
except PycodeError:
return None

def generate(self, more_content: Optional[StringList] = None, real_modname: str = None,
check_module: bool = False, all_members: bool = False) -> None:
# Do not pass real_modname and use the name from the __module__
Expand Down Expand Up @@ -1810,12 +1955,15 @@ class NewTypeMixin(DataDocumenterMixinBase):
supporting NewTypes.
"""

def _is_new_type(self) -> bool:
return inspect.isNewType(self.object) or hasattr(self.object, '__supertype__')

def should_suppress_directive_header(self) -> bool:
return (inspect.isNewType(self.object) or
return (self._is_new_type() or
super().should_suppress_directive_header())

def update_content(self, more_content: StringList) -> None:
if inspect.isNewType(self.object):
if self._is_new_type():
supertype = restify(self.object.__supertype__)
more_content.append(_('alias of %s') % supertype, '')
more_content.append('', '')
Expand Down Expand Up @@ -1944,6 +2092,13 @@ def import_object(self, raiseerror: bool = False) -> bool:

return ret

def update_content(self, more_content: StringList) -> None:
super().update_content(more_content)

if inspect.isclass(self.object):
more_content.append(_('alias of %s') % restify(self.object), '')
more_content.append('', '')

def should_suppress_value_header(self) -> bool:
if super().should_suppress_value_header():
return True
Expand Down
20 changes: 20 additions & 0 deletions tests/roots/test-ext-autodoc/target/type_alias_docstrings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from typing import List, TypeAlias, Union


SimpleInt = int
"""Docstring for SimpleInt."""


MyInt: TypeAlias = int
"""Docstring for MyInt."""


IntListOrUnion: TypeAlias = Union[List[int], int]
"""Docstring for IntListOrUnion."""


class _Aliased:
pass


AliasWithoutDoc = _Aliased
44 changes: 44 additions & 0 deletions tests/test_ext_autodoc_automodule.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,50 @@ def test_automodule_undoc_members(app):
]


@pytest.mark.sphinx('html', testroot='ext-autodoc',
confoverrides={'autodoc_typehints': 'description'})
def test_type_alias_docstrings(app):
options = {'members': None, 'undoc-members': None}
actual = do_autodoc(app, 'module', 'target.type_alias_docstrings', options)
assert list(actual) == [
'',
'.. py:module:: target.type_alias_docstrings',
'',
'',
'.. py:attribute:: AliasWithoutDoc',
' :module: target.type_alias_docstrings',
'',
' alias of :class:`target.type_alias_docstrings._Aliased`',
'',
'.. py:data:: IntListOrUnion',
' :module: target.type_alias_docstrings',
'',
' Docstring for IntListOrUnion.',
'',
' alias of :obj:`~typing.Union`\\ [:class:`~typing.List`\\ [:class:`int`], :class:`int`]',
'',
'',
'.. py:data:: MyInt',
' :module: target.type_alias_docstrings',
' :type: TypeAlias',
" :value: <class 'int'>",
'',
' Docstring for MyInt.',
'',
' alias of :class:`int`',
'',
'',
'.. py:data:: SimpleInt',
' :module: target.type_alias_docstrings',
" :value: <class 'int'>",
'',
' Docstring for SimpleInt.',
'',
' alias of :class:`int`',
'',
]


@pytest.mark.sphinx('html', testroot='ext-autodoc')
def test_automodule_special_members(app):
options = {'members': None,
Expand Down