# -*- coding: utf-8 -*-
"""numerical field class"""
import enum
import functools
import math
from typing import TYPE_CHECKING, Generic, TypeVar, Union, cast
import aenum
from pcapkit.corekit.fields.field import Field, NoValue
from pcapkit.utilities.exceptions import BaseError, FieldValueError, IntError, ProtocolError
__all__ = [
'NumberField',
'Int32Field', 'UInt32Field',
'Int16Field', 'UInt16Field',
'Int64Field', 'UInt64Field',
'Int8Field', 'UInt8Field',
'EnumField',
]
if TYPE_CHECKING:
from enum import IntEnum as StdlibEnum
from typing import Any, Callable, Optional, Type
from aenum import IntEnum as AenumEnum
from typing_extensions import Literal, Self
from pcapkit.corekit.fields.field import NoValueType
_T = TypeVar('_T', bound='int')
[docs]
class NumberField(Field[int], Generic[_T]):
"""Numerical value for protocol fields.
Args:
length: Field size (in bytes); if a callable is given, it should return
an integer value and accept the current packet as its only argument.
default: Field default value, if any.
signed: Whether the field is signed; :data:`None` defers to the
class-level ``__signed__``, which this class leaves unset and so
means unsigned.
byteorder: Field byte order.
bit_length: Field bit length.
callback: Callback function to be called upon
:meth:`self.__call__ <pcapkit.corekit.fields.field.FieldBase.__call__>`.
Raises:
IntError: If no ``length`` is given and ``__length__`` fixes none either.
FieldValueError: If ``signed`` contradicts a sign already fixed by
``__signed__`` -- never from this class, which fixes none.
ProtocolError: If ``bit_length`` is given negative. Left alone,
``(1 << bit_length) - 1`` raises a bare, uncatchable
:exc:`ValueError` (``negative shift count``) here, before
:meth:`__call__`'s own negative-``length`` guard (#828/#829) or
:attr:`~pcapkit.corekit.fields.field.FieldBase.length`'s (#805)
ever see anything -- this one fires at construction time, on the
argument itself rather than on a resolved wire length. See
GitHub issue #831.
Notes:
A subclass such as :class:`UInt32Field` fixes the sign through
``__signed__``, so ``signed`` there is at best redundant. It used to be
discarded outright, in both directions, which meant
``UInt32Field(signed=True)`` handed back an unsigned field whose values
only looked wrong once the high bit was set -- see GitHub issue #545. A
contradicting value is now rejected instead; omitting it, or passing the
sign the class already fixes, stays legal.
"""
__length__ = None # type: Optional[int]
__template__ = None # type: Optional[str]
__signed__ = None # type: Optional[bool]
@property
def bit_length(self) -> 'int':
"""Field bit length."""
return self._bit_length
def __init__(self, length: 'Optional[int | Callable[[dict[str, Any]], int]]' = None,
default: 'int | NoValueType' = NoValue, signed: 'Optional[bool]' = None,
byteorder: 'Literal["little", "big"]' = 'big',
bit_length: 'Optional[int]' = None,
callback: 'Callable[[Self, dict[str, Any]], None]' = lambda *_: None) -> 'None':
if length is None:
if self.__length__ is None:
raise IntError(f'Field has no length.')
length = self.__length__
super().__init__(length, default, callback)
if bit_length is not None:
if bit_length < 0:
raise ProtocolError(
f'Field {self.name} resolved to a negative length; '
f'bit_length={bit_length!r}'
)
self._bit_length = bit_length
self._bit_mask = (1 << bit_length) - 1
else:
self._bit_length, self._bit_mask = -1, -1
# NOTE: ``__signed__`` fixes the sign for a subclass such as
# :class:`UInt32Field`, and used to *discard* the ``signed`` argument to
# do it -- in both directions, so ``UInt32Field(signed=True)`` returned
# an unsigned field and ``Int8Field(signed=False)`` a signed one, both
# without a word. ``None`` is what "not given" looks like, which is what
# lets a contradicting value be told apart from the default and rejected
# while leaving an agreeing one alone. See #545.
if self.__signed__ is None:
self._signed = False if signed is None else bool(signed)
elif signed is None or bool(signed) == self.__signed__:
self._signed = self.__signed__
else:
raise FieldValueError(
f'{type(self).__name__}: field is fixed as '
f'{"signed" if self.__signed__ else "unsigned"}, '
f'but signed={signed!r} was given'
)
self._byteorder = byteorder
self._need_process = False
endian = '>' if byteorder == 'big' else '<'
if self.__template__ is not None:
struct_fmt = self.__template__
else:
# NOTE: ``self._signed``, not the ``signed`` argument. A subclass
# that fixes ``__signed__`` without also fixing ``__template__``
# would otherwise build its template from the argument and parse
# with the opposite sign to the one it declared.
struct_fmt = self.build_template(self._length, self._signed)
self._template = f'{endian}{struct_fmt}'
[docs]
def __call__(self, packet: 'dict[str, Any]') -> 'Self':
"""Update field attributes.
Args:
packet: Packet data.
Returns:
New instance of :class:`NumberField`.
This method will return a new instance of :class:`NumberField` instead of
updating the current instance.
Raises:
ProtocolError: If the resolved ``length`` is negative -- e.g. a
``length`` callback such as ``lambda pkt: pkt['len'] - 4``
resolving below zero once the wire value it reads is smaller
than the subtrahend. Left alone, ``1 << (length * 8)`` raises
a bare, uncatchable :exc:`ValueError` (``negative shift
count``) when ``bit_length`` was not supplied, before
:attr:`~pcapkit.corekit.fields.field.FieldBase.length` (see
its own :exc:`ProtocolError` guard, #805/#811/#827) or
:meth:`build_template` ever sees the value: this method sets
``self._bit_length`` from the resolved length eagerly, as a
cache, and shifts by it immediately, so the crash happens on
*this* line rather than on the later, already-guarded ones.
See GitHub issue #828. This guard runs regardless of whether
``bit_length`` was supplied, so a field constructed with a
fixed ``bit_length`` *and* a callable ``length`` that resolves
negative raises the identical message as one with no
``bit_length`` at all, rather than falling through to a
``template='...-1s'`` :exc:`ProtocolError` from
:attr:`~pcapkit.corekit.fields.field.FieldBase.length` later --
see GitHub issue #831. A resolved length of exactly ``0`` is a
legitimate empty field (e.g. ``len=4`` above resolving to
``0``) and is left alone.
Notes:
Rebuilding the template here is what applies a callable ``length``,
and :meth:`build_template` recomputes ``self._need_process`` as it
goes, so the flag and the template always describe the same width.
They did not always: see GitHub issue #591.
"""
new_self = super().__call__(packet)
if new_self._length < 0:
raise ProtocolError(
f'Field {new_self.name} resolved to a negative length; '
f'length={new_self._length!r}'
)
if new_self._bit_length < 0:
new_self._bit_length = new_self._length * 8
new_self._bit_mask = (1 << new_self._bit_length) - 1
endian = '>' if new_self._byteorder == 'big' else '<'
struct_fmt = new_self.build_template(new_self._length, new_self._signed)
new_self._template = f'{endian}{struct_fmt}'
return new_self
[docs]
def build_template(self, length: 'int', signed: 'bool') -> 'str':
"""Build template for field.
Arguments:
length: Field size (in bytes)
signed: Whether the field is signed
Returns:
Template for field.
Notes:
``self._need_process`` is **assigned** here rather than only ever
raised, so that it always describes the ``length`` this template
was built for. It used to be
set :data:`True` in the fall-through branch and never put back,
which made it a latch: a callable ``length`` is a placeholder of
``-1`` at construction, ``-1`` takes the fall-through branch, and
the flag then survived the rebuild in :meth:`__call__` that
resolved the real width. :meth:`pre_process` consequently handed
:obj:`bytes` to a template that had become ``>Q`` -- or ``>I``,
``>H``, ``>B`` -- and :func:`struct.pack` refused it. See GitHub
issue #591.
Assigning it is what tells a placeholder apart from a width that
genuinely needs byte packing, without having to remember that a
placeholder was ever in play: the answer for ``-1`` is
:data:`True`, the answer for ``8`` is :data:`False`, and whichever
width is in force now is the one that decides. A callable
resolving to, say, ``3`` still takes the fall-through branch and
still gets :data:`True`, because for ``3`` that is the correct
answer rather than a leftover one.
"""
if length == 8: # unpack to 8-byte integer (long long)
struct_fmt, need_process = 'q' if signed else 'Q', False
elif length == 4: # unpack to 4-byte integer (int / long)
struct_fmt, need_process = 'i' if signed else 'I', False
elif length == 2: # unpack to 2-byte integer (short)
struct_fmt, need_process = 'h' if signed else 'H', False
elif length == 1: # unpack to 1-byte integer (char)
struct_fmt, need_process = 'b' if signed else 'B', False
else: # do not unpack
struct_fmt, need_process = f'{length}s', True
self._need_process = need_process
return struct_fmt
[docs]
def pre_process(self, value: 'int', packet: 'dict[str, Any]') -> 'int | bytes': # pylint: disable=unused-argument
"""Process field value before construction (packing).
Arguments:
value: Field value.
packet: Packet data.
Returns:
Processed field value.
Notes:
Masking against :attr:`self._bit_mask <NumberField.bit_length>`
truncates the value to the field's bit length, but it also turns a
negative value into its unsigned two's-complement pattern, which
neither :func:`struct.pack` nor :meth:`int.to_bytes` accepts for a
signed field. A signed field therefore maps the pattern back into
its signed range afterwards, so that e.g. a PCAP-NG section length
of ``-1`` (section length not specified) can be written out.
A field packed without having been resolved -- so with ``_length``
still negative -- has its width derived from the value instead, and
that rebuild can land on a width :func:`struct` has a native
integer code for. The flag is therefore consulted **after** the
rebuild rather than before it, since deciding first and rebuilding
second is how the template and the value being returned came to
disagree in the first place. C.f. #591.
That width is a **ceiling** of the bit length over eight, and it is
written as one. It used to read
``math.ceil(value.bit_length() // 8)``, which is not a ceiling at
all: :func:`math.ceil` of an :obj:`int` is that :obj:`int`, so the
``//`` had already floored the quotient and the outer call did
nothing. Every value whose bit length is not an exact multiple of
eight was therefore sized one octet short -- ``256`` at one octet,
``65536`` at two, and ``1`` itself at *zero* -- which
:meth:`int.to_bytes` and :func:`struct.pack` both refuse. See GitHub
issue #599.
"""
value = value & self._bit_mask
if self._signed and value > self._bit_mask >> 1:
value -= self._bit_mask + 1
if self._need_process and self._length < 0:
self._length = math.ceil(value.bit_length() / 8)
endian = '>' if self._byteorder == 'big' else '<'
struct_fmt = self.build_template(self._length, self._signed)
self._template = f'{endian}{struct_fmt}'
if not self._need_process:
return value
return value.to_bytes(
self._length, self._byteorder, signed=self._signed
)
[docs]
def post_process(self, value: 'int | bytes', packet: 'dict[str, Any]') -> 'int': # pylint: disable=unused-argument
"""Process field value after parsing (unpacked).
Args:
value: Field value.
packet: Packet data.
Returns:
Processed field value.
"""
if not self._need_process:
return cast('int', value) & self._bit_mask
return int.from_bytes(
cast('bytes', value), self._byteorder, signed=self._signed
) & self._bit_mask
[docs]
class Int32Field(NumberField):
"""Integer value for protocol fields.
Args:
length: Field size (in bytes).
default: Field default value, if any.
signed: Whether the field is signed; fixed as :data:`True` here, so a
contradicting :data:`False` is rejected rather than ignored.
byteorder: Field byte order.
bit_length: Field bit length.
callback: Callback function to be called upon
:meth:`self.__call__ <pcapkit.corekit.fields.field.FieldBase.__call__>`.
Raises:
FieldValueError: If ``signed`` is given as :data:`False`, contradicting
the sign this class fixes.
"""
__length__ = 4
__template__ = 'i'
__signed__ = True
[docs]
class UInt32Field(NumberField):
"""Unsigned integer value for protocol fields.
Args:
length: Field size (in bytes).
default: Field default value, if any.
signed: Whether the field is signed; fixed as :data:`False` here, so a
contradicting :data:`True` is rejected rather than ignored.
byteorder: Field byte order.
bit_length: Field bit length.
callback: Callback function to be called upon
:meth:`self.__call__ <pcapkit.corekit.fields.field.FieldBase.__call__>`.
Raises:
FieldValueError: If ``signed`` is given as :data:`True`, contradicting
the sign this class fixes.
"""
__length__ = 4
__template__ = 'I'
__signed__ = False
[docs]
class Int16Field(NumberField):
"""Short integer value for protocol fields.
Args:
length: Field size (in bytes).
default: Field default value, if any.
signed: Whether the field is signed; fixed as :data:`True` here, so a
contradicting :data:`False` is rejected rather than ignored.
byteorder: Field byte order.
bit_length: Field bit length.
callback: Callback function to be called upon
:meth:`self.__call__ <pcapkit.corekit.fields.field.FieldBase.__call__>`.
Raises:
FieldValueError: If ``signed`` is given as :data:`False`, contradicting
the sign this class fixes.
"""
__length__ = 2
__template__ = 'h'
__signed__ = True
[docs]
class UInt16Field(NumberField):
"""Unsigned short integer value for protocol fields.
Args:
length: Field size (in bytes).
default: Field default value, if any.
signed: Whether the field is signed; fixed as :data:`False` here, so a
contradicting :data:`True` is rejected rather than ignored.
byteorder: Field byte order.
bit_length: Field bit length.
callback: Callback function to be called upon
:meth:`self.__call__ <pcapkit.corekit.fields.field.FieldBase.__call__>`.
Raises:
FieldValueError: If ``signed`` is given as :data:`True`, contradicting
the sign this class fixes.
"""
__length__ = 2
__template__ = 'H'
__signed__ = False
[docs]
class Int64Field(NumberField):
"""Long integer value for protocol fields.
Args:
length: Field size (in bytes).
default: Field default value, if any.
signed: Whether the field is signed; fixed as :data:`True` here, so a
contradicting :data:`False` is rejected rather than ignored.
byteorder: Field byte order.
bit_length: Field bit length.
callback: Callback function to be called upon
:meth:`self.__call__ <pcapkit.corekit.fields.field.FieldBase.__call__>`.
Raises:
FieldValueError: If ``signed`` is given as :data:`False`, contradicting
the sign this class fixes.
"""
__length__ = 8
__template__ = 'q'
__signed__ = True
[docs]
class UInt64Field(NumberField):
"""Unsigned long integer value for protocol fields.
Args:
length: Field size (in bytes).
default: Field default value, if any.
signed: Whether the field is signed; fixed as :data:`False` here, so a
contradicting :data:`True` is rejected rather than ignored.
byteorder: Field byte order.
bit_length: Field bit length.
callback: Callback function to be called upon
:meth:`self.__call__ <pcapkit.corekit.fields.field.FieldBase.__call__>`.
Raises:
FieldValueError: If ``signed`` is given as :data:`True`, contradicting
the sign this class fixes.
"""
__length__ = 8
__template__ = 'Q'
__signed__ = False
[docs]
class Int8Field(NumberField):
"""Byte value for protocol fields.
Args:
length: Field size (in bytes).
default: Field default value, if any.
signed: Whether the field is signed; fixed as :data:`True` here, so a
contradicting :data:`False` is rejected rather than ignored.
byteorder: Field byte order.
bit_length: Field bit length.
callback: Callback function to be called upon
:meth:`self.__call__ <pcapkit.corekit.fields.field.FieldBase.__call__>`.
Raises:
FieldValueError: If ``signed`` is given as :data:`False`, contradicting
the sign this class fixes.
"""
__length__ = 1
__template__ = 'b'
__signed__ = True
[docs]
class UInt8Field(NumberField):
"""Unsigned byte value for protocol fields.
Args:
length: Field size (in bytes).
default: Field default value, if any.
signed: Whether the field is signed; fixed as :data:`False` here, so a
contradicting :data:`True` is rejected rather than ignored.
byteorder: Field byte order.
bit_length: Field bit length.
callback: Callback function to be called upon
:meth:`self.__call__ <pcapkit.corekit.fields.field.FieldBase.__call__>`.
Raises:
FieldValueError: If ``signed`` is given as :data:`True`, contradicting
the sign this class fixes.
"""
__length__ = 1
__template__ = 'B'
__signed__ = False
[docs]
class EnumField(NumberField[Union[enum.IntEnum, aenum.IntEnum]]):
"""Enumerated value for protocol fields.
Args:
length: Field size (in bytes); if a callable is given, it should return
an integer value and accept the current packet as its only argument.
default: Field default value, if any.
signed: Whether the field is signed; :data:`None` defers to the
class-level ``__signed__``, which this class leaves unset and so
means unsigned.
byteorder: Field byte order.
bit_length: Field bit length.
namespace: Field namespace (a :class:`enum.IntEnum` class).
callback: Callback function to be called upon
:meth:`self.__call__ <pcapkit.corekit.fields.field.FieldBase.__call__>`.
Notes:
A wire value the ``namespace`` registry has no member for resolves to a
nameless pseudo-member rather than failing the parse -- see
:meth:`post_process`.
"""
def __init__(self, length: 'int | Callable[[dict[str, Any]], int]',
default: 'StdlibEnum | AenumEnum | NoValueType' = NoValue,
signed: 'Optional[bool]' = None,
byteorder: 'Literal["little", "big"]' = 'big',
bit_length: 'Optional[int]' = None,
namespace: 'Optional[Type[StdlibEnum] | Type[AenumEnum]]' = None,
callback: 'Callable[[Self, dict[str, Any]], None]' = lambda *_: None) -> 'None':
super().__init__(length, default, signed, byteorder, bit_length, callback)
self._namespace = namespace
[docs]
def post_process(self, value: 'int | bytes', packet: 'dict[str, Any]') -> 'StdlibEnum | AenumEnum':
"""Process field value after parsing (unpacked).
Args:
value: Field value.
packet: Packet data.
Returns:
Processed field value -- the registry member declared for the value,
or a nameless pseudo-member carrying the value itself when the
registry declares none.
Raises:
BaseError: Whatever in-library error the registry raised for the
value, re-raised untouched.
Notes:
The registry is consulted through its constructor, which raises for
a value no member and no ``_missing_`` rule accounts for. That raise
used to propagate, and it is :mod:`aenum`'s own bare
:exc:`ValueError`: not one of :mod:`pcapkit.utilities.exceptions`,
so a caller cannot tell it from a bug of its own, and not an
:exc:`EOFError`, so :meth:`Extractor.record_frames
<pcapkit.foundation.extraction.Extractor.record_frames>` does not
catch it. One unassigned code therefore cost the whole extraction.
It also made the "unknown" reader the formats require unreachable
for any genuinely unassigned code -- PCAP-NG's
:class:`~pcapkit.protocols.schema.misc.pcapng.UnknownBlock`, and the
``unassigned`` option readers of IPv4, TCP, HOPOPT, MH and HIP --
because the lookup failed several frames before the dispatch that
would have selected it. PCAP-NG repeats a block's total length at
both ends precisely so that a reader can skip a block type it does
not recognise; that skip is what this fallback restores. See GitHub
issue #701.
The fallback is the same nameless pseudo-member this method already
builds for a field carrying no registry at all, so it is a value
shape the package already produces and the dump layer already
renders -- as ``<unknown>::<unassigned> [28]``, through
:func:`~pcapkit.dumpkit.common.render_enum`, not through the
``name is None`` branch #648 added, which a member named
``<unassigned>`` never takes -- and one an :class:`int`-keyed
dispatch registry looks up by value like any declared member. It is
built per value rather than grafted onto
the registry with :func:`aenum.extend_enum`, for two reasons: a
capture carrying many distinct unassigned codes would otherwise grow
a process-global registry without bound, which is the growth
:meth:`ProtocolBase._lookup_registry
<pcapkit.protocols.protocol.ProtocolBase._lookup_registry>` exists
to avoid; and a stdlib :class:`enum.IntEnum` registry is then
handled exactly like an :class:`aenum.IntEnum` one.
Only a *foreign* rejection is absorbed. A registry rejecting a value
with one of :mod:`pcapkit.utilities.exceptions` has made a
deliberate decision that this layer -- which sees only that the value
arrived in a field of some width -- is in no position to overrule, so
an in-library error propagates unchanged and it is only :mod:`aenum`'s
and :mod:`enum`'s "no member has this value" that becomes a
pseudo-member. That is what keeps the fallback from being an
unconditional ``except ValueError: pass``.
No registry under :mod:`pcapkit.const` raises an in-library error
from its guard today, and deliberately so: a generated guard raises a
bare, unlogged :exc:`ValueError` precisely because the generated
``get()``'s ``except ValueError`` fallback has to keep catching it
(GitHub issues #584 and #647). The registries that *do* bound
themselves to a width and reject outside it are the bit-flag ones --
:class:`pcapkit.const.tcp.flags.Flags` among them -- and none of
those is named as the namespace of a plain :class:`EnumField`
anywhere in the package, so no in-library guard loses its force
through this method. The distinction is therefore for a registry
registered from outside :mod:`pcapkit.const`, which has no such
obligation to stay quiet.
"""
value = super().post_process(value, packet)
if self._namespace is not None:
try:
return self._namespace(value)
except ValueError as error:
# NOTE: An in-library rejection is pcapkit's own decision about
# the value, rather than the enumeration library reporting that
# no member carries it, so it is not this layer's to absorb.
if isinstance(error, BaseError):
raise
return self._pseudo_member(value)
[docs]
def _pseudo_member(self, value: 'int') -> 'StdlibEnum | AenumEnum':
"""Build the bounded nameless pseudo-member this method falls back to
when ``value`` is a foreign miss rather than an in-library rejection.
Returns:
A single-member, throwaway :class:`enum.IntEnum` instance, built
fresh per call rather than :func:`~aenum.extend_enum`-ed onto
``self._namespace``, per this method's own docstring above.
"""
unknown = enum.IntEnum('<unknown>', {
'<unassigned>': value,
}, module='pcapkit.const', qualname='pcapkit.const.<unknown>')
return getattr(unknown, '<unassigned>')
[docs]
@staticmethod
def _unregistered_member(namespace: 'Type[StdlibEnum] | Type[AenumEnum]',
value: 'Any', name: 'str' = '<unassigned>',
**attrs: 'Any') -> 'Any':
"""Build a member of ``namespace``, absent from every one of its own
lookup tables, for a value a *parse* -- rather than a direct call to
the registry's own ``get()`` -- resolved without anyone asking for a
name.
GitHub issue #575: the owner's ruling is that an unassigned wire value
should resolve to a real member of the registry the field names --
``isinstance`` against it and every ancestor holds, and it renders and
dispatches exactly like a declared one -- provided building it never
grows the registry, which is the whole reason the field stopped
calling ``get()`` unconditionally in the first place. This is what
gets there: it calls ``namespace``'s own storage base's ``__new__``
directly -- :class:`str` or :class:`int`, whichever ``namespace``
derives from -- which skips ``namespace``'s *own* ``__new__``
entirely, and with it the ``cls.__registry__.add(...)`` /
``cls.__members_ns__[...] = ...`` line every registry in this package
uses to record a member it mints. No entry is added to
``_member_map_`` or ``_value2member_map_`` either, since those are
only ever touched by the metaclass machinery :func:`aenum.extend_enum`
drives, which this bypasses completely.
Note:
Building a member this way, rather than as some other type
altogether, is why :meth:`~pcapkit.protocols.schema.transport.tcp.PortEnumField.post_process`
and its siblings need this rather than :meth:`_pseudo_member`:
the result answers ``isinstance(result, AppType)`` truthfully,
which matters to at least seven ``isinstance`` sites elsewhere in
:mod:`pcapkit.protocols` (see ``test_a_member_is_still_an_apptype``
in ``tests/const/test_const_apptype_split_unit.py``), and a value
that fails all of them would be a second defect standing in for
the one this fix removes.
The member this returns is absent from ``_value2member_map_``, so
a *value*-keyed lookup on it -- ``self._namespace(value)`` --
still raises exactly as it did before this existed. Nothing on the
parse or reconstruction path does that to a value it just resolved
this way, which is what keeps this safe to return from
``post_process``. A direct call to
:meth:`~pcapkit.const.reg.apptype.AppType.get` for the same port
is a different matter and deliberately unchanged: asking the
registry for a name is an explicit request for a named member, so
it still mints one -- measured on this tree,
``AppType.get(54321, proto=tcp)`` returns ``PORT_54321_tcp`` and
takes ``TCP.__members__`` from 6147 to 6148, and a second call
with the same port returns that member rather than raising. Two
unregistered members for the same value also compare equal
without being identical, since
:class:`~pcapkit.const.reg.apptype.AppType` and
:class:`~pcapkit.const.pcapng.option_type.OptionType` both define
``__eq__``/``__hash__`` off an attribute (``.port`` /
``.opt_value``) rather than object identity -- harmless for every
reader in this package, since none compares one with ``is`` or
keys a mapping on it expecting identity, but worth knowing before
reusing this elsewhere.
:mod:`pickle` and :func:`copy.copy`/:func:`copy.deepcopy` all
reduce an :class:`~enum.Enum` member through
``Enum.__reduce_ex__``, which returns ``(cls, (value,))`` -- the
one lookup this member is deliberately absent from. Left alone
that is a genuine regression rather than a pre-existing
limitation, because the call sites used to *mint*, so the member
was registered and a round-trip worked. Measured on CPython
3.14.7, resolving port 53406 through
:class:`~pcapkit.protocols.schema.transport.tcp.PortEnumField`:
on ``83b58ebda`` ``pickle.loads(pickle.dumps(member))`` returned
the member, and with the mint removed and nothing in its place it
raised ``ValueError: 'unknown [53406 - tcp]' is not a valid TCP``
-- while ``pickle.dumps`` still succeeded, so the failure
surfaced only on read-back rather than where it was caused.
So ``__reduce_ex__`` is set on the member itself, reducing it to
:func:`_rebuild_unregistered_member` instead of to a value lookup.
Both :mod:`pickle` and :mod:`copy` fetch that attribute with
:func:`getattr` on the object rather than on its type, so a
per-instance override is honoured: verified against the C
:mod:`pickle` accelerator on every protocol from 0 to 5, against
the pure-Python ``pickle._Pickler``, and against
:func:`copy.copy`/:func:`copy.deepcopy` both as they are on 3.11+
and with CPython's ``Enum.__copy__``/``__deepcopy__`` deleted to
emulate 3.10, where those two do not exist. Rebuilding re-enters
this method rather than ``namespace.__new__``, so an unpickled
member is unregistered exactly as the original was and the
registry does not grow -- ``TCP.__members__`` measured at 6147
before and after. On 3.11+ ``copy``/``deepcopy`` still return the
member itself, since ``Enum.__copy__`` short-circuits ahead of
any reduction; on 3.10 they return an equal rebuilt one, which is
the same answer for an immutable value.
Args:
namespace: The concrete registry class to build the member as an
instance of. ``isinstance`` holds against it and every
ancestor; it never gains an entry in any of its own tables.
value: The value ``namespace``'s own constructor would have
wrapped -- e.g. the crafted string
:meth:`~pcapkit.const.reg.apptype.AppType.__new__` builds from
a name, a port and a transport, or
:meth:`~pcapkit.const.pcapng.option_type.OptionType.__new__`'s
equivalent -- kept the same shape here so a rendered or
re-keyed member reads the same either way.
name: The member's own ``.name``; ``'<unassigned>'`` matches every
other nameless value this package produces.
**attrs: Extra attributes to set on the returned member, matching
the shape the caller's registry gives its real members --
:class:`~pcapkit.const.reg.apptype.AppType`'s ``.port``,
``.svc`` and ``.proto``, or
:class:`~pcapkit.const.pcapng.option_type.OptionType`'s
``.opt_name`` and ``.opt_value``.
Returns:
The unregistered member.
Raises:
TypeError: If ``namespace`` derives from neither :class:`str` nor
:class:`int` -- every registry this package builds does one or
the other, and guessing wrong for some future one would ship
a member silently missing whatever its storage base provides,
rather than saying plainly that this needs extending first.
"""
obj: 'Any'
if issubclass(namespace, str):
obj = str.__new__(namespace, value)
elif issubclass(namespace, int):
obj = int.__new__(namespace, value)
else:
raise TypeError(
f'{namespace!r} derives from neither str nor int; '
'_unregistered_member does not know how to build one of its members')
# NOTE: setting the enum protocol's own name/value attributes
# directly, rather than through namespace's own __new__, is what
# skips the registration that __new__ would otherwise have done.
obj._name_ = name # pylint: disable=protected-access
obj._value_ = value # pylint: disable=protected-access
for attr_name, attr_value in attrs.items():
setattr(obj, attr_name, attr_value)
# NOTE: ``Enum.__reduce_ex__`` reduces a member to ``(cls, (value,))``,
# i.e. to the one lookup this member is deliberately absent from, so
# pickle and copy would both raise on it without this. Overriding it
# per instance -- which pickle and copy both honour, since both fetch
# it with getattr on the object rather than on its type -- rebuilds an
# equivalent unregistered member instead. functools.partial rather
# than a closure keeps this off obj itself, so the member does not
# become part of a reference cycle merely by being reducible.
obj.__reduce_ex__ = functools.partial(
_reduce_unregistered_member, namespace, value, name, attrs)
return obj
[docs]
def _rebuild_unregistered_member(namespace: 'Type[StdlibEnum] | Type[AenumEnum]',
value: 'Any', name: 'str',
attrs: 'dict[str, Any]') -> 'Any':
"""Rebuild the member :meth:`EnumField._unregistered_member` returned.
This is what :mod:`pickle` and :mod:`copy` reconstruct through, in place of
the value lookup ``Enum.__reduce_ex__`` would otherwise have reduced the
member to. It is a module-level function rather than a method so that every
:mod:`pickle` protocol can name it: protocols below 4 cannot reference a
callable nested inside a class.
Args:
namespace: The registry class to rebuild the member as an instance of.
value: The member's ``_value_``.
name: The member's ``_name_``.
attrs: The extra attributes the member carried.
Returns:
A member equal to the original and, like it, absent from every one of
``namespace``'s lookup tables -- rebuilding goes back through
:meth:`EnumField._unregistered_member` and never through
``namespace.__new__``, so it cannot register anything either.
"""
return EnumField._unregistered_member( # pylint: disable=protected-access
namespace, value, name, **attrs)
[docs]
def _reduce_unregistered_member( # pylint: disable=unused-argument
namespace: 'Type[StdlibEnum] | Type[AenumEnum]',
value: 'Any', name: 'str', attrs: 'dict[str, Any]',
protocol: 'int') -> 'tuple[Callable[..., Any], tuple[Any, ...]]':
"""The ``__reduce_ex__`` :meth:`EnumField._unregistered_member` installs.
Bound to its first four arguments with :func:`functools.partial`, so that
the reducer holds the ingredients of the member rather than the member
itself.
Args:
namespace: The registry class the member is an instance of.
value: The member's ``_value_``.
name: The member's ``_name_``.
attrs: The extra attributes the member carries.
protocol: The :mod:`pickle` protocol version, ignored -- the reduction
is the same for all of them, and :mod:`copy` passes 4 here.
Returns:
A two-tuple of :func:`_rebuild_unregistered_member` and its arguments.
"""
return (_rebuild_unregistered_member, (namespace, value, name, attrs))