diff --git a/pcapkit/const/pcapng/option_type.py b/pcapkit/const/pcapng/option_type.py index 2d28ce0869..419a0205da 100644 --- a/pcapkit/const/pcapng/option_type.py +++ b/pcapkit/const/pcapng/option_type.py @@ -278,6 +278,56 @@ def register(cls, value: 'int', name: 'str') -> 'OptionType': except TypeError as error: raise ValueError(str(error)) from error + @classmethod + def register_alias(cls, value: 'int', name: 'str') -> 'OptionType': + """Add ``name`` as a further name for the member at option code ``value``. + + Overrides :meth:`~pcapkit.corekit.enum.EnumRegistry.register_alias` + for the same reason as :meth:`register`: the base looks the + :class:`int` code up in :attr:`~aenum.Enum._value2member_map_`, whose + keys are the formatted ``'{name} [{value}]'`` strings, so it could + never find one. + + The member is resolved as :meth:`get` resolves ``value`` with + ``namespace`` set to ``name``'s prefix: that namespace overlaid on + ``opt``. So ``register_alias(2, 'if_label')`` aliases ``if_name``, + while ``register_alias(1, 'if_note')`` aliases ``opt_comment``, since + ``if`` assigns no code 1 of its own. The alias adds a name only; the + member keeps its own name and namespace. + + Args: + value: Option code of the existing member to alias. + name: Alias to add for it, led by the namespace to resolve + ``value`` in. + + Returns: + The existing member, now reachable under ``name`` as well. + + Raises: + ValueError: If neither ``name``'s namespace nor ``opt`` assigns + ``value``, or if ``name`` is already taken. + + """ + namespace = name.split('_', maxsplit=1)[0] + member = cls.__members_ns__.get(namespace, {}).get(value) + if member is None: + member = cls.__members_ns__.get('opt', {}).get(value) + if member is None: + raise ValueError(f'{value!r} is not a registered {cls.__name__} in namespace ' + f'{namespace!r} or opt; use {cls.__name__}.register() to mint one') + + # NOTE: :func:`~aenum.extend_enum` builds a throwaway member through + # :meth:`__new__` to learn its value, and :meth:`__new__` files that + # throwaway in :attr:`__members_ns__` over ``member``. The alias + # resolves to ``member``, so ``member`` is put back. + member_ns = member.opt_name.split('_', maxsplit=1)[0] + try: + return extend_enum(cls, name, value, member.opt_name) + except TypeError as error: + raise ValueError(str(error)) from error + finally: + cls.__members_ns__[member_ns][value] = member + @staticmethod def get(key: 'int | str', default: 'int' = -1, *, namespace: 'str' = 'opt') -> 'OptionType': """Backport support for original codes. diff --git a/pcapkit/vendor/pcapng/option_type.py b/pcapkit/vendor/pcapng/option_type.py index 3f0a8cb372..7c68716732 100644 --- a/pcapkit/vendor/pcapng/option_type.py +++ b/pcapkit/vendor/pcapng/option_type.py @@ -188,6 +188,56 @@ def register(cls, value: 'int', name: 'str') -> '{NAME}': except TypeError as error: raise ValueError(str(error)) from error + @classmethod + def register_alias(cls, value: 'int', name: 'str') -> '{NAME}': + """Add ``name`` as a further name for the member at option code ``value``. + + Overrides :meth:`~pcapkit.corekit.enum.EnumRegistry.register_alias` + for the same reason as :meth:`register`: the base looks the + :class:`int` code up in :attr:`~aenum.Enum._value2member_map_`, whose + keys are the formatted ``'{{name}} [{{value}}]'`` strings, so it could + never find one. + + The member is resolved as :meth:`get` resolves ``value`` with + ``namespace`` set to ``name``'s prefix: that namespace overlaid on + ``opt``. So ``register_alias(2, 'if_label')`` aliases ``if_name``, + while ``register_alias(1, 'if_note')`` aliases ``opt_comment``, since + ``if`` assigns no code 1 of its own. The alias adds a name only; the + member keeps its own name and namespace. + + Args: + value: Option code of the existing member to alias. + name: Alias to add for it, led by the namespace to resolve + ``value`` in. + + Returns: + The existing member, now reachable under ``name`` as well. + + Raises: + ValueError: If neither ``name``'s namespace nor ``opt`` assigns + ``value``, or if ``name`` is already taken. + + """ + namespace = name.split('_', maxsplit=1)[0] + member = cls.__members_ns__.get(namespace, {{}}).get(value) + if member is None: + member = cls.__members_ns__.get('opt', {{}}).get(value) + if member is None: + raise ValueError(f'{{value!r}} is not a registered {{cls.__name__}} in namespace ' + f'{{namespace!r}} or opt; use {{cls.__name__}}.register() to mint one') + + # NOTE: :func:`~aenum.extend_enum` builds a throwaway member through + # :meth:`__new__` to learn its value, and :meth:`__new__` files that + # throwaway in :attr:`__members_ns__` over ``member``. The alias + # resolves to ``member``, so ``member`` is put back. + member_ns = member.opt_name.split('_', maxsplit=1)[0] + try: + return extend_enum(cls, name, value, member.opt_name) + except TypeError as error: + raise ValueError(str(error)) from error + finally: + cls.__members_ns__[member_ns][value] = member + @staticmethod def get(key: 'int | str', default: 'int' = -1, *, namespace: 'str' = 'opt') -> '{NAME}': """Backport support for original codes. diff --git a/tests/const/test_const_option_type_alias_unit.py b/tests/const/test_const_option_type_alias_unit.py new file mode 100644 index 0000000000..42630af8b2 --- /dev/null +++ b/tests/const/test_const_option_type_alias_unit.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +"""``OptionType.register_alias`` adds a name for an existing option code. + +GitHub issue #1330: :meth:`OptionType.register_alias +` fell through to +the base, which tests the :class:`int` code against +:attr:`~aenum.Enum._value2member_map_`. That table is keyed by the formatted +``'{name} [{value}]'`` strings, so every call raised ``ValueError: … is not a +registered OptionType``. + +The alias resolves ``value`` in the alias name's namespace overlaid on ``opt``, +as :meth:`~pcapkit.const.pcapng.option_type.OptionType.get` does. + +The registry is imported inside each test, after +:func:`~tests._support.reimport_once_per_class`, and the class's private import +is restored afterwards, so the aliases added here do not leak. The import is +shared by the tests of the class, so each test uses its own alias names. + +""" + +import unittest + +from tests._support import reimport_once_per_class + + +class TestOptionTypeRegisterAlias(unittest.TestCase): + """GitHub issue #1330.""" + + def setUp(self) -> None: + reimport_once_per_class(self, restore=True) + + def test_alias_in_opt_resolves_to_the_member(self) -> None: + from pcapkit.const.pcapng.option_type import OptionType + + count = len(list(OptionType)) + member = OptionType.register_alias(1, 'opt_note_1330') + self.assertIs(member, OptionType.opt_comment) + self.assertIs(getattr(OptionType, 'opt_note_1330'), OptionType.opt_comment) + self.assertIs(OptionType.__members__['opt_note_1330'], OptionType.opt_comment) + self.assertIs(OptionType.get('opt_note_1330'), OptionType.opt_comment) + # An alias adds a name, not a member, and the member keeps its own name. + self.assertEqual(len(list(OptionType)), count) + self.assertEqual(member.name, 'opt_comment') + self.assertEqual(member.opt_name, 'opt_comment') + + def test_alias_leaves_the_namespace_table_on_the_member(self) -> None: + from pcapkit.const.pcapng.option_type import OptionType + + OptionType.register_alias(2, 'if_label_1330') + self.assertIs(OptionType.__members_ns__['if'][2], OptionType.if_name) + self.assertIs(OptionType.get(2, namespace='if'), OptionType.if_name) + self.assertIs(getattr(OptionType, 'if_label_1330'), OptionType.if_name) + + def test_alias_namespace_falls_back_to_opt(self) -> None: + from pcapkit.const.pcapng.option_type import OptionType + + member = OptionType.register_alias(1, 'if_note_1330') + self.assertIs(member, OptionType.opt_comment) + self.assertNotIn(1, OptionType.__members_ns__['if']) + self.assertIs(OptionType.__members_ns__['opt'][1], OptionType.opt_comment) + + def test_alias_namespace_takes_precedence_over_opt(self) -> None: + from pcapkit.const.pcapng.option_type import OptionType + + # Code 2 is ``epb_flags`` in ``epb`` and ``if_name`` in ``if``. + self.assertIs(OptionType.register_alias(2, 'epb_fl_1330'), OptionType.epb_flags) + self.assertIs(OptionType.__members_ns__['epb'][2], OptionType.epb_flags) + self.assertIs(OptionType.__members_ns__['if'][2], OptionType.if_name) + + def test_register_aliases_adds_each_name(self) -> None: + from pcapkit.const.pcapng.option_type import OptionType + + members = OptionType.register_aliases(1, 'opt_a_1330', 'opt_b_1330') + self.assertEqual(members, (OptionType.opt_comment, OptionType.opt_comment)) + self.assertIs(OptionType.__members__['opt_b_1330'], OptionType.opt_comment) + + def test_unassigned_code_is_refused(self) -> None: + from pcapkit.const.pcapng.option_type import OptionType + + with self.assertRaisesRegex(ValueError, 'is not a registered OptionType'): + OptionType.register_alias(0x7ABC, 'if_missing_1330') + self.assertNotIn('if_missing_1330', OptionType.__members__) + + def test_taken_name_is_refused(self) -> None: + from pcapkit.const.pcapng.option_type import OptionType + + with self.assertRaises(ValueError): + OptionType.register_alias(2, 'opt_comment') + self.assertIs(OptionType.__members_ns__['opt'][1], OptionType.opt_comment) + self.assertIs(OptionType.__members_ns__['if'][2], OptionType.if_name) + + +if __name__ == '__main__': + unittest.main()