Split autodoc name parsing utilities to a new module (#14033)

This commit is contained in:
Adam Turner authored and GitHub committed 2025-11-04 21:32:15 +00:00
1 parent 6de23cd95e
commit 4d2bd1e862
6 files changed
+286 -231

No files matched your search

+1 -1
View File
@@ -25,6 +25,7 @@ from sphinx.ext.autodoc._directive_options import (
)
from sphinx.ext.autodoc._event_listeners import between, cut_lines
from sphinx.ext.autodoc._member_finder import ObjectMember, special_member_re
from sphinx.ext.autodoc._names import py_ext_sig_re
from sphinx.ext.autodoc._sentinels import ALL, EMPTY, SUPPRESS, UNINITIALIZED_ATTR
from sphinx.ext.autodoc._sentinels import (
INSTANCE_ATTR as INSTANCEATTR,
@@ -33,7 +34,6 @@ from sphinx.ext.autodoc._sentinels import (
SLOTS_ATTR as SLOTSATTR,
)
from sphinx.ext.autodoc.directive import AutodocDirective
from sphinx.ext.autodoc.importer import py_ext_sig_re
from sphinx.ext.autodoc.typehints import _merge_typehints
if TYPE_CHECKING:
+183
View File
@@ -0,0 +1,183 @@
"""Importer utilities for autodoc"""
from __future__ import annotations
import re
from typing import TYPE_CHECKING
from sphinx.locale import __
from sphinx.util import logging
if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
from sphinx.environment import _CurrentDocument
from sphinx.ext.autodoc._property_types import _AutodocObjType
logger = logging.getLogger(__name__)
#: extended signature RE: with explicit module name separated by ::
py_ext_sig_re = re.compile(
r"""^ ([\w.]+::)? # explicit module name
([\w.]+\.)? # module and/or class name(s)
(\w+) \s* # thing name
(?: \[\s*(.*?)\s*])? # optional: type parameters list
(?: \((.*)\) # optional: arguments
(?:\s* -> \s* (.*))? # return annotation
)? $ # and nothing more
""",
re.VERBOSE,
)
def _parse_name(
*,
name: str,
objtype: _AutodocObjType,
current_document: _CurrentDocument,
ref_context: Mapping[str, str | None],
) -> tuple[str, tuple[str, ...], str | None, str | None] | None:
"""Parse *name* into module name, path, arguments, and return annotation."""
# 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()
if args is not None:
args = f'({args})'
# 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=ref_context.get('py:module'),
ref_context_py_class=ref_context.get('py:class', ''), # type: ignore[arg-type]
)
if resolved is None:
return None
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: _AutodocObjType,
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', 'type'}:
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
+1 -2
View File
@@ -7,6 +7,7 @@ from inspect import Parameter, Signature
from typing import TYPE_CHECKING, NewType, TypeVar
from sphinx.errors import PycodeError
from sphinx.ext.autodoc._names import py_ext_sig_re
from sphinx.ext.autodoc._property_types import _AssignStatementProperties
from sphinx.ext.autodoc.preserve_defaults import update_default_value
from sphinx.ext.autodoc.type_comment import _update_annotations_using_type_comments
@@ -336,8 +337,6 @@ def _extract_signatures_from_docstrings(
props: _ItemProperties,
tab_width: int,
) -> list[_FormattedSignature]:
from sphinx.ext.autodoc.importer import py_ext_sig_re
signatures: list[_FormattedSignature] = []
# candidates of the object name
+1 -167
View File
@@ -23,6 +23,7 @@ from sphinx.ext.autodoc._docstrings import (
_docstring_lines_for_props,
_get_docstring_lines,
)
from sphinx.ext.autodoc._names import _parse_name
from sphinx.ext.autodoc._property_types import (
_AssignStatementProperties,
_ClassDefProperties,
@@ -68,19 +69,6 @@ logger = logging.getLogger(__name__)
_hide_value_re = re.compile(r'^:meta \s*hide-value:( +|$)')
#: extended signature RE: with explicit module name separated by ::
py_ext_sig_re = re.compile(
r"""^ ([\w.]+::)? # explicit module name
([\w.]+\.)? # module and/or class name(s)
(\w+) \s* # thing name
(?: \[\s*(.*?)\s*])? # optional: type parameters list
(?: \((.*)\) # optional: arguments
(?:\s* -> \s* (.*))? # return annotation
)? $ # and nothing more
""",
re.VERBOSE,
)
class _ImportedObject:
#: module containing the object to document
@@ -933,160 +921,6 @@ def _load_object_by_name(
return props
def _parse_name(
*,
name: str,
objtype: _AutodocObjType,
current_document: _CurrentDocument,
ref_context: Mapping[str, str | None],
) -> tuple[str, tuple[str, ...], str | None, str | None] | None:
"""Parse *name* into module name, path, arguments, and return annotation."""
# 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()
if args is not None:
args = f'({args})'
# 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=ref_context.get('py:module'),
ref_context_py_class=ref_context.get('py:class', ''), # type: ignore[arg-type]
)
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: _AutodocObjType,
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', 'type'}:
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
_objects_with_type_comment_annotations: WeakSet[Any] = WeakSet()
"""Cache of objects with annotations updated from type comments."""
+1 -61
View File
@@ -7,7 +7,6 @@ source file translated by test_build.
from __future__ import annotations
import itertools
import logging
import pathlib
import sys
from typing import TYPE_CHECKING
@@ -17,7 +16,6 @@ import pytest
from docutils.statemachine import StringList
from sphinx import addnodes
from sphinx.environment import _CurrentDocument
from sphinx.ext.autodoc._directive_options import (
_AutoDocumenterOptions,
inherited_members_option,
@@ -28,7 +26,7 @@ from sphinx.ext.autodoc._generate import _generate_directives
from sphinx.ext.autodoc._property_types import _ItemProperties
from sphinx.ext.autodoc._sentinels import ALL
from sphinx.ext.autodoc.directive import _AutodocAttrGetter
from sphinx.ext.autodoc.importer import _load_object_by_name, _parse_name
from sphinx.ext.autodoc.importer import _load_object_by_name
from sphinx.util.inspect import safe_getattr
from tests.test_ext_autodoc.autodoc_util import do_autodoc
@@ -48,64 +46,6 @@ if TYPE_CHECKING:
processed_signatures: list[tuple[str, str]] = []
def test_parse_name(caplog: pytest.LogCaptureFixture) -> None:
# work around sphinx.util.logging.setup()
logger = logging.getLogger('sphinx')
logger.handlers[:] = [caplog.handler]
current_document = _CurrentDocument()
ref_context: dict[str, Any] = {}
def parse(objtype, name):
parsed = _parse_name(
name=name,
objtype=objtype,
current_document=current_document,
ref_context=ref_context,
)
if parsed is None:
return None
module_name, parts, args, retann = parsed
return module_name, list(parts), args, retann
# for modules
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 given for automodule' in caplog.messages[0]
# for functions/classes
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 = ''
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
ref_context['py:module'] = 'sphinx.testing.util'
parsed = parse('method', 'SphinxTestApp.cleanup')
assert parsed == ('sphinx.testing.util', ['SphinxTestApp', 'cleanup'], None, None)
ref_context['py:module'] = 'sphinx.testing.util'
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)
def get_docstring_lines(obj_type, obj, *, config):
parent = object # dummy
props = _ItemProperties(
@@ -0,0 +1,99 @@
"""Test the autodoc extension. This mainly tests name resolution & parsing."""
from __future__ import annotations
import logging
from sphinx.environment import _CurrentDocument
from sphinx.ext.autodoc._names import _parse_name
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Mapping
from typing import Any
import pytest
from sphinx.ext.autodoc._property_types import _AutodocObjType
def parse_name(
objtype: _AutodocObjType,
name: str,
*,
current_document: _CurrentDocument | None = None,
ref_context: Mapping[str, str | None] | None = None,
) -> tuple[str, list[str], str | None, str | None] | None:
if current_document is None:
current_document = _CurrentDocument()
if ref_context is None:
ref_context = {}
parsed = _parse_name(
name=name,
objtype=objtype,
current_document=current_document,
ref_context=ref_context,
)
if parsed is None:
return None
module_name, parts, args, retann = parsed
return module_name, list(parts), args, retann
def test_parse_module_names(caplog: pytest.LogCaptureFixture) -> None:
# work around sphinx.util.logging.setup()
logger = logging.getLogger('sphinx')
logger.handlers[:] = [caplog.handler]
parsed = parse_name('module', 'test_ext_autodoc')
assert parsed == ('test_ext_autodoc', [], None, None)
parsed = parse_name('module', 'test.test_ext_autodoc')
assert parsed == ('test.test_ext_autodoc', [], None, None)
parsed = parse_name('module', 'test(arg)')
assert parsed is None
assert 'signature arguments given for automodule' in caplog.messages[0]
def test_parse_function_names() -> None:
parsed = parse_name('function', 'test_ext_autodoc.raises')
assert parsed == ('test_ext_autodoc', ['raises'], None, None)
parsed = parse_name('function', 'test_ext_autodoc.raises(exc) -> None')
assert parsed == ('test_ext_autodoc', ['raises'], '(exc)', 'None')
def test_parse_function_names_current_document() -> None:
current_document = _CurrentDocument()
current_document.autodoc_module = 'test_ext_autodoc'
parsed = parse_name('function', 'raises', current_document=current_document)
assert parsed == ('test_ext_autodoc', ['raises'], None, None)
def test_parse_function_names_ref_context() -> None:
ref_context: dict[str, Any] = {'py:module': 'test_ext_autodoc'}
parsed = parse_name('function', 'raises', ref_context=ref_context)
assert parsed == ('test_ext_autodoc', ['raises'], None, None)
parsed = parse_name('class', 'Base', ref_context=ref_context)
assert parsed == ('test_ext_autodoc', ['Base'], None, None)
def test_parse_name_members() -> None:
# for members
ref_context: dict[str, Any] = {'py:module': 'sphinx.testing.util'}
parsed = parse_name('method', 'SphinxTestApp.cleanup', ref_context=ref_context)
assert parsed == ('sphinx.testing.util', ['SphinxTestApp', 'cleanup'], None, None)
current_document = _CurrentDocument()
current_document.autodoc_class = 'SphinxTestApp'
ref_context['py:class'] = 'Foo'
parsed = parse_name(
'method', 'cleanup', current_document=current_document, ref_context=ref_context
)
assert parsed == ('sphinx.testing.util', ['SphinxTestApp', 'cleanup'], None, None)
parsed = parse_name(
'method',
'SphinxTestApp.cleanup',
current_document=current_document,
ref_context=ref_context,
)
assert parsed == ('sphinx.testing.util', ['SphinxTestApp', 'cleanup'], None, None)