Skip to content
Merged
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
73 changes: 73 additions & 0 deletions docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
from sphinx import addnodes
from sphinx.errors import NoUri
from sphinx.ext.autodoc.preserve_defaults import DefaultValue, update_default_value
from sphinx.ext.napoleon.docstring import GoogleDocstring
from sphinx.locale import _ as translate
from sphinx.transforms.post_transforms import SphinxPostTransform
from sphinx.util import logging as sphinx_logging
from sphinx.util.inspect import safe_getattr, stringify_signature
Expand Down Expand Up @@ -572,6 +574,74 @@ def resolve_builtins_exactly(app: 'Sphinx', doctree: 'document') -> None: # pyl
del attributes['refspecific']


def warning_reference(name: str) -> str:
"""Write a ``Warns:`` entry's warning class as the link ``Raises:`` would make.

Napoleon turns a ``Raises:`` entry into a ``:raises:`` field, whose Python
domain field type links the exception. A ``Warns:`` entry goes into a plain
field instead, holding the class name in bold and linking nothing
(:issue:`2094`). The ``exc`` role written here searches the way that field
type does: a leading dot asks for the search by suffix, from the current
module and class outwards, so a bare ``RegistryWarning`` finds
:class:`~pcapkit.utilities.warnings.RegistryWarning` from any module. A
``~`` prefix still shortens the title to the last component. A builtin name
gets no dot, which is what :func:`resolve_builtins_exactly` leaves of the
field type's search, so ``UserWarning`` is linked to the Python docs.

Several names separated by commas get a link each. A name holding a
backtick is markup already, a role or a literal, and is kept as written.

Args:
name: The entry's name, as napoleon read it.

Returns:
The reStructuredText linking the warning class or classes.

"""
if '`' in name:
return name
references = []
for part in name.split(','):
part = part.strip()
if not part:
continue
title = '~' if part.startswith('~') else ''
target = part.lstrip('~')
if target not in BUILTIN_NAMES and not target.startswith('.'):
target = f'.{target}'
references.append(f':exc:`{title}{target}`')
return ', '.join(references)


def parse_warns_section(self: 'GoogleDocstring', section: str) -> 'List[str]': # pylint: disable=unused-argument
"""Napoleon's ``Warns:`` section, with each warning class linked (:issue:`2094`).

:func:`setup` installs this in place of napoleon's own parser, which reads
the same fields and formats them the same way but leaves the name in bold.
It is replaced on ``GoogleDocstring``, which ``NumpyDocstring`` inherits it
from, so a NumPy ``Warns`` section is linked too. An entry that names a
type as well, the ``name (type): description`` form, is not a warning
class on its own and is left as napoleon formats it.

The two napoleon methods this calls are private, used against the Sphinx
release ``docs/requirements.txt`` pins, as napoleon's own parser does.

Args:
self: The docstring being parsed.
section: The section's header.

Returns:
The section as a ``Warns`` field, napoleon's layout unchanged.

"""
fields = []
for name, type_, desc in self._consume_fields(): # pylint: disable=protected-access
if name and not type_:
name, type_ = '', warning_reference(name)
fields.append((name, type_, desc))
return self._format_fields(translate('Warns'), fields) # pylint: disable=protected-access


#: Enum member docstrings as autodoc read them, held from
#: :func:`save_enum_member_docstring` until :func:`restore_enum_member_docstring`.
_ENUM_MEMBER_DOCSTRINGS = {} # type: Dict[str, List[str]]
Expand Down Expand Up @@ -1227,6 +1297,9 @@ def setup(app: 'Sphinx') -> None:
# connects at the default 500.
app.connect('autodoc-process-docstring', save_enum_member_docstring, priority=400)
app.connect('autodoc-process-docstring', restore_enum_member_docstring, priority=600)
# NB: napoleon looks its section parsers up afresh for every docstring it
# parses, so replacing one on the class reaches every docstring of the build.
GoogleDocstring._parse_warns_section = parse_warns_section # pylint: disable=protected-access
# NB: ``aenum``'s metaclass derives from the standard library's, and autodoc
# picks a getter by :func:`isinstance` on the class it reads, so one covers both.
app.add_autodoc_attrgetter(enum.EnumMeta, read_own_enum_new)
221 changes: 221 additions & 0 deletions tests/project/test_docs_warns_xrefs_2094_unit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
# -*- coding: utf-8 -*-
"""Pins the cross-references in the docs build's ``Warns:`` sections.

GitHub issue #2094: napoleon turns a ``Raises:`` entry into a ``:raises:``
field, which the Python domain links to the exception, but writes a ``Warns:``
entry into a plain field with the class name in bold, linking nothing -- and a
``~`` prefix was printed as it stands. ``parse_warns_section`` in
:file:`docs/source/conf.py`, which ``setup`` installs over napoleon's own parser,
writes each warning class as an ``exc`` role that searches the way the
``:raises:`` field does.

The tests run napoleon itself with the build's settings, and resolve the role
and the ``:raises:`` field's node through the Python domain side by side. They
need Sphinx and the pinned ``sphinx-autodoc-typehints``, and skip without them,
but run no build.

"""

