|
| 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