Source code for pcapkit.corekit.infoclass

# -*- 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 InfoMeta(abc.ABCMeta): """Meta class to add dynamic support to :class:`Info`. * :attr:`Info.__additional__`, :attr:`Info.__excluded__` and :attr:`Info.__included__` list the field names treated as built-in names, left out of ``repr`` and the dumps, and carried as metadata by :meth:`Info.to_dict`, respectively. .. note:: :meth:`__new__` appends each base's names to the subclass's own: .. code-block:: python class A(Info): __additional__ = ['a', 'b'] class B(A): __additional__ = ['c', 'd'] class C(B): __additional__ = ['e', 'f'] print(A.__additional__) # ['a', 'b'] print(B.__additional__) # ['c', 'd', 'a', 'b'] print(C.__additional__) # ['e', 'f', 'c', 'd', 'a', 'b'] """
[docs] def __new__(cls, name: 'str', bases: 'tuple[type, ...]', attrs: 'dict[str, Any]', **kwargs: 'Any') -> 'Type[Info]': """Create the class, extending its :attr:`Info.__additional__`, :attr:`Info.__excluded__` and :attr:`Info.__included__` with every base's. Args: name: Class name. bases: Base classes. attrs: Class namespace. **kwargs: Arbitrary keyword arguments, passed on to :class:`type`. """ if '__additional__' not in attrs: attrs['__additional__'] = [] if '__excluded__' not in attrs: attrs['__excluded__'] = [] if '__included__' not in attrs: attrs['__included__'] = [] for base in bases: if hasattr(base, '__additional__'): attrs['__additional__'].extend( name for name in base.__additional__ if name not in attrs['__additional__']) if hasattr(base, '__excluded__'): attrs['__excluded__'].extend(name for name in base.__excluded__ if name not in attrs['__excluded__']) if hasattr(base, '__included__'): attrs['__included__'].extend(name for name in base.__included__ if name not in attrs['__included__']) return super().__new__(cls, name, bases, attrs, **kwargs) # type: ignore[return-value]
[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