Skip to content

Commit 0eb3d68

Browse files
authored
docs: cross-reference the warning classes in Warns sections (#2094) (#2096)
1 parent 328ffeb commit 0eb3d68

2 files changed

Lines changed: 294 additions & 0 deletions

File tree

‎docs/source/conf.py‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
from sphinx import addnodes
2222
from sphinx.errors import NoUri
2323
from sphinx.ext.autodoc.preserve_defaults import DefaultValue, update_default_value
24+
from sphinx.ext.napoleon.docstring import GoogleDocstring
25+
from sphinx.locale import _ as translate
2426
from sphinx.transforms.post_transforms import SphinxPostTransform
2527
from sphinx.util import logging as sphinx_logging
2628
from sphinx.util.inspect import safe_getattr, stringify_signature
@@ -572,6 +574,74 @@ def resolve_builtins_exactly(app: 'Sphinx', doctree: 'document') -> None: # pyl
572574
del attributes['refspecific']
573575

574576

577+
def warning_reference(name: str) -> str:
578+
"""Write a ``Warns:`` entry's warning class as the link ``Raises:`` would make.
579+
580+
Napoleon turns a ``Raises:`` entry into a ``:raises:`` field, whose Python
581+
domain field type links the exception. A ``Warns:`` entry goes into a plain
582+
field instead, holding the class name in bold and linking nothing
583+
(:issue:`2094`). The ``exc`` role written here searches the way that field
584+
type does: a leading dot asks for the search by suffix, from the current
585+
module and class outwards, so a bare ``RegistryWarning`` finds
586+
:class:`~pcapkit.utilities.warnings.RegistryWarning` from any module. A
587+
``~`` prefix still shortens the title to the last component. A builtin name
588+
gets no dot, which is what :func:`resolve_builtins_exactly` leaves of the
589+
field type's search, so ``UserWarning`` is linked to the Python docs.
590+
591+
Several names separated by commas get a link each. A name holding a
592+
backtick is markup already, a role or a literal, and is kept as written.
593+
594+
Args:
595+
name: The entry's name, as napoleon read it.
596+
597+
Returns:
598+
The reStructuredText linking the warning class or classes.
599+
600+
"""
601+
if '`' in name:
602+
return name
603+
references = []
604+
for part in name.split(','):
605+
part = part.strip()
606+
if not part:
607+
continue
608+
title = '~' if part.startswith('~') else ''
609+
target = part.lstrip('~')
610+
if target not in BUILTIN_NAMES and not target.startswith('.'):
611+
target = f'.{target}'
612+
references.append(f':exc:`{title}{target}`')
613+
return ', '.join(references)
614+
615+
616+
def parse_warns_section(self: 'GoogleDocstring', section: str) -> 'List[str]': # pylint: disable=unused-argument
617+
"""Napoleon's ``Warns:`` section, with each warning class linked (:issue:`2094`).
618+
619+
:func:`setup` installs this in place of napoleon's own parser, which reads
620+
the same fields and formats them the same way but leaves the name in bold.
621+
It is replaced on ``GoogleDocstring``, which ``NumpyDocstring`` inherits it
622+
from, so a NumPy ``Warns`` section is linked too. An entry that names a
623+
type as well, the ``name (type): description`` form, is not a warning
624+
class on its own and is left as napoleon formats it.
625+
626+
The two napoleon methods this calls are private, used against the Sphinx
627+
release ``docs/requirements.txt`` pins, as napoleon's own parser does.
628+
629+
Args:
630+
self: The docstring being parsed.
631+
section: The section's header.
632+
633+
Returns:
634+
The section as a ``Warns`` field, napoleon's layout unchanged.
635+
636+
"""
637+
fields = []
638+
for name, type_, desc in self._consume_fields(): # pylint: disable=protected-access
639+
if name and not type_:
640+
name, type_ = '', warning_reference(name)
641+
fields.append((name, type_, desc))
642+
return self._format_fields(translate('Warns'), fields) # pylint: disable=protected-access
643+
644+
575645
#: Enum member docstrings as autodoc read them, held from
576646
#: :func:`save_enum_member_docstring` until :func:`restore_enum_member_docstring`.
577647
_ENUM_MEMBER_DOCSTRINGS = {} # type: Dict[str, List[str]]
@@ -1227,6 +1297,9 @@ def setup(app: 'Sphinx') -> None:
12271297
# connects at the default 500.
12281298
app.connect('autodoc-process-docstring', save_enum_member_docstring, priority=400)
12291299
app.connect('autodoc-process-docstring', restore_enum_member_docstring, priority=600)
1300+
# NB: napoleon looks its section parsers up afresh for every docstring it
1301+
# parses, so replacing one on the class reaches every docstring of the build.
1302+
GoogleDocstring._parse_warns_section = parse_warns_section # pylint: disable=protected-access
12301303
# NB: ``aenum``'s metaclass derives from the standard library's, and autodoc
12311304
# picks a getter by :func:`isinstance` on the class it reads, so one covers both.
12321305
app.add_autodoc_attrgetter(enum.EnumMeta, read_own_enum_new)
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
# -*- coding: utf-8 -*-
2+
"""Pins the cross-references in the docs build's ``Warns:`` sections.
3+
4+
GitHub issue #2094: napoleon turns a ``Raises:`` entry into a ``:raises:``
5+
field, which the Python domain links to the exception, but writes a ``Warns:``
6+
entry into a plain field with the class name in bold, linking nothing -- and a
7+
``~`` prefix was printed as it stands. ``parse_warns_section`` in
8+
:file:`docs/source/conf.py`, which ``setup`` installs over napoleon's own parser,
9+
writes each warning class as an ``exc`` role that searches the way the
10+
``:raises:`` field does.
11+
12+
The tests run napoleon itself with the build's settings, and resolve the role
13+
and the ``:raises:`` field's node through the Python domain side by side. They
14+
need Sphinx and the pinned ``sphinx-autodoc-typehints``, and skip without them,
15+
but run no build.
16+
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import inspect
22+
import re
23+
import types
24+
import unittest
25+
from typing import TYPE_CHECKING
26+
from unittest import mock
27+
28+
from tests.project.test_docs_ambiguous_xrefs_1775_unit import (HAS_SPHINX, REF_CONTEXT, SPHINX_SKIP_REASON,
29+
connections, load_conf)
30+
31+
if TYPE_CHECKING:
32+
from typing import Any
33+
34+
#: Test tier (#1958): drives a hook of docs/source/conf.py, which the docs build runs
35+
#: on one interpreter.
36+
__tier__ = 'LATEST'
37+
38+
#: Where the warning classes are documented, and one of them.
39+
WARNINGS = 'pcapkit.utilities.warnings'
40+
REGISTRY_WARNING = f'{WARNINGS}.RegistryWarning'
41+
42+
#: A Google ``Warns:`` section in each form an entry's name takes.
43+
GOOGLE = '''Do something.
44+
45+
Warns:
46+
RegistryWarning: If bare.
47+
pcapkit.utilities.warnings.EngineWarning: If dotted, over
48+
two lines.
49+
~pcapkit.utilities.warnings.FormatWarning: If shortened.
50+
AttributeWarning, UserWarning: If several, one a builtin.
51+
52+
'''
53+
54+
#: What the section above becomes.
55+
GOOGLE_WARNS = [
56+
':Warns: * :exc:`.RegistryWarning` -- If bare.',
57+
' * :exc:`.pcapkit.utilities.warnings.EngineWarning` -- If dotted, over',
58+
' two lines.',
59+
' * :exc:`~.pcapkit.utilities.warnings.FormatWarning` -- If shortened.',
60+
' * :exc:`.AttributeWarning`, :exc:`UserWarning` -- If several, one a builtin.',
61+
]
62+
63+
#: An ``exc`` role, as the hook writes it.
64+
EXC_ROLE = re.compile(r':exc:`([^`]+)`')
65+
66+
67+
@unittest.skipUnless(HAS_SPHINX, SPHINX_SKIP_REASON)
68+
class WarnsSectionTests(unittest.TestCase):
69+
"""Napoleon links the warning classes of a ``Warns:`` section."""
70+
71+
conf = None # type: Any
72+
73+
@classmethod
74+
def setUpClass(cls) -> 'None':
75+
from sphinx.ext.napoleon.docstring import GoogleDocstring # pylint: disable=import-outside-toplevel
76+
77+
# ``setup`` replaces napoleon's parser on the class; put napoleon's back after.
78+
patcher = mock.patch.object(GoogleDocstring, '_parse_warns_section',
79+
GoogleDocstring.__dict__['_parse_warns_section'])
80+
patcher.start()
81+
cls.addClassCleanup(patcher.stop)
82+
cls.conf = load_conf()
83+
cls.wired = connections(cls.conf)
84+
85+
def config(self) -> 'Any':
86+
"""Napoleon's settings, as the build reads them from ``conf.py``."""
87+
from sphinx.ext.napoleon import Config # pylint: disable=import-outside-toplevel
88+
return Config(**{key: getattr(self.conf, key) for key in dir(self.conf)
89+
if key.startswith('napoleon_')})
90+
91+
def google(self, text: 'str', what: 'str' = 'function') -> 'list[str]':
92+
"""``text`` as napoleon's Google parser writes it."""
93+
from sphinx.ext.napoleon.docstring import GoogleDocstring # pylint: disable=import-outside-toplevel
94+
return GoogleDocstring(text, self.config(), what=what).lines()
95+
96+
def numpy(self, text: 'str') -> 'list[str]':
97+
"""``text`` as napoleon's NumPy parser writes it."""
98+
from sphinx.ext.napoleon.docstring import NumpyDocstring # pylint: disable=import-outside-toplevel
99+
return NumpyDocstring(text, self.config(), what='function').lines()
100+
101+
def test_setup_installs_the_parser(self) -> 'None':
102+
"""Without it, napoleon's own parser writes the name in bold and nothing links it."""
103+
from sphinx.ext.napoleon.docstring import GoogleDocstring # pylint: disable=import-outside-toplevel
104+
self.assertIs(GoogleDocstring.__dict__['_parse_warns_section'],
105+
getattr(self.conf, 'parse_warns_section', None))
106+
107+
def test_google_section_links_every_form_of_name(self) -> 'None':
108+
"""Bare, dotted, ``~``-prefixed and several names, each as an ``exc`` role."""
109+
lines = self.google(GOOGLE)
110+
start = next(index for index, line in enumerate(lines) if line.startswith(':Warns:'))
111+
self.assertEqual(lines[start:start + len(GOOGLE_WARNS)], GOOGLE_WARNS)
112+
113+
def test_numpy_section_is_linked_too(self) -> 'None':
114+
"""``NumpyDocstring`` inherits the parser, so its ``Warns`` section is linked."""
115+
lines = self.numpy('Do something.\n\nWarns\n-----\nRegistryWarning\n If bare.\n')
116+
self.assertIn(':Warns: :exc:`.RegistryWarning` -- If bare.', lines)
117+
118+
def test_name_with_a_type_is_left_alone(self) -> 'None':
119+
"""The ``name (type): description`` form names no warning class by itself."""
120+
lines = self.google('Do something.\n\nWarns:\n count (int): If typed.\n')
121+
self.assertIn(':Warns: **count** (:py:class:`int`) -- If typed.', lines)
122+
123+
def test_a_role_is_kept_as_written(self) -> 'None':
124+
"""An entry already written as a role is not wrapped in a second one."""
125+
self.assertEqual(self.conf.warning_reference(':class:`~pcapkit.utilities.warnings.InfoWarning`'),
126+
':class:`~pcapkit.utilities.warnings.InfoWarning`')
127+
128+
def test_real_docstring(self) -> 'None':
129+
"""``register_protocol``'s ``Warns:`` entry used to be ``**pcapkit.utilities.warnings.RegistryWarning**``."""
130+
from pcapkit.foundation.registry.protocols import register_protocol # pylint: disable=import-outside-toplevel
131+
lines = self.google(inspect.getdoc(register_protocol))
132+
warns = [line for line in lines if line.startswith(':Warns:')]
133+
self.assertEqual(len(warns), 1)
134+
self.assertTrue(warns[0].startswith(f':Warns: :exc:`.{REGISTRY_WARNING}` -- '), warns[0])
135+
self.assertNotIn(f'**{REGISTRY_WARNING}**', '\n'.join(lines))
136+
137+
138+
@unittest.skipUnless(HAS_SPHINX, SPHINX_SKIP_REASON)
139+
class RaisesParityTests(unittest.TestCase):
140+
"""A ``Warns:`` role resolves to what a ``:raises:`` field of the same name does."""
141+
142+
conf = None # type: Any
143+
144+
@classmethod
145+
def setUpClass(cls) -> 'None':
146+
cls.conf = load_conf()
147+
148+
def setUp(self) -> 'None':
149+
# pylint: disable=import-outside-toplevel
150+
from sphinx.domains.python import ObjectEntry, PythonDomain
151+
from sphinx.util.docutils import new_document
152+
153+
self.env = types.SimpleNamespace(
154+
ref_context=dict(REF_CONTEXT), domaindata={},
155+
config=types.SimpleNamespace(python_use_unqualified_type_names=False),
156+
current_document=types.SimpleNamespace(docname='index', default_role=None),
157+
)
158+
self.domain = PythonDomain(self.env)
159+
self.env.get_domain = lambda name: self.domain
160+
self.domain.objects[REGISTRY_WARNING] = ObjectEntry('warnings', REGISTRY_WARNING, 'exception', False)
161+
self.document = new_document('<test>')
162+
self.document.settings.env = self.env
163+
164+
def raises_field(self, name: 'str') -> 'Any':
165+
"""The node a ``:raises name:`` field becomes, by the Python domain's field type."""
166+
from sphinx.domains.python._object import PyGroupedField # pylint: disable=import-outside-toplevel
167+
field = PyGroupedField('exceptions', label='Raises', rolename='exc',
168+
names=('raises',), can_collapse=True)
169+
return field.make_xref('exc', 'py', name, env=self.env)
170+
171+
def warns_role(self, name: 'str') -> 'Any':
172+
"""The node the hook's role for ``name`` becomes."""
173+
from sphinx.domains.python import PyXRefRole # pylint: disable=import-outside-toplevel
174+
text = EXC_ROLE.fullmatch(self.conf.warning_reference(name)).group(1)
175+
inliner = types.SimpleNamespace(document=self.document, reporter=types.SimpleNamespace(
176+
get_source_and_line=lambda lineno=None: ('<test>', lineno)))
177+
result, messages = PyXRefRole()('py:exc', f':py:exc:`{text}`', text, 1, inliner)
178+
self.assertEqual(messages, [])
179+
return result[0]
180+
181+
def resolve(self, node: 'Any') -> 'Any':
182+
"""What the Python domain links ``node`` to, after ``resolve_builtins_exactly``."""
183+
from docutils import nodes # pylint: disable=import-outside-toplevel
184+
self.document += nodes.paragraph('', '', node)
185+
self.conf.resolve_builtins_exactly(None, self.document)
186+
builder = types.SimpleNamespace(name='html', get_relative_uri=lambda source, target: '')
187+
with mock.patch('sphinx.domains.python.logger'):
188+
return self.domain.resolve_xref(self.env, 'index', builder, node['reftype'],
189+
node['reftarget'], node, nodes.literal('', 'x'))
190+
191+
def test_names_resolve_alike(self) -> 'None':
192+
"""Bare, dotted and ``~``-prefixed, from a module other than the class's own."""
193+
for name in ('RegistryWarning', REGISTRY_WARNING, f'~{REGISTRY_WARNING}'):
194+
with self.subTest(name=name):
195+
raises = self.resolve(self.raises_field(name))
196+
warns = self.resolve(self.warns_role(name))
197+
self.assertIsNotNone(raises)
198+
self.assertIsNotNone(warns)
199+
self.assertEqual(warns['reftitle'], raises['reftitle'])
200+
self.assertEqual(warns['reftitle'], REGISTRY_WARNING)
201+
202+
def test_titles_read_alike(self) -> 'None':
203+
"""``~`` shortens the title to the class name, and nothing else does."""
204+
for name, title in (('RegistryWarning', 'RegistryWarning'),
205+
(REGISTRY_WARNING, REGISTRY_WARNING),
206+
(f'~{REGISTRY_WARNING}', 'RegistryWarning')):
207+
with self.subTest(name=name):
208+
self.assertEqual(self.raises_field(name).astext(), title)
209+
self.assertEqual(self.warns_role(name).astext(), title)
210+
211+
def test_builtin_is_left_to_intersphinx(self) -> 'None':
212+
"""A builtin warning is searched for exactly, so the Python docs link it."""
213+
from sphinx.domains.python import ObjectEntry # pylint: disable=import-outside-toplevel
214+
lookalike = 'pcapkit.const.misc.UserWarning'
215+
self.domain.objects[lookalike] = ObjectEntry('misc', lookalike, 'attribute', False)
216+
self.assertIsNone(self.resolve(self.raises_field('UserWarning')))
217+
self.assertIsNone(self.resolve(self.warns_role('UserWarning')))
218+
219+
220+
if __name__ == '__main__':
221+
unittest.main()

0 commit comments

Comments
 (0)