From 636574a7eded6d12c7c12c910f06e45616a6a066 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Tue, 22 Sep 2026 00:56:24 -0400 Subject: [PATCH] fix(mh): stop the four flag enums recursing on any non-member value (#623) * pcapkit/vendor/mh/binding_ack_flag.py, binding_update_flag.py, handover_ack_flag.py, handover_initiate_flag.py: `_missing_` ended in `return cls(value)` -- the same constructor that had just failed to find the value -- so every in-range value that is not already a member re-entered `_missing_` unbounded and raised RecursionError. Because these are IntFlag types whose members are single bits, the reachable hole was `F(0)`, no flags set, and every composite of two defined bits with it. Defining `_missing_` at all is what caused it: the override shadowed the aenum Flag machinery that resolves exactly those values, which is why pcapkit/const/tcp/flags.py, which defines none, never had the defect. All four now end in `return super()._missing_(value)`, the tail pcapkit/vendor/default.py emits for every other generated enumeration and that 75 of the 117 modules under pcapkit/const/ already carry, so `F(0)` is an empty flag and `BindingACKFlag(0x06)` is `S|D`. The range guard above it is untouched. * pcapkit/const/mh/: regenerated from those four templates rather than hand-edited, these being generated output the next crawl would revert. The crawlers reproduce the committed files byte for byte -- md5 2ddc85e2.., 5b15dcd1.., b7a9d045.., fc0a68f0.. unchanged across the regeneration. * tests/const/test_const_enum_lookup.py: a companion IntFlag sweep. #492's own sweep excludes IntFlag by construction, which is why it never caught this. 6 new tests, 16 subtests that fail without the fix, covering `F(0)`, an unassigned single bit, a composite of two defined bits, the surviving range guard, and template/generated agreement character for character. Scoped runs green: tests/const/ 20 passed / 249 subtests, tests/vendor/ 55 passed, tests/protocols/internet/test_mh_unit.py 50 passed, tests/project/ 96 passed. Coverage on the four const modules rose from 87/89/85/86% to 94/94/93/93%. Fixes #623 --- CHANGELOG.md | 1 + docs/source/changelog/1.5.0.rst | 25 +++ pcapkit/const/mh/binding_ack_flag.py | 2 +- pcapkit/const/mh/binding_update_flag.py | 2 +- pcapkit/const/mh/handover_ack_flag.py | 2 +- pcapkit/const/mh/handover_initiate_flag.py | 2 +- pcapkit/vendor/mh/binding_ack_flag.py | 2 +- pcapkit/vendor/mh/binding_update_flag.py | 2 +- pcapkit/vendor/mh/handover_ack_flag.py | 2 +- pcapkit/vendor/mh/handover_initiate_flag.py | 2 +- tests/const/test_const_enum_lookup.py | 226 ++++++++++++++++++++ 11 files changed, 260 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49167a1eb..b2d747494 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,7 @@ This is the resolution of #548, which reported `TransType.L2TP` (115) as registe - **Fixed** -- the one assertion #604 left pinning the old padding side, which had been red on `mainline` since #621 merged. `TCPUDPUnitTests.test_a_truncated_option_still_parses_its_declared_length` expected a truncated TCP option's `data` as the synthesised zero octets *followed by* the real ones, which is what `rjust()` produced; #621 made the padding `ljust()` everywhere but could not retarget this file, since another change (#612) owned it at the time and editing it concurrently risked discarding work that has since landed. The real octets now come first for both parametrised widths, and the docstring above the assertion says tail-padding rather than left-padding. Test-only: no library code changes, and the sibling case in `tests/protocols/internet/test_ipv4_unit.py` was already retargeted in #621. Measured against `main` at `2221c2d8f`: two subtest failures before, none after (#604). - **Fixed** -- `main` went red the moment #604's `ljust()` landed, because `TCPUDPUnitTests.test_a_truncated_option_still_parses_its_declared_length` still pinned the head-padded short read that fix removed. The `Reserved_79` option declaring `length=12` over 6 real octets now reports `aabbccddeeff00000000` where the test expected `00000000aabbccddeeff`, so both subtests -- `declared_length=12` and `=32` -- failed on that one assertion while the 17 other cases in the file stayed green: the parse itself never changed, only which end the synthesised zeros sit at. The expectation is inverted, and the docstring above it -- which said the short read was *left*-padded and described the value as four zero octets followed by the six real ones -- is corrected to match, since a docstring that contradicts its own assertion is how the stale expectation survived in the first place. The inputs do discriminate: `trailing` is non-zero and the pad width is 4 and 24, so neither subtest would hold under the other order. #621 left this file alone deliberately, because #612 owned it at the time, and merged two minutes ahead of the cross-review verdict that named it (#604, #621). - **Fixed** -- `util/bump_version.py` left `CITATION.cff` naming the previous release. Nothing else in the repository maintains that file -- no workflow, hook or packaging file mentions it -- so every bump since it landed in #615 would have stranded the `version` and `date-released` it renders as GitHub's "Cite this repository" button and that citation managers, Zenodo and dependency inventories read directly. Both fields now move with `__version__`. They have the same standing, since the file's own header says both describe the newest *published* release, and moving only one would assert that 1.5.0b5 was released on the day 1.5.0b4 was; the date is taken in UTC, because seven of the thirty most recent bumps were made late evening in US-Eastern where a local date is a day behind the publish it describes. That the two are the same day at all is measured rather than assumed: the bump is what triggers `create-release.yml`, the median gap to the PyPI upload is three minutes, and the UTC calendar dates agree 30 times out of 30. The rewrite is line-oriented, so the comment header, key ordering and each field's existing quoting survive -- `cff-version` and a `references` entry's own `version` are anchored out at column zero -- and the result is checked with `cffconvert --validate`. An absent file is reported on stderr and skipped rather than failing the vendor cron before its `git commit`, which would discard the whole registry crawl for the sake of a documentation file; a file present with no `version` field raises instead, before anything is written, because rewriting nothing while reporting success is the staleness this fixes. Two things came with it. The script gains a `main()` guard, having previously run the entire bump at import, which is why it had no testable surface; and the `import pcapkit` fallback in its version reader, which returned `"1.5.0b4'\n"` -- closing quote and newline included, which `packaging` rejects -- is fixed, a path that had never worked and went unnoticed because the only caller installs the package first. A new gate asserts the committed file still names the packaged version, covering the version changes made by hand, which never run this script at all -- 40 of the 159 commits that have moved `__version__` on `main`, a quarter over the project's life and 11 of the most recent 25 (#625). +- **Fixed** -- `_missing_` in the four Mobility Header flag enumerations ended in `return cls(value)`, the same constructor that had just failed to find the value, so every in-range value that is not already a member re-entered `_missing_` unbounded and raised `RecursionError`. `BindingACKFlag`, `BindingUpdateFlag`, `HandoverACKFlag` and `HandoverInitiateFlag` are `IntFlag` types whose members are single bits, so the hole was not some exotic bit pattern: `F(0)` -- no flags set, the most ordinary value a flag octet can carry -- recursed on all four, and so did every composite of two defined bits, such as `BindingACKFlag(0x06)`. Defining `_missing_` at all is what caused it, because it shadowed the `aenum` `Flag` machinery that resolves exactly those values; `pcapkit/const/tcp/flags.py` defines no `_missing_` and has never had the defect. All four now end in `return super()._missing_(value)`, which is what `pcapkit/vendor/default.py` emits for every other generated enumeration and what 75 of the 117 modules under `pcapkit/const/` already do, so `F(0)` is an empty flag and `BindingACKFlag(0x06)` is `S|D`. The `extend_enum` idiom that `pcapkit/const/pcapng/record_type.py` and `secrets_type.py` use to mint a member for an unassigned integer -- the only other two modules whose `_missing_` ends in `return cls(value)`, and which do not recurse precisely because that `extend_enum` runs first -- was considered and rejected for a flag type: naming `0x06` `Unassigned_0x06` would hide the composite and pollute `_member_map_` with an entry per bit pattern, up to 65536 of them for `BindingUpdateFlag`. The range guard above it is untouched, so an out-of-range or non-integer value is still the same `ValueError`. Fixed in the four `pcapkit/vendor/mh/` templates and regenerated, the `pcapkit/const/mh/` modules being generated output that the next crawl would otherwise revert (#623). Preceded by `1.5.0a1` (2026-09-15), `1.5.0b1` and `1.5.0b2` (both 2026-09-18) and `1.5.0b3` (2026-09-19), all published as prereleases and so resolved only by `pip install --pre`. `1.5.0b1` half-shipped: the tag, the GitHub release and the Conda deployments landed, but PyPI rejected the wheel because `twine check` found a Sphinx-only `:mod:` role in `README.rst`, which `pyproject.toml` declares as the dynamic long description. `1.5.0b2` is what reshipped it -- the release workflow is version-driven, so an existing version cannot republish -- and `1.5.0b3` followed the CI change that stops a TestPyPI outage from costing a release its wheels (#497, #498). diff --git a/docs/source/changelog/1.5.0.rst b/docs/source/changelog/1.5.0.rst index 96e7dcd6e..52ba0da17 100644 --- a/docs/source/changelog/1.5.0.rst +++ b/docs/source/changelog/1.5.0.rst @@ -1063,6 +1063,31 @@ pull requests between #326 and #509. version, covering the version changes made by hand, which never run this script at all -- 40 of the 159 commits that have moved ``__version__`` on ``main``, a quarter over the project's life and 11 of the most recent 25 (#625). +* **Fixed** -- ``_missing_`` in the four Mobility Header flag enumerations ended in + ``return cls(value)``, the same constructor that had just failed to find the value, + so every in-range value that is not already a member re-entered ``_missing_`` + unbounded and raised ``RecursionError``. ``BindingACKFlag``, ``BindingUpdateFlag``, + ``HandoverACKFlag`` and ``HandoverInitiateFlag`` are ``IntFlag`` types whose + members are single bits, so the hole was not some exotic bit pattern: ``F(0)`` -- + no flags set, the most ordinary value a flag octet can carry -- recursed on all + four, and so did every composite of two defined bits, such as + ``BindingACKFlag(0x06)``. Defining ``_missing_`` at all is what caused it, because + it shadowed the ``aenum`` ``Flag`` machinery that resolves exactly those values; + ``pcapkit/const/tcp/flags.py`` defines no ``_missing_`` and has never had the + defect. All four now end in ``return super()._missing_(value)``, which is what + ``pcapkit/vendor/default.py`` emits for every other generated enumeration and what + 75 of the 117 modules under ``pcapkit/const/`` already do, so ``F(0)`` is an empty + flag and ``BindingACKFlag(0x06)`` is ``S|D``. The ``extend_enum`` idiom that + ``pcapkit/const/pcapng/record_type.py`` and ``secrets_type.py`` use to mint a + member for an unassigned integer -- the only other two modules whose ``_missing_`` + ends in ``return cls(value)``, and which do not recurse precisely because that + ``extend_enum`` runs first -- was considered and rejected for a flag type: naming + ``0x06`` ``Unassigned_0x06`` would hide the composite and pollute ``_member_map_`` + with an entry per bit pattern, up to 65536 of them for ``BindingUpdateFlag``. The + range guard above it is untouched, so an out-of-range or non-integer value is + still the same ``ValueError``. Fixed in the four ``pcapkit/vendor/mh/`` templates + and regenerated, the ``pcapkit/const/mh/`` modules being generated output that the + next crawl would otherwise revert (#623). Preceded by ``1.5.0a1`` (2026-09-15), ``1.5.0b1`` and ``1.5.0b2`` (both 2026-09-18) and ``1.5.0b3`` (2026-09-19), all published as prereleases and so diff --git a/pcapkit/const/mh/binding_ack_flag.py b/pcapkit/const/mh/binding_ack_flag.py index 4cac25596..2258a31e0 100644 --- a/pcapkit/const/mh/binding_ack_flag.py +++ b/pcapkit/const/mh/binding_ack_flag.py @@ -70,4 +70,4 @@ def _missing_(cls, value: 'int') -> 'BindingACKFlag': """ if not (isinstance(value, int) and 0 <= value <= 0xFF): raise ValueError('%r is not a valid %s' % (value, cls.__name__)) - return cls(value) + return super()._missing_(value) diff --git a/pcapkit/const/mh/binding_update_flag.py b/pcapkit/const/mh/binding_update_flag.py index dd635f98c..6149634bb 100644 --- a/pcapkit/const/mh/binding_update_flag.py +++ b/pcapkit/const/mh/binding_update_flag.py @@ -85,4 +85,4 @@ def _missing_(cls, value: 'int') -> 'BindingUpdateFlag': """ if not (isinstance(value, int) and 0 <= value <= 0xFFFF): raise ValueError('%r is not a valid %s' % (value, cls.__name__)) - return cls(value) + return super()._missing_(value) diff --git a/pcapkit/const/mh/handover_ack_flag.py b/pcapkit/const/mh/handover_ack_flag.py index dc086e5d2..61a3a3dd2 100644 --- a/pcapkit/const/mh/handover_ack_flag.py +++ b/pcapkit/const/mh/handover_ack_flag.py @@ -58,4 +58,4 @@ def _missing_(cls, value: 'int') -> 'HandoverACKFlag': """ if not (isinstance(value, int) and 0 <= value <= 0xFF): raise ValueError('%r is not a valid %s' % (value, cls.__name__)) - return cls(value) + return super()._missing_(value) diff --git a/pcapkit/const/mh/handover_initiate_flag.py b/pcapkit/const/mh/handover_initiate_flag.py index 816603076..2b3f0458a 100644 --- a/pcapkit/const/mh/handover_initiate_flag.py +++ b/pcapkit/const/mh/handover_initiate_flag.py @@ -61,4 +61,4 @@ def _missing_(cls, value: 'int') -> 'HandoverInitiateFlag': """ if not (isinstance(value, int) and 0 <= value <= 0xFF): raise ValueError('%r is not a valid %s' % (value, cls.__name__)) - return cls(value) + return super()._missing_(value) diff --git a/pcapkit/vendor/mh/binding_ack_flag.py b/pcapkit/vendor/mh/binding_ack_flag.py index a47106dd2..f81eff18c 100644 --- a/pcapkit/vendor/mh/binding_ack_flag.py +++ b/pcapkit/vendor/mh/binding_ack_flag.py @@ -76,7 +76,7 @@ def _missing_(cls, value: 'int') -> '{NAME}': """ if not ({FLAG}): raise ValueError('%r is not a valid %s' % (value, cls.__name__)) - return cls(value) + return super()._missing_(value) ''' # type: Callable[[str, str, str, str, str], str] diff --git a/pcapkit/vendor/mh/binding_update_flag.py b/pcapkit/vendor/mh/binding_update_flag.py index 9dd22ef18..57648cff3 100644 --- a/pcapkit/vendor/mh/binding_update_flag.py +++ b/pcapkit/vendor/mh/binding_update_flag.py @@ -75,7 +75,7 @@ def _missing_(cls, value: 'int') -> '{NAME}': """ if not ({FLAG}): raise ValueError('%r is not a valid %s' % (value, cls.__name__)) - return cls(value) + return super()._missing_(value) ''' # type: Callable[[str, str, str, str, str], str] diff --git a/pcapkit/vendor/mh/handover_ack_flag.py b/pcapkit/vendor/mh/handover_ack_flag.py index fe9a4080d..3069468aa 100644 --- a/pcapkit/vendor/mh/handover_ack_flag.py +++ b/pcapkit/vendor/mh/handover_ack_flag.py @@ -76,7 +76,7 @@ def _missing_(cls, value: 'int') -> '{NAME}': """ if not ({FLAG}): raise ValueError('%r is not a valid %s' % (value, cls.__name__)) - return cls(value) + return super()._missing_(value) ''' # type: Callable[[str, str, str, str, str], str] diff --git a/pcapkit/vendor/mh/handover_initiate_flag.py b/pcapkit/vendor/mh/handover_initiate_flag.py index 2f069fcdc..7a37484a0 100644 --- a/pcapkit/vendor/mh/handover_initiate_flag.py +++ b/pcapkit/vendor/mh/handover_initiate_flag.py @@ -76,7 +76,7 @@ def _missing_(cls, value: 'int') -> '{NAME}': """ if not ({FLAG}): raise ValueError('%r is not a valid %s' % (value, cls.__name__)) - return cls(value) + return super()._missing_(value) ''' # type: Callable[[str, str, str, str, str], str] diff --git a/tests/const/test_const_enum_lookup.py b/tests/const/test_const_enum_lookup.py index e71014949..7d46e3560 100644 --- a/tests/const/test_const_enum_lookup.py +++ b/tests/const/test_const_enum_lookup.py @@ -18,6 +18,24 @@ ``pcapkit.const`` tree (see :mod:`pcapkit.vendor`) that drops either fix will fail this suite rather than silently reintroducing GitHub issue #492. +That sweep deliberately skips :class:`~aenum.IntFlag`, on the grounds that it is +"a different value-lookup contract" -- which is exactly why it did not catch +GitHub issue #623, a third lookup defect of the same family living in the class +of enum it excluded. The four Mobility Header flag enumerations overrode +``_missing_`` with a body ending in ``return cls(value)``, the same constructor +that had just failed to find the value, so every in-range value that is not +already a member re-entered ``_missing_`` unbounded and raised +:exc:`RecursionError`. Because their members are single bits, the reachable hole +was ``0`` -- no flags set -- and every composite of two defined bits. Overriding +``_missing_`` at all is what caused it: the override shadowed the ``aenum`` +:class:`~aenum.Flag` machinery that resolves precisely those values, which is why +:class:`~pcapkit.const.tcp.flags.Flags`, which defines no ``_missing_``, never +had the defect. :class:`~pcapkit.const.mh.binding_ack_flag.BindingACKFlag` and +its three siblings now end in ``return super()._missing_(value)``, the tail +:mod:`pcapkit.vendor.default` emits for every other generated enumeration. So +this module carries a companion sweep over the :class:`~aenum.IntFlag` classes, +covering the contract the :class:`~aenum.IntEnum` sweep declines to. + Every test class purges :mod:`pcapkit` from :data:`sys.modules` in ``setUp``, matching the convention every other module in this suite uses (see :func:`tests._support.purge_modules`). It matters more here than usual: this @@ -33,8 +51,10 @@ from __future__ import annotations import importlib +import importlib.util import inspect import pkgutil +import re import unittest from typing import TYPE_CHECKING @@ -57,6 +77,47 @@ 'pcapkit.const.mh.mn_id_subtype.MNIDSubtype', }) +#: The four Mobility Header flag enumerations of GitHub issue #623, as +#: ``(module stem, class name, two defined single-bit member names, the value +#: ORing those two members gives)``. That composite is deliberately a value no +#: registry assigns: it is in range, it is not a member, and it is what +#: ``return cls(value)`` recursed on. The pairs are the two lowest bits each +#: registry defines -- ``BindingACKFlag`` ``D=0x02`` ``S=0x04``, +#: ``BindingUpdateFlag`` ``D=0x10`` ``S=0x20``, ``HandoverACKFlag`` ``F=0x20`` +#: ``P=0x40``, ``HandoverInitiateFlag`` ``F=0x10`` ``P=0x20``. +MH_FLAG_ENUMS = ( + ('binding_ack_flag', 'BindingACKFlag', ('D', 'S'), 0x06), + ('binding_update_flag', 'BindingUpdateFlag', ('D', 'S'), 0x30), + ('handover_ack_flag', 'HandoverACKFlag', ('F', 'P'), 0x60), + ('handover_initiate_flag', 'HandoverInitiateFlag', ('F', 'P'), 0x30), +) + + +def _normalize(context: 'str') -> 'str': + """Whitespace-normalise a rendered template the way the writer does. + + :meth:`pcapkit.vendor.default.Vendor.__init__` rstrips every non-blank line, + drops whitespace-only lines, joins with ``\\n`` and writes the result with + :func:`print`, which restores the single trailing newline. Reproducing it + here is what lets a rendered template be compared against a committed module + character for character. + + Args: + context: Rendered template text. + + Returns: + The text as it would be written to ``pcapkit/const/``. + + """ + lines = [] # type: list[str] + for line in context.splitlines(): + if line: + if line.strip(): + lines.append(line.rstrip()) + else: + lines.append(line) + return '\n'.join(lines) + '\n' + def _iter_const_int_enums() -> 'list[type]': """Discover every :class:`~aenum.IntEnum` defined under :mod:`pcapkit.const`. @@ -84,6 +145,30 @@ def _iter_const_int_enums() -> 'list[type]': return classes +def _iter_const_int_flags() -> 'list[type]': + """Discover every :class:`~aenum.IntFlag` defined under :mod:`pcapkit.const`. + + The mirror image of :func:`_iter_const_int_enums`: same walk, same + defined-here test, but keeping exactly the classes that one discards. This + is the sweep GitHub issue #492 declined to run, and GitHub issue #623 is + what was hiding in it. + + Returns: + The discovered flag classes, in walk order. + + """ + import pcapkit.const as const_pkg + + classes = [] # type: list[type] + for module_info in pkgutil.walk_packages(const_pkg.__path__, const_pkg.__name__ + '.'): + module = importlib.import_module(module_info.name) + for _, obj in vars(module).items(): + if (inspect.isclass(obj) and issubclass(obj, IntFlag) + and obj.__module__ == module_info.name): + classes.append(obj) + return classes + + class ConstEnumZeroLookupTests(unittest.TestCase): """``Enum(0)`` must behave as the backing registry says it should.""" @@ -180,6 +265,147 @@ def test_ftp_return_code_missing_methods_are_classmethods(self) -> None: self.assertEqual(int(GroupingInformation(9)), 9) +class ConstFlagMissingRecursionTests(unittest.TestCase): + """An ``IntFlag`` lookup must resolve or raise, never recurse. + + GitHub issue #623. ``_missing_`` in the four Mobility Header flag + enumerations ended in ``return cls(value)`` -- the constructor that had just + failed to find the value -- so every in-range non-member re-entered + ``_missing_`` unbounded and raised :exc:`RecursionError`. The tests below + assert the *resolved* value rather than catching the recursion, so an + unfixed tree fails on the error rather than on an assertion, and the four + named registries are covered alongside the sweep. + + """ + + if TYPE_CHECKING: + flags: 'list[type]' + + @classmethod + def setUpClass(cls) -> None: + purge_modules(['pcapkit']) + cls.flags = _iter_const_int_flags() + cls.addClassCleanup(purge_modules, ['pcapkit']) + + def test_the_sweep_size_is_pinned(self) -> None: + """A flag enum added or removed needs a fresh look, not a silent pass.""" + names = {f'{obj.__module__}.{obj.__qualname__}' for obj in self.flags} + self.assertEqual(len(self.flags), 7) + + expected = {f'pcapkit.const.mh.{stem}.{name}' + for stem, name, _, _ in MH_FLAG_ENUMS} + self.assertTrue(expected.issubset(names), + f'issue #623 registries missing from the sweep: ' + f'{expected - names}') + + def test_zero_resolves_to_an_empty_flag(self) -> None: + """``F(0)`` -- no flags set -- is the reachable trigger of issue #623.""" + for obj in self.flags: + qualname = f'{obj.__module__}.{obj.__qualname__}' + with self.subTest(enum=qualname): + try: + empty = obj(0) + except RecursionError: + self.fail(f'{qualname}(0) recursed through _missing_; ' + f'see GitHub issue #623') + self.assertEqual(int(empty), 0) + self.assertFalse(bool(empty)) + + def test_a_composite_of_defined_bits_decomposes(self) -> None: + """An unassigned in-range value is a composite, not an error. + + Delegating to ``aenum``'s own :class:`~aenum.Flag` machinery is what + restores this; the ``extend_enum`` idiom the integer registries use + would have minted an opaque ``Unassigned_0x06`` member instead. + """ + for stem, name, members, composite in MH_FLAG_ENUMS: + qualname = f'pcapkit.const.mh.{stem}.{name}' + with self.subTest(enum=qualname, value=hex(composite)): + obj = getattr(importlib.import_module(f'pcapkit.const.mh.{stem}'), name) + + # The premise: the composite really is unassigned, so it is + # _missing_ that has to resolve it. + self.assertNotIn(composite, {member.value for member in obj}) + + first, second = (obj[member] for member in members) + self.assertEqual(int(first) | int(second), composite) + + resolved = obj(composite) + self.assertEqual(int(resolved), composite) + self.assertEqual(resolved, first | second) + + def test_an_unassigned_single_bit_resolves(self) -> None: + """``0x01`` is below every bit these four define, and recursed too.""" + for stem, name, _, _ in MH_FLAG_ENUMS: + qualname = f'pcapkit.const.mh.{stem}.{name}' + with self.subTest(enum=qualname): + obj = getattr(importlib.import_module(f'pcapkit.const.mh.{stem}'), name) + self.assertNotIn(1, {member.value for member in obj}) + self.assertEqual(int(obj(1)), 1) + + def test_the_range_guard_still_rejects(self) -> None: + """The guard above the fixed line is untouched, so it still raises.""" + for stem, name, _, _ in MH_FLAG_ENUMS: + qualname = f'pcapkit.const.mh.{stem}.{name}' + with self.subTest(enum=qualname): + obj = getattr(importlib.import_module(f'pcapkit.const.mh.{stem}'), name) + + # One past the registry's own width, read off the guard rather + # than assumed: 0xFF for three of them, 0xFFFF for the update + # flags, whose registry runs to 0x8000. + width = max(member.value for member in obj) + beyond = 0x10000 if width > 0xFF else 0x100 + with self.assertRaises(ValueError): + obj(beyond) + with self.assertRaises(ValueError): + obj(-1) + with self.assertRaises(ValueError): + obj('not an integer') + + @unittest.skipUnless(importlib.util.find_spec('requests') is not None, + 'pcapkit.vendor needs requests') + def test_the_vendor_templates_still_emit_the_fix(self) -> None: + """A regeneration must not undo the fix. + + ``pcapkit/const/`` is generated from ``pcapkit/vendor/``, so the + committed tree passing the tests above is not evidence that the + templates agree with it -- the next crawl would simply revert them. + Renders each of the four templates with the registry data taken back out + of the module generated from it, and compares the whole file character + for character. Needs no network: the crawl supplies only the enumeration + block, which is read from the committed module rather than fetched. + """ + import pathlib + + block = re.compile( + r'class \w+\(IntFlag\):\n """.*?"""\n\n (.*?)\n\n @staticmethod', + re.S) + + for stem, name, _, _ in MH_FLAG_ENUMS: + with self.subTest(vendor=f'pcapkit.vendor.mh.{stem}'): + vendor_module = importlib.import_module(f'pcapkit.vendor.mh.{stem}') + vendor_class = getattr(vendor_module, name) + + const_module = importlib.import_module(f'pcapkit.const.mh.{stem}') + committed = pathlib.Path( + const_module.__file__ # type: ignore[arg-type] + ).read_text(encoding='utf-8') + + enum_block = block.search(committed) + self.assertIsNotNone( + enum_block, f'no enumeration block in pcapkit.const.mh.{stem}') + + rendered = _normalize(vendor_module.LINE( + vendor_class.__name__, vendor_class.__doc__, vendor_class.FLAG, + enum_block.group(1), # type: ignore[union-attr] + f'pcapkit.vendor.mh.{stem}', + )) + + self.assertIn('return super()._missing_(value)', rendered) + self.assertNotIn(' return cls(value)', rendered) + self.assertEqual(rendered, committed) + + class RouterAlertPacketParseTests(unittest.TestCase): """Parse an on-the-wire packet carrying RFC 2113's Router Alert value 0."""