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
36 changes: 33 additions & 3 deletions pcapkit/corekit/infoclass.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import collections.abc
import contextlib
import enum
import inspect
import itertools
import sys
import types
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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_:
Expand Down Expand Up @@ -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)
Expand Down
115 changes: 115 additions & 0 deletions tests/corekit/test_infoclass.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading