# -*- coding: utf-8 -*-
"""Info Class
================
.. module:: pcapkit.corekit.infoclass
:mod:`pcapkit.corekit.infoclass` contains :obj:`dict` like class
:class:`~pcapkit.corekit.infoclass.Info`, which is originally
designed to work like :func:`dataclasses.dataclass` as introduced
in :pep:`557`, and the immutable multi-mapping classes
:class:`~pcapkit.corekit.infoclass.MultiInfo` and
:class:`~pcapkit.corekit.infoclass.OrderedMultiInfo` that a finalised
:class:`~pcapkit.corekit.infoclass.Info` holds its option lists in.
"""
import abc
import collections.abc
import contextlib
import enum
import itertools
import sys
import types
import typing
from typing import TYPE_CHECKING, Generic, TypeVar, cast
from pcapkit.corekit.enum import EnumLookup
from pcapkit.corekit.multidict import MultiDict, OrderedMultiDict, iter_multi_items
from pcapkit.utilities.compat import Mapping, UnionType, final, get_annotations
from pcapkit.utilities.exceptions import InfoError, UnsupportedCall, stacklevel
from pcapkit.utilities.pickle import ReduceAsProtocol2, ReduceToItems
from pcapkit.utilities.warnings import InfoWarning, warn
if TYPE_CHECKING:
from typing import (Any, Collection, ItemsView, Iterable, Iterator, NoReturn, Optional,
SupportsIndex, Type)
from typing_extensions import Self
__all__ = ['Info', 'info_final', 'MultiInfo', 'OrderedMultiInfo']
KT = TypeVar('KT')
VT = TypeVar('VT')
ST = TypeVar('ST', bound='Type[Info]')
[docs]
class FinalisedState(EnumLookup, enum.IntEnum):
"""Finalised state.
Built on :class:`~pcapkit.corekit.enum.EnumLookup` per GitHub issue
:issue:`877`'s ruling that every non-registry enumeration shares that
lookup contract; this class defines neither ``get`` nor ``_missing_`` of
its own.
"""
#: Not finalised.
NONE = enum.auto()
#: Base class.
BASE = enum.auto()
#: Finalised. A class reaching this state is also handed to
#: :func:`~pcapkit.utilities.compat.final`, so a correctly finalised class
#: carries *both* markers -- and they answer two different questions, which
#: is why both are read rather than one standing in for the other.
#: ``__final__`` answers "may this be subclassed?", for
#: :meth:`Info.__init_subclass__`; this state answers "has
#: :func:`info_final` already generated the attributes?", for
#: :func:`info_final`'s own re-entry check. Carrying ``__final__``
#: *without* this state is the mismatch :meth:`Info.__new__` refuses:
#: marked final by hand, never finalised, and so missing the generated
#: ``__init__`` that makes the class usable at all.
FINAL = enum.auto()
[docs]
def info_final(cls: 'ST', *, _finalised: 'bool' = True) -> 'ST':
"""Finalise info class.
This decorator function is used to generate necessary
attributes and methods for the decorated :class:`Info`
class. It can be useful to reduce runtime generation
time as well as caching already generated attributes.
Notes:
The decorator should only be used on the *final* class. Applying it
with ``_finalised=True`` seals the class against *subclassing*, which
:meth:`Info.__init_subclass__` enforces, and marks it with
:func:`~pcapkit.utilities.compat.final` -- so ``@info_final`` implies
``@final`` and there is never a reason to write both. Writing both is
harmless in either order, though; what is not is ``@final`` *without*
this decorator, which :meth:`Info.__new__` refuses -- provided ``@final``
is :func:`~pcapkit.utilities.compat.final` (or, on Python 3.11 and up,
:func:`typing.final` directly). Below 3.11, plain :func:`typing.final`
does not set ``__final__`` at all (gh-90500), so a class marked with a
user's own ``from typing import final`` on 3.10 is not refused: there is
nothing on the class for :meth:`Info.__new__` to read.
Applying this decorator to the same class a second time only warns: the
first application already did the work, so the duplicate is redundant
rather than wrong, and the class comes back finalised and usable.
Args:
cls: Info class.
_finalised: Whether to make the info class as finalised.
Returns:
Finalised info class.
Warns:
pcapkit.utilities.warnings.InfoWarning: If ``cls`` has already been
finalised *by this function*, i.e. it carries
:attr:`FinalisedState.FINAL` in its own ``__dict__``. The class is
returned untouched.
.. versionchanged:: 1.5.0
``@final`` is enforced at runtime: a class marked with it but never
passed through this decorator is refused at first construction
(:issue:`778`).
:meta decorator:
"""
# NOTE: ``__finalised__``, not ``__final__``, so ``@info_final`` over ``@final`` still
# generates; read off ``cls.__dict__``, as an inherited ``FINAL`` says nothing of ``cls``.
if cls.__dict__.get('__finalised__') == FinalisedState.FINAL:
warn(f'{cls.__name__}: info class has been finalised; now skipping',
InfoWarning, stacklevel=stacklevel())
return cls
# NOTE: ``Info`` never reaches ``BASE``, so ``__base_ready__``, in its own ``__dict__`` and
# apart from ``__finalised__``, stops a bare ``Info()`` redoing this setup on every call.
if cls is Info and cls.__dict__.get('__base_ready__'):
return cls
temp = ['__map__', '__map_reverse__', '__multi__', '__builtin__', '__finalised__']
temp.extend(cls.__additional__)
for obj in cls.mro():
temp.extend(dir(obj))
cls.__builtin__ = set(temp)
cls.__excluded__.extend(cls.__builtin__)
# NOTE: Only a subclass of ``Info`` not overriding ``__init__`` gets one; a :class:`_MultiInfo`
# declares no fields and keeps the mapping constructor it inherits (#1484).
if '__init__' not in cls.__dict__ and cls is not Info and not issubclass(cls, _MultiInfo):
args_ = [] # type: list[str]
dict_ = [] # type: list[str]
for cls_ in cls.mro(): # pragma: no branch
# NOTE: We skip the ``Info`` class itself, to avoid superclass
# type annotations being considered.
if cls_ is Info:
break
# NOTE: Reversed so superclass annotations come first. Each class gives only its own,
# as below 3.10 a field-less class reads its base's (bpo-43901, #1692).
for key in reversed(get_annotations(cls_)):
# NOTE: We skip duplicated annotations to avoid duplicate
# argument in function definition.
if key in args_:
continue
args_.append(key)
dict_.append(f'{key}={key}')
# NOTE: reversed to match the order of the type annotations, from the
# most base class to the most derived.
args_.reverse()
dict_.reverse()
# NOTE: We only generate typed ``__init__`` method if only the class
# has type annotations from any of itself and its base classes.
if args_:
# NOTE: The following code is to make the ``__init__`` method work.
# It is inspired from the :func:`dataclasses._create_fn` function.
init_ = (
f'def __create_fn__():\n'
f' def __init__(self, {", ".join(args_)}):\n'
f' self.__update__({", ".join(dict_)})\n'
f' self.__post_init__()\n'
f' return __init__\n'
)
else:
init_ = (
'def __create_fn__():\n'
' def __init__(self, dict_=None, **kwargs):\n'
' self.__update__(dict_, **kwargs)\n'
' self.__post_init__()\n'
' return __init__\n'
)
ns = {} # type: dict[str, Any]
exec(init_, None, ns) # pylint: disable=exec-used # nosec
cls.__init__ = ns['__create_fn__']() # type: ignore[misc]
cls.__init__.__qualname__ = f'{cls.__name__}.__init__' # type: ignore[misc]
if not _finalised:
# NOTE: Never promote ``Info`` itself, as every later subclass would inherit ``BASE`` and
# skip its one-shot finalisation (#778); ``__base_ready__`` marks ``Info`` instead.
if cls is not Info:
cls.__finalised__ = FinalisedState.BASE
else:
cls.__base_ready__ = True
return cls
cls.__finalised__ = FinalisedState.FINAL
return final(cls)
[docs]
class Info(ReduceAsProtocol2, Mapping[str, VT], Generic[VT], metaclass=InfoMeta):
"""Turn dictionaries into :obj:`object` like instances.
* :class:`Info` objects are :class:`~collections.abc.Mapping` instances,
not :obj:`dict` subclasses
* :class:`Info` objects are *iterable*, and support the read-only
:obj:`dict` interface
* :class:`Info` objects are **immutable**, thus cannot set or delete
attributes after initialisation
Important:
:class:`Info` will attempt to rename keys with the same names as the
class's builtin methods, and store the mapping information in the
:attr:`__map__` and :attr:`__map_reverse__` attributes. However, when
accessing such renamed keys, the original key name should always be
used, i.e., such renaming is totally transparent to the user.
.. versionchanged:: 1.3.1.post8
Iterating an instance skips the names listed in :attr:`__excluded__`.
A key whose name is a dunder, such as ``__next_type__``, is metadata.
Iterating an instance, ``len()`` and :meth:`to_dict` leave such a key out
unless :attr:`__included__` names it, and carry every other key;
:attr:`__excluded__` names the keys that ``repr``, ``str`` and the dumps of
:mod:`pcapkit.dumpkit` leave out besides. A subclass extends either list
with its own names, as for :attr:`__additional__`.
.. versionchanged:: 1.5.0
:attr:`__excluded__` names the keys left out of ``repr``, ``str`` and
the dumps only, and :attr:`__included__` the dunder keys that iterating
an instance, ``len()`` and :meth:`to_dict` carry (:issue:`2003`).
"""
if TYPE_CHECKING:
#: Mapping of name conflicts with builtin methods (original names to
#: transformed names).
__map__: 'dict[str, str]'
#: Mapping of name conflicts with builtin methods (transformed names to
#: original names).
__map_reverse__: 'dict[str, str]'
#: Values of keys held more than once, i.e. every value but the first,
#: keyed by the (transformed) key that was last in :attr:`__dict__` when
#: each was added, so :meth:`to_dict` writes it right after that key.
__multi__: 'dict[str, list[tuple[str, Any]]]'
#: List of builtin methods.
__builtin__: 'set[str]'
#: Flag for finalised class initialisation.
__finalised__: 'FinalisedState' = FinalisedState.NONE
#: List of additional built-in names.
__additional__: 'list[str]' = []
#: List of names of keys left out of ``repr``, ``str`` and the dumps of
#: :mod:`pcapkit.dumpkit` -- metadata such as ``__next_type__``, which
#: :meth:`to_dict` may still carry, see :attr:`__included__`.
#: Finalisation adds the names in :attr:`__builtin__`, though ``repr``,
#: ``str`` and the dumps write a field renamed for clashing with one of
#: them, such as a reassembled datagram's ``packet``, under its own name
#: (:issue:`2733`).
__excluded__: 'list[str]' = []
#: List of names of dunder keys that iterating an instance, ``len()`` and
#: :meth:`to_dict` carry -- metadata such as ``__next_type__``, which a
#: rebuild from the :obj:`dict` reads. Every other dunder key is dropped
#: from all three, though indexing still reads it; a key that is no
#: dunder is carried regardless. A subclass extends the list with its own
#: names, as for :attr:`__excluded__`, so a custom :class:`Info` holding
#: dunder keys of its own must list them here for :meth:`to_dict` to keep
#: them (:issue:`2003`).
__included__: 'list[str]' = []
[docs]
def __init_subclass__(cls, /, *args: 'Any', **kwargs: 'Any') -> 'None':
"""Refuse to derive from a finalised info class.
:func:`~pcapkit.utilities.compat.final` is a promise to the type
checker and nothing more: it records ``__final__`` on the class and
leaves the interpreter free to subclass it anyway. Every class
:func:`info_final` finalises carries a generated ``__init__`` built
from the annotations that were visible at that moment, so a subclass
added afterwards inherits a constructor that does not know about its
own fields -- silently, and with the ``__builtin__`` and
``__excluded__`` sets of its parent. This turns that promise into a
rule the interpreter keeps.
Args:
*args: Arbitrary positional arguments.
**kwargs: Arbitrary keyword arguments in class definition.
Raises:
InfoError: If any class in ``cls``'s ancestry carries the
``__final__`` marker in its own ``__dict__``.
"""
# NOTE: The whole MRO, not ``cls.__bases__``, so a marker two levels up still guards;
# ``base.__dict__``, as an inherited ``__final__`` would reject declarations predating it.
for base in cls.__mro__[1:]:
if base.__dict__.get('__final__'):
raise InfoError(f'{cls.__name__}: cannot subclass {base.__name__}, '
'which is final')
# NOTE: forwarded rather than swallowed, so a class keyword nobody
# accepts still reaches ``object`` and fails there.
super().__init_subclass__(*args, **kwargs)
[docs]
def __new__(cls, *args: 'VT', **kwargs: 'VT') -> 'Self': # pylint: disable=unused-argument
"""Create a new instance.
The class will try to automatically generate ``__init__`` method with
the same signature as specified in class variables' type annotations,
which is inspired by :pep:`557` (:mod:`dataclasses`).
Note:
Whether ``cls`` still needs finalising is read from its *own*
``__dict__``, never inherited (GitHub issue :issue:`1490`). Each
class is finalised on its first construction, whatever its
ancestors' state, so a subclass of a :attr:`FinalisedState.BASE` or
:attr:`FinalisedState.FINAL` ancestor still gets its own
``__builtin__``, ``__excluded__`` and generated ``__init__``, and a
``@final`` class under a ``BASE`` ancestor is refused like any other.
Args:
*args: Arbitrary positional arguments.
**kwargs: Arbitrary keyword arguments.
Raises:
InfoError: If ``cls`` was marked with
:func:`~pcapkit.utilities.compat.final` but never finalised by
:func:`info_final`, i.e. it carries ``__final__`` in its own
``__dict__`` without :attr:`FinalisedState.FINAL`.
"""
# NOTE: A bare ``@final`` lands after class creation, so it is caught here; both lookups
# read ``cls.__dict__``, as an inherited marker says nothing of this class (#1490).
if cls.__dict__.get('__finalised__', FinalisedState.NONE) == FinalisedState.NONE:
if cls.__dict__.get('__final__'):
raise InfoError(f'{cls.__name__}: marked final but never finalised, so it has no generated '
'__init__; apply info_final, which applies final itself, not final alone')
cls = info_final(cls, _finalised=False)
self = super().__new__(cls)
# NOTE: set on ``self`` so that ``__map__`` and ``__map_reverse__`` are
# not class variables shared by all instances.
super().__setattr__(self, '__map__', {})
super().__setattr__(self, '__map_reverse__', {})
super().__setattr__(self, '__multi__', {})
return self
def __copy__(self) -> 'Self':
# NOTE: The bookkeeping mappings are per instance, so a copy gets its
# own: a later :meth:`__update__` on it must not reach the original.
new = type(self).__new__(type(self))
new.__dict__.update(self.__dict__)
new.__dict__['__map__'] = dict(self.__map__)
new.__dict__['__map_reverse__'] = dict(self.__map_reverse__)
new.__dict__['__multi__'] = {key: list(values) for (key, values) in self.__multi__.items()}
return new
[docs]
def __post_init__(self) -> 'None':
"""Customisation method to be called after initialisation."""
def __update__(self, dict_: 'Optional[Mapping[str, VT] | Iterable[tuple[str, VT]]]' = None,
**kwargs: 'VT') -> 'None':
# NOTE: Keys clashing with the class's builtin methods are stored under mangled
# names, recorded in :attr:`__map__`.
# NOTE: A :class:`MultiDict` *adds* its values, as :meth:`MultiDict.update` does, with
# repeats recorded in :attr:`__multi__`; anything else replaces the values held.
# NOTE: A held :class:`MultiDict` or :class:`OrderedMultiDict` becomes a :class:`MultiInfo`
# or :class:`OrderedMultiInfo`, so a finalised instance holds no mutable mapping (#1484).
__name__ = type(self).__name__ # pylint: disable=redefined-builtin
multi_iter = () # type: Iterable[tuple[str, Any]]
if dict_ is None:
data_iter = kwargs.items() # type: Iterable[tuple[str, Any]]
elif isinstance(dict_, MultiDict):
multi_iter = dict_.items(multi=True)
data_iter = kwargs.items()
elif isinstance(dict_, (dict, collections.abc.Mapping)) or hasattr(dict_, 'items'):
data_iter = itertools.chain(dict_.items(), kwargs.items())
else:
data_iter = itertools.chain(dict_, kwargs.items())
for (key, value) in multi_iter:
new_key = f'_{__name__}{key}' if key in self.__builtin__ else key
if new_key in self.__dict__ and new_key not in self.__excluded__:
anchor = next(reversed(self.__dict__))
self.__multi__.setdefault(anchor, []).append((new_key, _freeze(value)))
else:
self.__update__({key: value})
for (key, value) in data_iter:
if self.__multi__:
# NOTE: A key replaced drops the values it held besides the first.
new_key = f'_{__name__}{key}' if key in self.__builtin__ else key
for values in self.__multi__.values():
values[:] = [item for item in values if item[0] != new_key]
if key in self.__builtin__:
new_key = f'_{__name__}{key}'
# NOTE: We keep record of the mapping bidirectionally.
self.__map__[key] = new_key
self.__map_reverse__[new_key] = key
key = new_key
# if key in self.__dict__:
# raise KeyExists(f'{key!r} already exists')
# NOTE: key names are kept as given, even where they break the
# ``.`` (:obj:`getattr`) operator.
# if isinstance(key, str):
# key = re.sub(r'\W', '_', key)
self.__dict__[key] = _freeze(value)
__init__ = __update__
def __str__(self) -> 'str':
temp = [] # type: list[str]
for (key, value) in self.__dict__.items():
if key in self.__excluded__:
continue
out_key = self.__map_reverse__.get(key, key)
temp.append(f'{out_key}={value}')
args = ', '.join(temp)
return f'{type(self).__name__}({args})'
def __repr__(self) -> 'str':
temp = [] # type: list[str]
for (key, value) in self.__dict__.items():
if key in self.__excluded__:
continue
out_key = self.__map_reverse__.get(key, key)
# NOTE: a multi-mapping :class:`Info`, i.e. an option list, is
# written out in full, as the multi-mapping it is.
if isinstance(value, Info) and not isinstance(value, _MultiInfo):
temp.append(f'{out_key}={type(value).__name__}(...)')
else:
temp.append(f'{out_key}={value!r}')
args = ', '.join(temp)
return f'<{type(self).__name__} {args}>'
def __len__(self) -> 'int':
# NOTE: count exactly what :meth:`__iter__` yields, so ``__excluded__``
# keys count and bookkeeping or unnamed dunder keys do not (#2003).
return sum(1 for key in self.__dict__ if not self.__hidden(key))
def __iter__(self) -> 'Iterator[str]':
for key in self.__dict__:
if self.__hidden(key):
continue
yield self.__map_reverse__.get(key, key)
def __getitem__(self, name: 'str') -> 'VT':
key = self.__map__.get(name, name)
return self.__dict__[key]
[docs]
def items(self, multi: 'bool' = False) -> 'ItemsView[str, VT] | Iterator[tuple[str, VT]]': # type: ignore[override]
"""Return the ``(key, value)`` pairs.
Args:
multi: If set to :obj:`True`, an iterator with a pair for each value
of each key, as :meth:`to_dict` writes them. Otherwise a view of
the pairs of the first value of each key, as the ``items``
method of :class:`collections.abc.Mapping` returns.
"""
if not multi:
return super().items()
return ((self.__map_reverse__.get(key, key), value) for (key, value) in self.__items_multi())
def __items_multi(self) -> 'Iterator[tuple[str, VT]]':
"""Every ``(key, value)`` pair, with the transformed keys, in order."""
for (key, value) in self.__dict__.items():
if not self.__hidden(key):
yield key, value
yield from self.__multi__.get(key, ())
def __hidden(self, key: 'str') -> 'bool':
"""Whether iteration, ``len()`` and :meth:`to_dict` leave ``key`` out.
That is a bookkeeping attribute in :attr:`__builtin__`, or a dunder key
:attr:`__included__` does not name (:issue:`2003`).
"""
if key in self.__builtin__:
return True
return (isinstance(key, str) and key.startswith('__') and key.endswith('__')
and key not in self.__included__)
def __setattr__(self, name: 'str', value: 'VT') -> 'NoReturn':
raise UnsupportedCall("can't set attribute")
def __delattr__(self, name: 'str') -> 'NoReturn':
raise UnsupportedCall("can't delete attribute")
[docs]
@classmethod
def from_dict(cls, dict_: 'Optional[Mapping[str, VT] | Iterable[tuple[str, VT]]]' = None,
**kwargs: 'VT') -> 'Self':
r"""Create a new instance.
* If ``dict_`` is present and has a ``.keys()`` method, then does:
``for k in dict_: self[k] = dict_[k]``.
* If ``dict_`` is present and has no ``.keys()`` method, then does:
``for k, v in dict_: self[k] = v``.
* If ``dict_`` is not present, then does:
``for k, v in kwargs.items(): self[k] = v``.
* If ``dict_`` is a :class:`~pcapkit.corekit.multidict.MultiDict`, a
key it holds more than once keeps every value, as :meth:`to_dict`
returns them: the first is the value of the key, and
``items(multi=True)`` yields all of them.
A mapping given for a key whose type annotation resolves at runtime to
an :class:`Info` subclass (or to :data:`~typing.Optional` of one) is
rebuilt into that class, so ``cls.from_dict(info.to_dict())`` restores
such nested values. A key without such an annotation keeps the mapping
as is -- see :meth:`to_dict`.
Every :class:`~pcapkit.corekit.multidict.MultiDict` or
:class:`~pcapkit.corekit.multidict.OrderedMultiDict` value is stored
as a :class:`MultiInfo` or :class:`OrderedMultiInfo` respectively, or
as the subclass of either that the key's annotation names, such as
:class:`~pcapkit.protocols.application.httpv2.data.Settings`.
The rebuild is as unchecked as the rest of this method: a mapping whose
keys are not the nested class's own is accepted and becomes an instance
carrying those keys, exactly as a direct call to the nested class's own
:meth:`from_dict` would. Validating it here would reject data that
``Info(**kwargs)`` has always accepted, so the shape of the mapping
stays the caller's responsibility.
Args:
dict\_: Source data.
**kwargs: Arbitrary keyword arguments.
"""
self = cls.__new__(cls)
self.__update__(dict_, **kwargs)
for (name, info_cls) in _nested_types(cls).items():
key = self.__map__.get(name, name)
if key in self.__dict__:
self.__dict__[key] = _rebuild(info_cls, self.__dict__[key])
for values in self.__multi__.values():
for (index, (rkey, rvalue)) in enumerate(values):
if rkey == key:
values[index] = (rkey, _rebuild(info_cls, rvalue))
return self
[docs]
def to_dict(self) -> 'OrderedMultiDict[str, VT]':
"""Convert :class:`Info` into :obj:`dict`.
The :obj:`dict` is an
:class:`~pcapkit.corekit.multidict.OrderedMultiDict` of the fields, in
order -- a :class:`_OrderedMultiDict`, i.e. one whose ``!=`` is the
negation of its ``==``, so two equal exports compare ``!=``
:data:`False`. A key held more than once -- as given by a
:class:`~pcapkit.corekit.multidict.MultiDict` to :meth:`from_dict`, e.g.
a repeated IPv6 extension header -- keeps every value, in place, for
``items(multi=True)`` and :meth:`~pcapkit.corekit.multidict.MultiDict.getlist`,
while indexing sees the first. A nested :class:`Info` becomes its own
:meth:`to_dict`: an :class:`~pcapkit.corekit.multidict.OrderedMultiDict`
for a field model or an :class:`OrderedMultiInfo`, and a
:class:`~pcapkit.corekit.multidict.MultiDict` for a :class:`MultiInfo`;
the values those two hold are not converted.
Note:
``dict(result)``, ``{**result}`` and :func:`json.dumps` see the
*first* value of each key only, and silently drop the rest of a
repeated key; read every value with ``items(multi=True)`` or
:meth:`~pcapkit.corekit.multidict.MultiDict.getlist`. The result
compares equal to another
:class:`~pcapkit.corekit.multidict.MultiDict` only, never to a
plain :obj:`dict`.
Important:
We only convert nested :class:`Info` objects into :obj:`dict` if
they are the direct value of the :class:`Info` object's attribute.
Should such :class:`Info` objects be nested within other data
types, such as :obj:`list`, :obj:`tuple`, :obj:`set`, etc., we
leave them intact rather than converting them into :obj:`dict`.
Note:
:meth:`from_dict` reverses the flattening only for keys whose type
annotation resolves at runtime to an :class:`Info` subclass. For
any other key -- every key of a bare :class:`Info`, and annotations
naming a type imported only under :data:`~typing.TYPE_CHECKING` --
the conversion is one-way, and the nested value comes back as the
:class:`OrderedMultiInfo` that :meth:`from_dict` stores the
:class:`~pcapkit.corekit.multidict.OrderedMultiDict` it was
written as in.
.. versionchanged:: 1.5.0
Returns an :class:`~pcapkit.corekit.multidict.OrderedMultiDict`
rather than a :obj:`dict`, so a key held more than once keeps every
value (:issue:`1484`). It leaves out the dunder keys
:attr:`__included__` does not name, carries the keys in
:attr:`__excluded__`, and records those as its own ``__excluded__``
for the dumpers to leave out (:issue:`2003`).
.. versionchanged:: 1.6.0
The ``__excluded__`` it records leaves out a field renamed for
clashing with a builtin name, such as a reassembled datagram's
``packet``, so the dumpers write that field under its own name
(:issue:`2733`).
"""
dict_ = _OrderedMultiDict() # type: _OrderedMultiDict[str, Any]
dict_.__excluded__ = _dump_excluded(self)
for (key, value) in self.__items_multi():
out_key = self.__map_reverse__.get(key, key)
if isinstance(value, Info):
dict_.add(out_key, value.to_dict())
#elif isinstance(value, (tuple, list, set, frozenset)):
# temp = [] # type: list[Any]
# for item in value:
# if isinstance(item, Info):
# temp.append(item.to_dict())
# else:
# temp.append(item)
# dict_[out_key] = value.__class__(temp)
else:
dict_.add(out_key, value)
return dict_
[docs]
def _ne(self: 'OrderedMultiDict[Any, Any]', other: 'object') -> 'bool':
"""``self != other``, as the negation of ``self == other``.
The ``__ne__`` that :class:`_OrderedMultiDict` and :class:`OrderedMultiInfo`
add to Werkzeug's :class:`~pcapkit.corekit.multidict.OrderedMultiDict`. A
:data:`NotImplemented` from ``__eq__`` is passed on, so that Python asks
``other`` in turn, exactly as it does for ``==``.
"""
result = self.__eq__(other)
if result is NotImplemented:
return result
return not result
[docs]
class _OrderedMultiDict(OrderedMultiDict[KT, VT]):
"""The :class:`~pcapkit.corekit.multidict.OrderedMultiDict` that :meth:`Info.to_dict` returns.
It adds one method, :meth:`__ne__` (:func:`_ne`). The
:mod:`~pcapkit.corekit.multidict` classes are Werkzeug's, kept verbatim,
and Werkzeug's :class:`~pcapkit.corekit.multidict.OrderedMultiDict`
defines ``__eq__`` but no ``__ne__``: ``!=`` falls back to :obj:`dict`'s,
which compares the internal buckets, so two equal exports would compare
``!=`` as well as ``==`` -- and, since ``__eq__`` compares the values with
``!=``, two exports holding a nested export would compare unequal
(:issue:`1484`). Its copies, deep copies and pickles stay of this class, as
Werkzeug's build them from ``type(self)``, and :meth:`__repr__` writes it as
the :class:`~pcapkit.corekit.multidict.OrderedMultiDict` it is.
"""
__ne__ = _ne
#: Names of the keys the dumpers leave out -- the :attr:`Info.__excluded__`
#: of the info exported, less the fields it renamed for clashing with a
#: builtin name (:issue:`2733`), kept by copies, deep copies and pickles;
#: none for an export of a multi-mapping (:issue:`2003`).
__excluded__ = () # type: Collection[str]
[docs]
def __repr__(self) -> 'str':
"""Write the export as the :class:`~pcapkit.corekit.multidict.OrderedMultiDict`
it is, with every pair.
"""
return f'{OrderedMultiDict.__name__}({list(self.items(multi=True))!r})'
def copy(self) -> 'Self':
"""Return a shallow copy, with the same ``__excluded__``."""
new = super().copy()
new.__excluded__ = self.__excluded__
return new
def deepcopy(self, memo: 'Optional[dict]' = None) -> 'Self':
"""Return a deep copy, with the same ``__excluded__``."""
new = cast('Self', super().deepcopy(memo))
new.__excluded__ = self.__excluded__
return new
def __reduce_ex__(self, protocol: 'SupportsIndex') -> 'Any':
return _restore_export, (type(self), list(self.items(multi=True)), list(self.__excluded__))
def _restore_export(cls: 'Type[_OrderedMultiDict]', pairs: 'list[tuple[Any, Any]]',
excluded: 'list[str]') -> '_OrderedMultiDict':
"""Unpickle an :class:`_OrderedMultiDict`, with its ``__excluded__``."""
new = cls(pairs)
new.__excluded__ = excluded
return new
def _dump_excluded(info: 'Info') -> 'Collection[str]':
"""Return the names of the keys of ``info`` the dumps leave out.
Args:
info: A field model, i.e. an :class:`Info` that is no multi-mapping.
Returns:
Its :attr:`Info.__excluded__`, less each field it renamed for clashing
with a builtin name. Finalisation adds every such name to
:attr:`Info.__excluded__`, and the dumps see a key under its own name
rather than as renamed, so they would otherwise leave out every renamed
field, a reassembled datagram's ``packet`` among them (:issue:`2733`).
``repr`` and ``str`` see the renamed key, and write the field. A
renamed dunder key stays left out, being metadata.
"""
renamed = info.__map__
if not renamed:
return info.__excluded__
return [name for name in info.__excluded__
if name not in renamed or (name.startswith('__') and name.endswith('__'))]
[docs]
class _MultiInfo(Info[VT], Generic[KT, VT]):
"""An :class:`Info` that holds its values as a multi-mapping.
The common base of :class:`MultiInfo` and :class:`OrderedMultiInfo`. Each
of those also derives from the :mod:`~pcapkit.corekit.multidict` class it
is named after, *after* :class:`Info`, so it is an :class:`Info` --
:func:`isinstance` holds, and it inherits :meth:`Info.__new__`'s
finalisation, :meth:`Info.from_dict`, :meth:`Info.__init_subclass__` and
:attr:`Info.__additional__`/:attr:`Info.__excluded__` -- while its values
live in the multi-mapping rather than in :attr:`~object.__dict__`.
That is the whole carve-out from :class:`Info`, and it is confined to this
class and its subclasses, :class:`MultiInfo` and :class:`OrderedMultiInfo`:
* :meth:`__new__` gives the multi-mapping its empty storage, and
:meth:`__update__` *adds* every pair to it, so that the inherited
:meth:`Info.from_dict` builds one; :func:`info_final` generates no
``__init__`` for such a class (it has no declared fields);
* :meth:`__setattr__` admits the multi-mapping's own bookkeeping, and
only while :meth:`__new__` or :meth:`__update__` is writing;
* every mutator of the multi-mapping raises
:exc:`~pcapkit.utilities.exceptions.UnsupportedCall`, as assignment to
an :class:`Info` does;
* :meth:`to_dict` exports the plain multi-mapping, so that
``cls.from_dict(info.to_dict()) == info``;
* :class:`MultiInfo` and :class:`OrderedMultiInfo` bind the mapping
protocol back to their multi-mapping class, which :class:`Info` and
:class:`~collections.abc.Mapping` precede in the MRO.
A key also reads as an attribute, giving its first value: a :obj:`str` key
by its name, an :class:`~enum.Enum` key by its member name. A name the
class itself defines, such as ``items``, is a method and not a key.
"""
[docs]
def __new__(cls, *args: 'Any', **kwargs: 'Any') -> 'Self': # pylint: disable=unused-argument
"""Create the instance with the empty multi-mapping :meth:`__update__` adds to."""
self = super().__new__(cls)
# NOTE: the multi-mapping's own ``__init__``, after Info and Mapping in the
# MRO, gives the empty storage (and bucket chain) ``__update__`` adds to.
with self.__writing(): # pylint: disable=protected-access
super(Info, self).__init__() # pylint: disable=bad-super-call
return self
def __init__(self, mapping: 'Optional[Mapping[Any, Any] | Iterable[tuple[Any, Any]]]' = None) -> 'None': # pylint: disable=line-too-long,super-init-not-called
self.__update__(mapping)
self.__post_init__()
[docs]
def __update__(self, dict_: 'Optional[Mapping[Any, Any] | Iterable[tuple[Any, Any]]]' = None,
**kwargs: 'Any') -> 'None':
"""Add every pair to the multi-mapping, so that a repeated key keeps every value.
Args:
dict_: Source pairs, as a mapping or an iterable of ``(key, value)`` pairs;
a list value of a mapping counts as one pair, and a
:class:`~pcapkit.corekit.multidict.MultiDict` gives every value of
each key.
**kwargs: More pairs, added after ``dict_``.
"""
# NOTE: every pair is *added*, as the multi-mapping's constructor does: a
# list value is one pair per item, and a MultiDict gives every value.
pairs = () if dict_ is None else iter_multi_items(dict_) # type: Iterable[tuple[Any, Any]]
with self.__writing():
for (key, value) in itertools.chain(pairs, kwargs.items()):
super(Info, self).add(key, value) # type: ignore[misc] # pylint: disable=bad-super-call,no-member
@contextlib.contextmanager
def __writing(self) -> 'Iterator[None]':
"""Admit attribute writes, for the multi-mapping's own bookkeeping."""
object.__setattr__(self, '_MultiInfo__open', True)
try:
yield
finally:
object.__setattr__(self, '_MultiInfo__open', False)
def __getattr__(self, name: 'str') -> 'Any':
# NOTE: only reached for a name that is not a real attribute, so a key
# named like a method is read by indexing instead, as for :class:`Info`.
if not (name.startswith('__') and name.endswith('__')):
if dict.__contains__(self, name):
return self[name]
for key in dict.keys(self): # type: ignore[arg-type,var-annotated]
if isinstance(key, enum.Enum) and key.name == name:
return self[key] # type: ignore[index]
raise UnsupportedCall(f'{type(self).__name__!r} object has no attribute {name!r}',
quiet=True)
[docs]
def __setattr__(self, name: 'str', value: 'Any') -> 'None': # type: ignore[override]
"""Admit an attribute write only while :meth:`__new__` or :meth:`__update__` is writing.
Args:
name: Attribute name.
value: Attribute value.
Raises:
UnsupportedCall: Outside those writes, as assignment to any :class:`Info` does.
"""
if not self.__dict__.get('_MultiInfo__open'):
raise UnsupportedCall("can't set attribute")
object.__setattr__(self, name, value)
def _immutable(self, *args: 'Any', **kwargs: 'Any') -> 'NoReturn':
raise UnsupportedCall(f'{type(self).__name__!r} object is immutable')
__setitem__ = __delitem__ = __ior__ = _immutable
add = setlist = setdefault = setlistdefault = update = _immutable
pop = popitem = poplist = popitemlist = clear = _immutable
__setstate__ = _immutable
[docs]
class MultiInfo(ReduceToItems, _MultiInfo[KT, VT], MultiDict[KT, VT]):
"""An immutable :class:`~pcapkit.corekit.multidict.MultiDict` that is an :class:`Info`.
It holds every value of a key, as a
:class:`~pcapkit.corekit.multidict.MultiDict` does, but refuses every
mutation, as :class:`Info` does, with
:exc:`~pcapkit.utilities.exceptions.UnsupportedCall` (see
:class:`_MultiInfo` for how it is both).
A finalised :class:`Info` holds every
:class:`~pcapkit.corekit.multidict.MultiDict` value as one of these, and
:meth:`to_dict` -- its own and its holder's -- turns it back into a
:class:`~pcapkit.corekit.multidict.MultiDict`.
Args:
mapping: The initial value, as for
:class:`~pcapkit.corekit.multidict.MultiDict`.
.. versionadded:: 1.5.0
"""
# NOTE: :class:`Info` and :class:`~collections.abc.Mapping` precede MultiDict in the MRO, so
# the mapping protocol is bound back to MultiDict's own; ``__eq__`` and ``__ne__`` are below.
__getitem__ = MultiDict.__getitem__ # type: ignore[assignment]
__iter__ = MultiDict.__iter__ # type: ignore[assignment]
__len__ = MultiDict.__len__
__contains__ = MultiDict.__contains__
__reversed__ = MultiDict.__reversed__
__hash__ = None # type: ignore[assignment]
__repr__ = MultiDict.__repr__
__str__ = MultiDict.__str__
__copy__ = MultiDict.__copy__
keys = MultiDict.keys
values = MultiDict.values
items = MultiDict.items # type: ignore[assignment]
get = MultiDict.get
def __eq__(self, other: 'object') -> 'bool':
# NOTE: MultiDict's ``__eq__`` is dict's and disagrees with OrderedMultiDict's, so the
# ordered side decides, as it does for a bare MultiDict, its subclass (#1484).
if isinstance(other, OrderedMultiDict):
return NotImplemented
return MultiDict.__eq__(self, other)
def __ne__(self, other: 'object') -> 'bool':
# NOTE: asks the ordered side's ``__eq__``, since Werkzeug's OrderedMultiDict
# has no ``__ne__`` and dict's compares the internal storage.
if isinstance(other, OrderedMultiDict):
result = other.__eq__(self)
else:
result = MultiDict.__eq__(self, other)
if result is NotImplemented:
return result
return not result
[docs]
def to_dict(self, flat: 'Optional[bool]' = None) -> 'Any':
"""Return the contents as a plain mapping.
Args:
flat: If omitted, a plain :class:`~pcapkit.corekit.multidict.MultiDict`
of every value, as :meth:`Info.to_dict` writes this object.
Otherwise :meth:`MultiDict.to_dict
<pcapkit.corekit.multidict.MultiDict.to_dict>`'s :obj:`dict`, of
the first value of each key if :obj:`True`, or of the
:obj:`list` of them if :obj:`False`.
"""
if flat is None:
return MultiDict(self)
return MultiDict.to_dict(self, flat) # type: ignore[call-overload]
def listvalues(self) -> 'Iterator[list[VT]]':
"""Return an iterator of the :obj:`list` of all values of each key.
Each :obj:`list` is a copy: :meth:`MultiDict.listvalues
<pcapkit.corekit.multidict.MultiDict.listvalues>` hands out the lists it
keeps the values in, through which they could be changed.
"""
return (values for (_, values) in self.lists())
[docs]
class OrderedMultiInfo(_MultiInfo[KT, VT], OrderedMultiDict[KT, VT]):
"""An immutable :class:`~pcapkit.corekit.multidict.OrderedMultiDict` that is an :class:`Info`.
As :class:`MultiInfo`, but keeping the order of every value across keys, as
an :class:`~pcapkit.corekit.multidict.OrderedMultiDict` does -- i.e. wire
order for an option list.
A finalised :class:`Info` holds every
:class:`~pcapkit.corekit.multidict.OrderedMultiDict` value as one of these,
and :meth:`to_dict` -- its own and its holder's -- turns it back into an
:class:`~pcapkit.corekit.multidict.OrderedMultiDict`.
Args:
mapping: The initial value, as for
:class:`~pcapkit.corekit.multidict.OrderedMultiDict`.
.. versionadded:: 1.5.0
"""
# NOTE: As for :class:`MultiInfo`, the mapping protocol is OrderedMultiDict's own; ``__ne__``
# is :func:`_ne`, since dict's compares the internal buckets (#1484).
__getitem__ = OrderedMultiDict.__getitem__ # type: ignore[assignment]
__iter__ = OrderedMultiDict.__iter__ # type: ignore[assignment]
__len__ = OrderedMultiDict.__len__
__contains__ = OrderedMultiDict.__contains__
__reversed__ = OrderedMultiDict.__reversed__
__eq__ = OrderedMultiDict.__eq__
__ne__ = _ne
__hash__ = None
__repr__ = OrderedMultiDict.__repr__
__str__ = OrderedMultiDict.__str__
__copy__ = OrderedMultiDict.__copy__
keys = OrderedMultiDict.keys
values = OrderedMultiDict.values
items = OrderedMultiDict.items # type: ignore[assignment]
get = OrderedMultiDict.get # type: ignore[assignment]
[docs]
def to_dict(self, flat: 'Optional[bool]' = None) -> 'Any':
"""Return the contents as a plain mapping.
Args:
flat: If omitted, a plain
:class:`~pcapkit.corekit.multidict.OrderedMultiDict` of every
value, in order, as :meth:`Info.to_dict` writes this object --
the same :class:`_OrderedMultiDict` it returns.
Otherwise :meth:`MultiDict.to_dict
<pcapkit.corekit.multidict.MultiDict.to_dict>`'s :obj:`dict`, of
the first value of each key if :obj:`True`, or of the
:obj:`list` of them if :obj:`False`.
"""
if flat is None:
return _OrderedMultiDict(self)
return OrderedMultiDict.to_dict(self, flat) # type: ignore[call-overload]
def _freeze(value: 'Any') -> 'Any':
"""Return ``value`` as stored in a finalised :class:`Info`.
A :class:`~pcapkit.corekit.multidict.OrderedMultiDict` becomes an
:class:`OrderedMultiInfo`, and any other
:class:`~pcapkit.corekit.multidict.MultiDict` a :class:`MultiInfo`. Either
already immutable, and every other value, is returned as it is.
"""
if isinstance(value, _MultiInfo) or not isinstance(value, MultiDict):
return value
if isinstance(value, OrderedMultiDict):
return OrderedMultiInfo(value)
return MultiInfo(value)
def _rebuild(info_cls: 'Type[Info]', value: 'Any') -> 'Any':
"""Rebuild ``value`` into ``info_cls``, the class annotated for its key, unless it is one.
A multi-mapping counts as a plain mapping here, even as the
:class:`OrderedMultiInfo` that :meth:`Info.__update__` stored an exported
:class:`Info` as.
"""
if issubclass(info_cls, _MultiInfo):
if isinstance(value, MultiDict) and type(value) is not info_cls: # type: ignore[comparison-overlap,unreachable] # pylint: disable=unidiomatic-typecheck
return info_cls(value)
elif isinstance(value, Mapping) and (not isinstance(value, Info) or isinstance(value, _MultiInfo)):
return info_cls.from_dict(value)
return value
#: Name of the per-class attribute :func:`_nested_info_types` caches its answer in.
_NESTED_INFO_CACHE = '__nested_info_types__'
#: Name of the per-class attribute :func:`_nested_types` caches its answer in.
_NESTED_CACHE = '__nested_types__'
#: Type of :data:`None`, i.e. the member ``Optional`` adds to a union.
_NONE_TYPE = type(None)
def _as_info_type(hint: 'Any') -> 'Optional[Type[Info]]':
"""Return the :class:`Info` subclass a resolved annotation names, if any.
:data:`~typing.Optional` of a single :class:`Info` subclass counts as that
subclass. A parametrised generic such as ``list[Packet]`` does not: the
value it describes is a container, and :meth:`Info.from_dict` has nothing
to rebuild it from. A :class:`MultiInfo` or :class:`OrderedMultiInfo`
subclass is an :class:`Info` subclass too, so that :meth:`Info.from_dict`
restores e.g. :class:`~pcapkit.protocols.application.httpv2.data.Settings`
from the :class:`~pcapkit.corekit.multidict.OrderedMultiDict` that
:meth:`Info.to_dict` writes it as.
Args:
hint: Resolved type annotation.
Returns:
The class the annotation names, or :data:`None`.
Note:
Both guards below are load-bearing on Python 3.9 and 3.10, where a
:class:`types.GenericAlias` such as ``list[Packet]`` satisfies
``isinstance(hint, type)`` while :func:`issubclass` still refuses it
with :exc:`TypeError` -- which would otherwise escape
:meth:`Info.from_dict` for any :class:`Info` class that carries such an
annotation. From 3.11 on the ``isinstance`` is already
:data:`False`, so only the older interpreters reach the
:exc:`TypeError`; ``NestedTypeResolverTests`` in
:file:`tests/corekit/test_from_dict_roundtrip_unit.py` pins the shape
on every version by handing this function an object that mimics it.
"""
origin = typing.get_origin(hint)
if origin is not None or not isinstance(hint, type):
# NOTE: a union of exactly one non-``None`` member is ``Optional`` of
# that member, so it unwraps to it; anything else is not an ``Info``.
args = [arg for arg in getattr(hint, '__args__', ()) if arg is not _NONE_TYPE]
is_union = origin is typing.Union or (UnionType is not None and origin is UnionType)
hint = args[0] if is_union and len(args) == 1 else None
if not isinstance(hint, type):
return None
try:
return hint if issubclass(hint, Info) else None
except TypeError:
return None
def _nested_info_types(cls: 'Type[Info]') -> 'dict[str, Type[Info]]':
"""Map the keys of ``cls`` annotated with an :class:`Info` subclass to it.
It is :func:`_nested_types` less the multi-mapping classes
(:class:`_MultiInfo`), and cached alongside.
Args:
cls: Info class.
Returns:
Mapping of key names to the :class:`Info` subclass annotated for them.
"""
cached = cls.__dict__.get(_NESTED_INFO_CACHE)
if cached is not None:
return cached
nested = {key: value for (key, value) in _nested_types(cls).items()
if not issubclass(value, _MultiInfo)} # type: dict[str, Type[Info]]
setattr(cls, _NESTED_INFO_CACHE, nested)
return nested
def _nested_types(cls: 'Type[Info]') -> 'dict[str, Type[Info]]':
"""Map the keys of ``cls`` annotated with a class :meth:`Info.from_dict` rebuilds to it.
Such a class is an :class:`Info`, :class:`MultiInfo` or
:class:`OrderedMultiInfo` subclass, as :func:`_as_info_type` reads it.
Each annotation is resolved on its own, in the namespace of the module and
the class that declared it, so one that cannot be resolved at runtime (e.g.
a name imported only under :data:`~typing.TYPE_CHECKING`) is skipped rather
than spoiling the rest.
Args:
cls: Info class.
Returns:
Mapping of key names to the class annotated for them.
Note:
The answer is cached in ``cls``'s own ``__dict__``, read back from
there rather than through :func:`getattr` so a subclass never inherits
its parent's map. The cache is deliberately kept on the class rather
than in a module-level :class:`weakref.WeakKeyDictionary`: such a map
would pin every self-referential class forever, since the cached value
strongly references the class the weak key points at, whereas the
reference cycle a class attribute creates is collectable.
An unresolvable annotation is cached as "skip" alongside the rest, so a
forward reference whose target is defined only *after* the first
:meth:`Info.from_dict` call stays one-way for the life of the process.
That is accepted rather than fixed: :meth:`Info.from_dict` runs per
parsed packet (see :meth:`IPv4.read
<pcapkit.protocols.internet.ipv4.protocol.IPv4.read>`, which builds its ToS
field with it), and
re-resolving the annotations on every call to catch a case that does
not arise in this package costs far more than it saves.
"""
cached = cls.__dict__.get(_NESTED_CACHE)
if cached is not None:
return cached
nested = {} # type: dict[str, Type[Info]]
seen = set() # type: set[str]
for cls_ in cls.mro(): # pragma: no branch
# NOTE: same walk as :func:`info_final`, which builds ``__init__``
# from these annotations; the most derived declaration of a key wins.
if cls_ is Info:
break
module = sys.modules.get(cls_.__module__)
globalns = dict(vars(module)) if module is not None else {}
localns = dict(vars(cls_))
for (key, ann) in get_annotations(cls_).items():
if key in seen:
continue
seen.add(key)
try:
hint = typing.get_type_hints(types.SimpleNamespace(__annotations__={key: ann}),
globalns, localns)[key]
except Exception: # pylint: disable=broad-except # nosec: B112 # unresolvable: not nested
continue
info_cls = _as_info_type(hint)
if info_cls is not None:
nested[key] = info_cls
setattr(cls, _NESTED_CACHE, nested)
return nested