from __future__ import annotations

import inspect
import re
import types
import unittest
from typing import TYPE_CHECKING
from unittest import mock

from tests.project.test_docs_ambiguous_xrefs_1775_unit import (HAS_SPHINX, REF_CONTEXT, SPHINX_SKIP_REASON,
connections, load_conf)

if TYPE_CHECKING:
from typing import Any

#: Test tier (#1958): drives a hook of docs/source/conf.py, which the docs build runs
#: on one interpreter.
__tier__ = 'LATEST'

#: Where the warning classes are documented, and one of them.
WARNINGS = 'pcapkit.utilities.warnings'
REGISTRY_WARNING = f'{WARNINGS}.RegistryWarning'

#: A Google ``Warns:`` section in each form an entry's name takes.
GOOGLE = '''Do something.

Warns:
RegistryWarning: If bare.
pcapkit.utilities.warnings.EngineWarning: If dotted, over
two lines.
~pcapkit.utilities.warnings.FormatWarning: If shortened.
AttributeWarning, UserWarning: If several, one a builtin.

'''

#: What the section above becomes.
GOOGLE_WARNS = [
':Warns: * :exc:`.RegistryWarning` -- If bare.',
' * :exc:`.pcapkit.utilities.warnings.EngineWarning` -- If dotted, over',
' two lines.',
' * :exc:`~.pcapkit.utilities.warnings.FormatWarning` -- If shortened.',
' * :exc:`.AttributeWarning`, :exc:`UserWarning` -- If several, one a builtin.',
]

#: An ``exc`` role, as the hook writes it.
EXC_ROLE = re.compile(r':exc:`([^`]+)`')


@unittest.skipUnless(HAS_SPHINX, SPHINX_SKIP_REASON)
class WarnsSectionTests(unittest.TestCase):
"""Napoleon links the warning classes of a ``Warns:`` section."""

conf = None # type: Any

@classmethod
def setUpClass(cls) -> 'None':
from sphinx.ext.napoleon.docstring import GoogleDocstring # pylint: disable=import-outside-toplevel

# ``setup`` replaces napoleon's parser on the class; put napoleon's back after.
patcher = mock.patch.object(GoogleDocstring, '_parse_warns_section',
GoogleDocstring.__dict__['_parse_warns_section'])
patcher.start()
cls.addClassCleanup(patcher.stop)
cls.conf = load_conf()
cls.wired = connections(cls.conf)

def config(self) -> 'Any':
"""Napoleon's settings, as the build reads them from ``conf.py``."""
from sphinx.ext.napoleon import Config # pylint: disable=import-outside-toplevel
return Config(**{key: getattr(self.conf, key) for key in dir(self.conf)
if key.startswith('napoleon_')})

def google(self, text: 'str', what: 'str' = 'function') -> 'list[str]':
"""``text`` as napoleon's Google parser writes it."""
from sphinx.ext.napoleon.docstring import GoogleDocstring # pylint: disable=import-outside-toplevel
return GoogleDocstring(text, self.config(), what=what).lines()

def numpy(self, text: 'str') -> 'list[str]':
"""``text`` as napoleon's NumPy parser writes it."""
from sphinx.ext.napoleon.docstring import NumpyDocstring # pylint: disable=import-outside-toplevel
return NumpyDocstring(text, self.config(), what='function').lines()

def test_setup_installs_the_parser(self) -> 'None':
"""Without it, napoleon's own parser writes the name in bold and nothing links it."""
from sphinx.ext.napoleon.docstring import GoogleDocstring # pylint: disable=import-outside-toplevel
self.assertIs(GoogleDocstring.__dict__['_parse_warns_section'],
getattr(self.conf, 'parse_warns_section', None))

def test_google_section_links_every_form_of_name(self) -> 'None':
"""Bare, dotted, ``~``-prefixed and several names, each as an ``exc`` role."""
lines = self.google(GOOGLE)
start = next(index for index, line in enumerate(lines) if line.startswith(':Warns:'))
self.assertEqual(lines[start:start + len(GOOGLE_WARNS)], GOOGLE_WARNS)

def test_numpy_section_is_linked_too(self) -> 'None':
"""``NumpyDocstring`` inherits the parser, so its ``Warns`` section is linked."""
lines = self.numpy('Do something.\n\nWarns\n-----\nRegistryWarning\n If bare.\n')
self.assertIn(':Warns: :exc:`.RegistryWarning` -- If bare.', lines)

def test_name_with_a_type_is_left_alone(self) -> 'None':
"""The ``name (type): description`` form names no warning class by itself."""
lines = self.google('Do something.\n\nWarns:\n count (int): If typed.\n')
self.assertIn(':Warns: **count** (:py:class:`int`) -- If typed.', lines)

def test_a_role_is_kept_as_written(self) -> 'None':
"""An entry already written as a role is not wrapped in a second one."""
self.assertEqual(self.conf.warning_reference(':class:`~pcapkit.utilities.warnings.InfoWarning`'),
':class:`~pcapkit.utilities.warnings.InfoWarning`')

