Skip to content

Commit ec97be1

Browse files
committed
docs(conf): fix class signatures, defaults, aenum names, table ids and viewcode's index (#1858, #1859, #1860, #1861, #1862, #1863)
- Name aenum classes aenum.X through a typehints_formatter, so a union with the stdlib namesake no longer reads IntEnum | IntEnum (#1858). - Turn on autodoc_preserve_defaults, and name a class that a default reaches through a private import alias by its own name (#1859). - Give a class whose __init__ a pass-through or deeper __new__ hides from autodoc that __init__'s parameters, and have sphinx-autodoc-typehints read the class's parameter types from it too (#1860). - Take a wrapped table's ids off furo's table-wrapper container (#1861). - Give every enumeration the (*values) signature (#1862). - Keep viewcode on the module binding an alias that reports builtins (#1863). - Add a tests/project unit test per issue, gated on the pinned release, and register their furo gate in tests/_dependency_gates.py. Closes #1858 Closes #1859 Closes #1860 Closes #1861 Closes #1862 Closes #1863
1 parent 57922c0 commit ec97be1

8 files changed

Lines changed: 1132 additions & 10 deletions

‎docs/source/conf.py‎

Lines changed: 404 additions & 9 deletions
Large diffs are not rendered by default.

‎tests/_dependency_gates.py‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,9 @@
235235
# extra carries them.
236236
'sphinx': ('Sphinx',),
237237
'sphinx_autodoc_typehints': ('sphinx-autodoc-typehints',),
238+
# The theme ``HAS_FURO`` asks for, whose table wrapper the #1861 transform
239+
# test runs. Only the ``docs`` extra carries it.
240+
'furo': ('furo',),
238241
}
239242

240243
#: Flags whose condition is not a distribution, with what it is instead. A flag
@@ -625,7 +628,7 @@ class Exclusion(NamedTuple):
625628
dark={'test': ('sphinx', 'sphinx_autodoc_typehints'),
626629
'gate': ('sphinx', 'sphinx_autodoc_typehints')},
627630
reason=(
628-
'The classes it gates (#1775, #1856 and the enum-value hook\'s) load '
631+
'The classes it gates (#1775, #1856, #1858-#1863 and the enum-value hook\'s) load '
629632
'docs/source/conf.py and drive its hooks with the nodes and events Sphinx '
630633
'itself builds, so they need Sphinx and the pinned sphinx-autodoc-typehints. '
631634
'Only deploy-pages.yml installs those, from docs/requirements.txt, and that '
@@ -643,6 +646,17 @@ class Exclusion(NamedTuple):
643646
'nothing and run on every leg.'
644647
),
645648
),
649+
'HAS_FURO': Exclusion(
650+
dark={'test': ('furo',), 'gate': ('furo',)},
651+
reason=(
652+
'The class it gates (#1861) runs the table-wrapper transform of furo, the '
653+
'docs theme, ahead of the conf.py transform that undoes its duplicate ids, '
654+
'and is gated on HAS_SPHINX as well. It is declined for the reason '
655+
'HAS_SPHINX is: only deploy-pages.yml installs furo, from '
656+
'docs/requirements.txt, and no workflow installs the docs extra itself; that '
657+
'build is the end-to-end check of the same transform.'
658+
),
659+
),
646660
}
647661

