diff --git a/pcapkit/corekit/infoclass.py b/pcapkit/corekit/infoclass.py index b411e364f6..5e71a61cda 100644 --- a/pcapkit/corekit/infoclass.py +++ b/pcapkit/corekit/infoclass.py @@ -17,6 +17,7 @@ import collections.abc import contextlib import enum +import inspect import itertools import sys import types @@ -69,6 +70,33 @@ class FinalisedState(EnumLookup, enum.IntEnum): FINAL = enum.auto() +#: :func:`inspect.get_annotations`, new in Python 3.10, or :data:`None` below it. +_get_annotations = getattr(inspect, 'get_annotations', None) + + +def _own_annotations(cls: 'type') -> 'dict[str, Any]': + """Return the annotations ``cls`` declares itself, never those of a base. + + Below Python 3.10 (bpo-43901), ``cls.__annotations__`` of a class whose body + declares none is its nearest base's, so a field-less :class:`Info` + subclass read :class:`Info`'s own ``__finalised__``, ``__additional__`` and + ``__excluded__`` as its fields (:issue:`1692`). There, a class's own + annotations are in its ``__dict__``; from 3.10 on, they are read with + :func:`inspect.get_annotations`, as from 3.14 they are evaluated lazily and + are not in ``__dict__`` at all. + + Args: + cls: Class to read. + + Returns: + The annotations ``cls``'s own body declares, which may be empty. + + """ + if _get_annotations is None: + return cls.__dict__.get('__annotations__', {}) + return _get_annotations(cls) + + def info_final(cls: 'ST', *, _finalised: 'bool' = True) -> 'ST': """Finalise info class. @@ -166,8 +194,10 @@ def info_final(cls: 'ST', *, _finalised: 'bool' = True) -> 'ST': break # NOTE: We iterate in reversed order to ensure that the type - # annotations of the superclasses are considered first. - for key in reversed(cls_.__annotations__): + # annotations of the superclasses are considered first. Each class + # contributes only the annotations it declares itself (see + # :func:`_own_annotations`), on every Python version. + for key in reversed(_own_annotations(cls_)): # NOTE: We skip duplicated annotations to avoid duplicate # argument in function definition. if key in args_: @@ -1161,7 +1191,7 @@ def _nested_types(cls: 'Type[Info]') -> 'dict[str, Type[Info]]': module = sys.modules.get(cls_.__module__) globalns = dict(vars(module)) if module is not None else {} localns = dict(vars(cls_)) - for (key, ann) in cls_.__dict__.get('__annotations__', getattr(cls_, '__annotations__', {})).items(): + for (key, ann) in _own_annotations(cls_).items(): if key in seen: continue seen.add(key) diff --git a/tests/corekit/test_infoclass.py b/tests/corekit/test_infoclass.py index 87ed38da08..ed86032224 100644 --- a/tests/corekit/test_infoclass.py +++ b/tests/corekit/test_infoclass.py @@ -103,5 +103,120 @@ class Child(Base): self.assertEqual(Info.from_dict(c=3).to_dict().to_dict(), {'c': 3}) +@unittest.skipUnless(HAS_RUNTIME, 'runtime dependencies not installed') +class OwnAnnotationsTests(unittest.TestCase): + """GitHub issue #1692: each class contributes only the annotations it declares. + + Below Python 3.10 (bpo-43901), ``cls.__annotations__`` of a class declaring + none is its nearest base's. These pin the 3.10+ answers on every version. + + """ + + #: Generated ``__init__`` signature of each class :meth:`_signatures` builds. + EXPECTED = { + 'Bare': '(self, dict_=None, **kwargs)', + 'BareChild': '(self, dict_=None, **kwargs)', + 'Point': '(self, x, y)', + 'PointChild': '(self, x, y)', + 'Chain': '(self, a, c)', + # NOTE: MRO ``Diamond, B, C, A``. Reading ``B.__annotations__`` gave + # ``A``'s ``a`` ahead of ``C``'s ``c``, i.e. ``(self, c, a, d)``. + 'Diamond': '(self, a, c, d)', + } + + def setUp(self) -> None: + reimport_once_per_class(self) + + @staticmethod + def _signatures() -> dict[str, str]: + """Build fresh classes, finalise them, and return each ``__init__`` signature.""" + import inspect + + from pcapkit.corekit.infoclass import Info, info_final + + class Bare(Info): + pass + + class BareChild(Bare): + pass + + class Point(Info): + x: int + y: int + + class PointChild(Point): + pass + + class A(Info): + a: int + + class B(A): + pass + + class Chain(B): + c: int + + class C(A): + c: int + + class Diamond(B, C): + d: int + + signatures = {} + for cls in (Bare, BareChild, Point, PointChild, Chain, Diamond): + # NOTE: ``_finalised=False`` is what :meth:`Info.__new__` passes; it + # leaves the class open, so ``BareChild`` can follow ``Bare``. + info_final(cls, _finalised=False) + signatures[cls.__name__] = str(inspect.signature(cls.__init__)) + return signatures + + def test_generated_signature_is_the_same_on_every_version(self) -> None: + self.assertEqual(self._signatures(), self.EXPECTED) + + from pcapkit.corekit.infoclass import Info + + class Bare(Info): + pass + + class BareChild(Bare): + pass + + for cls in (Bare, BareChild): + with self.subTest(cls=cls.__name__): + info = cls({'a': 1}, b=2) + self.assertEqual(list(info.items()), [('a', 1), ('b', 2)]) + + def test_the_pre_3_10_reader_gives_the_same_signatures(self) -> None: + # NOTE: ``_get_annotations`` set to ``None`` selects the reader Python + # 3.6-3.9 use, so every leg checks that branch. It is sound on 3.14 too + # because this module's ``from __future__ import annotations`` stores a + # class's own annotations in its ``__dict__`` there as well. + from pcapkit.corekit import infoclass + + with mock.patch.object(infoclass, '_get_annotations', None): + self.assertEqual(self._signatures(), self.EXPECTED) + + def test_from_dict_rebuilds_a_field_inherited_by_a_field_less_subclass(self) -> None: + from pcapkit.corekit.infoclass import Info + + class Leaf(Info): + v: int + + class Holder(Info): + # NOTE: an annotation is resolved in the namespace of the class + # declaring it; ``Kind`` stands in for a base module's globals. + # Below 3.10, ``Sub`` read ``child`` as its own and resolved it in + # its own namespace, where ``Kind`` is unknown, so it was skipped. + Kind = Leaf + child: Kind + + class Sub(Holder): + pass + + sub = Sub.from_dict(child={'v': 1}) + self.assertIsInstance(sub['child'], Leaf) + self.assertEqual(sub['child']['v'], 1) + + if __name__ == '__main__': unittest.main()