def test_real_docstring(self) -> 'None':
"""``register_protocol``'s ``Warns:`` entry used to be ``**pcapkit.utilities.warnings.RegistryWarning**``."""
from pcapkit.foundation.registry.protocols import register_protocol # pylint: disable=import-outside-toplevel
lines = self.google(inspect.getdoc(register_protocol))
warns = [line for line in lines if line.startswith(':Warns:')]
self.assertEqual(len(warns), 1)
self.assertTrue(warns[0].startswith(f':Warns: :exc:`.{REGISTRY_WARNING}` -- '), warns[0])
self.assertNotIn(f'**{REGISTRY_WARNING}**', '\n'.join(lines))


@unittest.skipUnless(HAS_SPHINX, SPHINX_SKIP_REASON)
class RaisesParityTests(unittest.TestCase):
"""A ``Warns:`` role resolves to what a ``:raises:`` field of the same name does."""

conf = None # type: Any

@classmethod
def setUpClass(cls) -> 'None':
cls.conf = load_conf()

def setUp(self) -> 'None':
# pylint: disable=import-outside-toplevel
from sphinx.domains.python import ObjectEntry, PythonDomain
from sphinx.util.docutils import new_document

self.env = types.SimpleNamespace(
ref_context=dict(REF_CONTEXT), domaindata={},
config=types.SimpleNamespace(python_use_unqualified_type_names=False),
current_document=types.SimpleNamespace(docname='index', default_role=None),
)
self.domain = PythonDomain(self.env)
self.env.get_domain = lambda name: self.domain
self.domain.objects[REGISTRY_WARNING] = ObjectEntry('warnings', REGISTRY_WARNING, 'exception', False)
self.document = new_document('<test>')
self.document.settings.env = self.env

def raises_field(self, name: 'str') -> 'Any':
"""The node a ``:raises name:`` field becomes, by the Python domain's field type."""
from sphinx.domains.python._object import PyGroupedField # pylint: disable=import-outside-toplevel
field = PyGroupedField('exceptions', label='Raises', rolename='exc',
names=('raises',), can_collapse=True)
return field.make_xref('exc', 'py', name, env=self.env)

def warns_role(self, name: 'str') -> 'Any':
"""The node the hook's role for ``name`` becomes."""
from sphinx.domains.python import PyXRefRole # pylint: disable=import-outside-toplevel
text = EXC_ROLE.fullmatch(self.conf.warning_reference(name)).group(1)
inliner = types.SimpleNamespace(document=self.document, reporter=types.SimpleNamespace(
get_source_and_line=lambda lineno=None: ('<test>', lineno)))
result, messages = PyXRefRole()('py:exc', f':py:exc:`{text}`', text, 1, inliner)
self.assertEqual(messages, [])
return result[0]

def resolve(self, node: 'Any') -> 'Any':
"""What the Python domain links ``node`` to, after ``resolve_builtins_exactly``."""
from docutils import nodes # pylint: disable=import-outside-toplevel
self.document += nodes.paragraph('', '', node)
self.conf.resolve_builtins_exactly(None, self.document)
builder = types.SimpleNamespace(name='html', get_relative_uri=lambda source, target: '')
with mock.patch('sphinx.domains.python.logger'):
return self.domain.resolve_xref(self.env, 'index', builder, node['reftype'],
node['reftarget'], node, nodes.literal('', 'x'))

def test_names_resolve_alike(self) -> 'None':
"""Bare, dotted and ``~``-prefixed, from a module other than the class's own."""
for name in ('RegistryWarning', REGISTRY_WARNING, f'~{REGISTRY_WARNING}'):
with self.subTest(name=name):
raises = self.resolve(self.raises_field(name))
warns = self.resolve(self.warns_role(name))
self.assertIsNotNone(raises)
self.assertIsNotNone(warns)
self.assertEqual(warns['reftitle'], raises['reftitle'])
self.assertEqual(warns['reftitle'], REGISTRY_WARNING)

def test_titles_read_alike(self) -> 'None':
"""``~`` shortens the title to the class name, and nothing else does."""
for name, title in (('RegistryWarning', 'RegistryWarning'),
(REGISTRY_WARNING, REGISTRY_WARNING),
(f'~{REGISTRY_WARNING}', 'RegistryWarning')):
with self.subTest(name=name):
self.assertEqual(self.raises_field(name).astext(), title)
self.assertEqual(self.warns_role(name).astext(), title)

def test_builtin_is_left_to_intersphinx(self) -> 'None':
"""A builtin warning is searched for exactly, so the Python docs link it."""
from sphinx.domains.python import ObjectEntry # pylint: disable=import-outside-toplevel
lookalike = 'pcapkit.const.misc.UserWarning'
self.domain.objects[lookalike] = ObjectEntry('misc', lookalike, 'attribute', False)
self.assertIsNone(self.resolve(self.raises_field('UserWarning')))
self.assertIsNone(self.resolve(self.warns_role('UserWarning')))


if __name__ == '__main__':
unittest.main()
Loading