648662

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# -*- coding: utf-8 -*-
2+
"""Pins the docs build's rendering of :mod:`aenum` classes in types.
3+
4+
GitHub issue #1858: ``typehints_fully_qualified`` is off and :mod:`aenum` has no
5+
inventory, so a parameter annotated ``StdlibIntEnum | AenumIntEnum`` rendered as
6+
``IntEnum | IntEnum``. ``format_aenum_class`` in :file:`docs/source/conf.py`, the
7+
build's ``typehints_formatter``, names an :mod:`aenum` class ``aenum.IntEnum``
8+
and leaves everything else to the default rendering.
9+
10+
The tests call the formatter directly. They need Sphinx and
11+
the pinned ``sphinx-autodoc-typehints``, which :file:`conf.py` imports, and skip
12+
without them, but run no build.
13+
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import enum
19+
import typing
20+
import unittest
21+
from typing import TYPE_CHECKING
22+
23+
import aenum
24+
25+
from pcapkit.const.reg.linktype import LinkType
26+
from tests.project.test_docs_ambiguous_xrefs_1775_unit import HAS_SPHINX, SPHINX_SKIP_REASON, load_conf
27+
28+
if TYPE_CHECKING:
29+
from typing import Any
30+
31+
32+
@unittest.skipUnless(HAS_SPHINX, SPHINX_SKIP_REASON)
33+
class AenumFormatterTests(unittest.TestCase):
34+
"""``format_aenum_class`` qualifies :mod:`aenum` classes and nothing else."""
35+
36+
conf = None # type: Any
37+
38+
@classmethod
39+
def setUpClass(cls) -> 'None':
40+
cls.conf = load_conf()
41+
42+
def format(self, annotation: 'Any') -> 'Any':
43+
"""The formatter's answer for ``annotation``."""
44+
return self.conf.format_aenum_class(annotation, None)
45+
46+
def test_aenum_classes_are_qualified(self) -> 'None':
47+
"""By the name the package exports, not the ``aenum._enum`` module defining them."""
48+
for cls in (aenum.IntEnum, aenum.StrEnum, aenum.Enum, aenum.IntFlag):
49+
with self.subTest(cls=cls):
50+
self.assertEqual(cls.__module__, 'aenum._enum')
51+
self.assertEqual(self.format(cls), f':py:class:`aenum.{cls.__name__}`')
52+
53+
def test_union_members_render_apart(self) -> 'None':
54+
"""``StdlibIntEnum | AenumIntEnum``, as ``dumpkit/pcap.py`` annotates ``protocol``."""
55+
answers = [self.format(arg) for arg in typing.get_args(typing.Union[enum.IntEnum, aenum.IntEnum])]
56+
self.assertEqual(answers, [None, ':py:class:`aenum.IntEnum`'])
57+
58+
def test_other_annotations_are_left_alone(self) -> 'None':
59+
"""A standard library class, a pcapkit registry built on :mod:`aenum`, and non-classes."""
60+
for annotation in (enum.IntEnum, int, LinkType, 'IntEnum', None, typing.Optional[int],
61+
list[aenum.IntEnum]):
62+
with self.subTest(annotation=annotation):
63+
self.assertIsNone(self.format(annotation))
64+
65+
def test_conf_configures_it(self) -> 'None':
66+
"""Only one formatter can be configured, and this is it."""
67+
self.assertIs(self.conf.typehints_formatter, self.conf.format_aenum_class)
68+
69+
70+
if __name__ == '__main__':
71+
unittest.main()
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
# -*- coding: utf-8 -*-
2+
"""Pins the docs build's class signatures to the constructor a call runs.
3+
4+
GitHub issue #1860: Sphinx 9.1 takes a class signature from any ``__new__`` the
5+
class defines or inherits, before ``__init__``. ``sphinx-autodoc-typehints`` 3.15
6+
takes a ``__new__`` defined beside the ``__init__``, as :func:`inspect.signature`
7+
does. :class:`~pcapkit.protocols.protocol.ProtocolBase`,
8+
:class:`~pcapkit.corekit.infoclass.Info`,
9+
:class:`~pcapkit.protocols.schema.schema.Schema` and the reassembly and
10+
flow-tracing bases each define a ``__new__`` that only passes ``*args`` and
11+
``**kwargs`` through, so their classes rendered that catch-all, and their
12+
class docstrings lost the types of the parameters they list.
13+
14+
``restore_class_signature`` in :file:`docs/source/conf.py` gives such a class its
15+
``__init__``'s parameters, and ``constructor_for_types`` has the library read the
16+
parameter types from that ``__init__``. The expected signatures are the ones the
17+
build showed before 3.15, where it had one.
18+
19+
The tests call the hooks directly and replay the signature event through Sphinx's
20+
own :class:`~sphinx.events.EventManager`. They need Sphinx and
21+
the pinned ``sphinx-autodoc-typehints`` and skip without them, but run no build.
22+
23+
"""
24+
25+
from __future__ import annotations
26+
27+
import inspect
28+
import types
29+
import unittest
30+
from typing import TYPE_CHECKING
31+
from unittest import mock
32+
33+
from tests.project.test_docs_ambiguous_xrefs_1775_unit import (HAS_SPHINX, SPHINX_SKIP_REASON, connections,
34+
load_conf)
35+
36+
if TYPE_CHECKING:
37+
from typing import Any
38+
39+
#: The event the signature hook answers.
40+
EVENT = 'autodoc-process-signature'
41+
42+
#: The catch-all the build rendered for these classes.
43+
CATCH_ALL = '(*args, **kwargs)'
44+
45+
46+
def app_for(conf: 'Any') -> 'Any':
47+
"""A stand-in application carrying the settings the hooks read."""
48+
config = types.SimpleNamespace(autodoc_preserve_defaults=conf.autodoc_preserve_defaults,
49+
strip_signature_backslash=False)
50+
return types.SimpleNamespace(config=config, pdb=False)
51+
52+
53+
def emit(conf: 'Any', app: 'Any', event: 'str', *args: 'Any', library: 'Any' = None) -> 'Any':
54+
"""What the build's handlers for ``event`` answer, in the order the build runs them.
55+
56+
Each handler is passed ``app`` and then ``args``. ``library`` stands in for a
57+
handler an extension connects at :meth:`Sphinx.connect
58+
<sphinx.application.Sphinx.connect>`'s default priority; :file:`conf.py`'s
59+
handlers follow with the priorities its ``setup`` gives them.
60+
61+
"""
62+
# pylint: disable=import-outside-toplevel
63+
from sphinx.application import Sphinx
64+
from sphinx.events import EventManager
65+
66+
events = EventManager(app)
67+
events.add(event)
68+
if library is not None:
69+
events.connect(event, library, inspect.signature(Sphinx.connect).parameters['priority'].default)
70+
for name, handler, priority in connections(conf):
71+
if name == event:
72+
events.connect(name, handler, priority)
73+
return events.emit_firstresult(event, *args)
74+
75+
76+
def resolve(name: 'str') -> 'Any':
77+
"""The object at the dotted ``name``, which may name a nested class."""
78+
import pkgutil # pylint: disable=import-outside-toplevel
79+
return pkgutil.resolve_name(name)
80+
81+
82+
#: Each class, and the signature restored for it.
83+
RESTORED = {
84+
'pcapkit.protocols.application.ftp.FTP': '(file=None, length=None, **kwargs)',
85+
'pcapkit.dumpkit.pcap.PCAPIO': '(fname, *, protocol, byteorder=sys.byteorder, nanosecond=False, **kwargs)',
86+
'pcapkit.foundation.reassembly.ipv4.IPv4': '(*, strict=True, store=True, timeout=None)',
87+
'pcapkit.foundation.traceflow.tcp.TCP': ('(fout, format, byteorder=sys.byteorder, nanosecond=False, '
88+
'bidirectional=True, analyse=False)'),
89+
'pcapkit.corekit.infoclass.Info': '(dict_=None, **kwargs)',
90+
'pcapkit.corekit.infoclass.MultiInfo': '(mapping=None)',
91+
'pcapkit.interface.misc.Stream': '(filename, packets, conversations)',
92+
'pcapkit.protocols.schema.transport.udp.UDP': ('(srcport=NO_VALUE, dstport=NO_VALUE, len=NO_VALUE, '
93+
'checksum=NO_VALUE, payload=NO_VALUE, trailer=NO_VALUE, *, '
94+
'__packet__=None, **kwargs)'),
95+
}
96+
97+
#: Classes whose signature is left to the other handlers: a class not yet
98+
#: finalised, whose ``__init__`` is generated on its first construction; a class
99+
#: whose ``__new__`` takes parameters of its own; a class with no ``__new__`` at all.
100+
LEFT_ALONE = (
101+
'pcapkit.protocols.data.application.ftp.FTP',
102+
'pcapkit.corekit.fields.strings.DecodedString',
103+
'pcapkit.foundation.extraction.Extractor',
104+
)
105+
106+
107+
class Forwarding:
108+
"""A base whose ``__init__`` names no parameters of its own."""
109+
110+
def __init__(self, *args: 'Any', **kwargs: 'Any') -> 'None':
111+
pass
112+
113+
114+
class Built(Forwarding):
115+
"""A class whose ``__new__`` takes the parameters, ahead of the base's ``__init__``."""
116+
117+
def __new__(cls, value: 'str', raw: 'bytes') -> 'Built': # pylint: disable=unused-argument
118+
return super().__new__(cls)
119+
120+
121+
@unittest.skipUnless(HAS_SPHINX, SPHINX_SKIP_REASON)
122+
class ClassSignatureTests(unittest.TestCase):
123+
"""``restore_class_signature`` gives these classes their ``__init__``'s parameters."""
124+
125+
conf = None # type: Any
126+
127+
@classmethod
128+
def setUpClass(cls) -> 'None':
129+
cls.conf = load_conf()
130+
131+
def setUp(self) -> 'None':
132+
# NB: the library's handler needs a real environment; here it is only
133+
# checked to be called.
134+
patcher = mock.patch.object(self.conf, 'typehints_process_signature', return_value=None)
135+
self.library = patcher.start()
136+
self.addCleanup(patcher.stop)
137+
self.app = app_for(self.conf)
138+
139+
def restore(self, name: 'str', what: 'str' = 'class') -> 'Any':
140+
"""The hook's own answer for the class at ``name``."""
141+
return self.conf.restore_class_signature(self.app, what, name, resolve(name), {}, CATCH_ALL, None)
142+
143+
def test_classes_get_their_init_parameters(self) -> 'None':
144+
"""Each rendered ``(*args, **kwargs)``, or ``PCAPIO`` its base's ``(fname, **kwargs)``."""
145+
for name, expected in RESTORED.items():
146+
with self.subTest(name=name):
147+
self.assertEqual(self.restore(name), (expected, None))
148+
149+
def test_restored_parameters_are_the_init_parameters(self) -> 'None':
150+
"""The names and kinds are the ``__init__``'s, and :func:`inspect.signature`'s where it has them."""
151+
from sphinx.util.inspect import signature_from_str # pylint: disable=import-outside-toplevel
152+
for name in RESTORED:
153+
cls = resolve(name)
154+
init = self.conf.hidden_init(cls)
155+
restored = signature_from_str(self.restore(name)[0])
156+
shape = [(param.name, param.kind) for param in restored.parameters.values()]
157+
with self.subTest(name=name):
158+
self.assertEqual(shape, [(param.name, param.kind)
159+
for param in list(inspect.signature(init).parameters.values())[1:]])
160+
public = inspect.signature(cls).parameters.values()
161+
if [param.kind for param in public] != [inspect.Parameter.VAR_POSITIONAL,
162+
inspect.Parameter.VAR_KEYWORD]:
163+
self.assertEqual(shape, [(param.name, param.kind) for param in public])
164+
165+
def test_other_classes_are_left_alone(self) -> 'None':
166+
"""Nothing hides these classes' constructor, or it is not known before first construction."""
167+
for name in LEFT_ALONE:
168+
with self.subTest(name=name):
169+
self.assertIsNone(self.restore(name))
170+
self.assertIsNone(self.restore('pcapkit.protocols.application.ftp.FTP', what='attribute'))
171+
self.library.assert_not_called()
172+
173+
def test_new_with_parameters_is_left_to_autodoc(self) -> 'None':
174+
"""Walking past it would restore the base's ``(*args, **kwargs)`` over ``(value, raw)``."""
175+
self.assertIsNone(self.conf.hidden_init(Built))
176+
self.assertIsNone(self.conf.restore_class_signature(self.app, 'class', 'Built', Built, {}, '(value, raw)',
177+
None))
178+
self.library.assert_not_called()
179+
180+
def test_library_handler_still_runs(self) -> 'None':
181+
"""From 3.14 it records the class, and only a recorded class gets its type parameters."""
182+
name = 'pcapkit.foundation.reassembly.ipv4.IPv4'
183+
self.restore(name)
184+
self.library.assert_called_once_with(self.app, 'class', name, resolve(name), {}, CATCH_ALL, None)
185+
186+
def test_setup_connects_it_ahead_of_the_library(self) -> 'None':
187+
"""At the library's priority the library would answer first, with the catch-all."""
188+
from sphinx.application import Sphinx # pylint: disable=import-outside-toplevel
189+
default = inspect.signature(Sphinx.connect).parameters['priority'].default
190+
wired = [(event, priority) for event, handler, priority in connections(self.conf)
191+
if handler is self.conf.restore_class_signature]
192+
self.assertEqual([event for event, _ in wired], [EVENT])
193+
self.assertLess(wired[0][1], default)
194+
195+
def test_build_restores_the_signature(self) -> 'None':
196+
"""Through the event, the catch-all the library answers is replaced."""
197+
name = 'pcapkit.protocols.application.ftp.FTP'
198+
result = emit(self.conf, self.app, EVENT, 'class', name, resolve(name), {}, CATCH_ALL, None,
199+
library=lambda *args: (CATCH_ALL, None))
200+
self.assertEqual(result, (RESTORED[name], None))
201+
202+
203+
@unittest.skipUnless(HAS_SPHINX, SPHINX_SKIP_REASON)
204+
class ConstructorTypesTests(unittest.TestCase):
205+
"""``constructor_for_types`` has the library read parameter types from the ``__init__``."""
206+
207+
conf = None # type: Any
208+
209+
@classmethod
210+
def setUpClass(cls) -> 'None':
211+
cls.conf = load_conf()
212+
213+
def test_init_behind_a_pass_through_new_is_read(self) -> 'None':
214+
"""Each class docstring lists the ``__init__`` parameters, which lost their types."""
215+
for name in ('pcapkit.foundation.reassembly.ipv4.IPv4', 'pcapkit.foundation.traceflow.tcp.TCP',
216+
'pcapkit.corekit.infoclass.Info', 'pcapkit.corekit.infoclass.MultiInfo'):
217+
cls = resolve(name)
218+
with self.subTest(name=name):
219+
self.assertEqual(self.conf.typehints_constructor_name(cls), '__new__')
220+
self.assertEqual(self.conf.constructor_for_types(cls), '__init__')
221+
222+
def test_overloaded_init_keeps_the_library_choice(self) -> 'None':
223+
"""Reading it would add an ``Overloads`` field to every protocol class."""
224+
cls = resolve('pcapkit.protocols.application.ftp.FTP')
225+
self.assertEqual(self.conf.constructor_for_types(cls), self.conf.typehints_constructor_name(cls))
226+
227+
def test_other_classes_keep_the_library_choice(self) -> 'None':
228+
"""A class with no pass-through ``__new__`` in the way is left as the library reads it."""
229+
for name in ('pcapkit.dumpkit.pcap.PCAPIO', 'pcapkit.corekit.fields.strings.DecodedString',
230+
'pcapkit.protocols.data.application.ftp.FTP'):
231+
cls = resolve(name)
232+
with self.subTest(name=name):
233+
self.assertEqual(self.conf.constructor_for_types(cls), self.conf.typehints_constructor_name(cls))
234+
235+
def test_builder_inited_points_the_library_at_it(self) -> 'None':
236+
"""Each module that reads the choice for the docstring types reads this one."""
237+
modules = (self.conf.typehints_docstring_handler, self.conf.typehints_hints)
238+
cls = resolve('pcapkit.foundation.reassembly.ipv4.IPv4')
239+
with mock.patch.object(modules[0], 'constructor_name', modules[0].constructor_name), \
240+
mock.patch.object(modules[1], 'constructor_name', modules[1].constructor_name):
241+
self.conf.read_constructor_types_from_init(None)
242+
for module in modules:
243+
self.assertIs(module.constructor_name, self.conf.constructor_for_types)
244+
self.assertEqual(self.conf.typehints_hints.documenting_class('x', cls), (cls, '__init__'))
245+
246+
def test_setup_connects_it(self) -> 'None':
247+
"""Unconnected, the library goes on reading the pass-through ``__new__``."""
248+
wired = [event for event, handler, _ in connections(self.conf)
249+
if handler is self.conf.read_constructor_types_from_init]
250+
self.assertEqual(wired, ['builder-inited'])
251+
252+
253+
if __name__ == '__main__':
254+
unittest.main()

0 commit comments

Comments
 (0)