diff --git a/CHANGELOG.md b/CHANGELOG.md index f68a8803d..18430b02e 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). - **Fixed** -- the HIP `SOLUTION` builder sized the parameter with `4 + math.ceil(max(random.bit_length(), solution.bit_length()) / 4)`, which is an invalid shorthand for the two fields it actually has to describe. [RFC 7401 Section 5.2.5](https://datatracker.ietf.org/doc/html/rfc7401#section-5.2.5) gives the parameter's `Length` as `4 + RHASH_len / 4` over a `Random #I` and a `Puzzle solution #J` of `RHASH_len / 8` octets **each**, and `/ 4` equals twice `/ 8` only because `RHASH_len` -- the natural output length of a hash function, in bits -- is a whole number of octets. Applied to an arbitrary `bit_length()` the identity fails, and the builder emitted an *odd* contents width, which its own reader then refused: `_read_param_solution`'s `(schema.len - 4) % 2` guard exists because `SolutionParameter` splits that width into two equal `(len - 4) // 2` halves, so an odd width cannot be framed at all. `random=0x1` with `solution=0xfff` declared `len=7` and raised `ProtocolError: HIPv2: [ParamNo 321] invalid format` on input the library had itself produced. The undersized length was the worse half of it: because both fields take their width from that same `len`, `solution=0xfff` was packed into the one octet it allowed and came back as `0xff` -- silent truncation, nothing raised. Fixed to `4 + 2 * math.ceil(max(...) / 8)`, the form the sibling `PUZZLE` builder already uses, which is even by construction and so can never trip the guard. Loosening the reader was rejected as the alternative: an odd contents width has no meaning in the wire format, since the RFC makes the two fields equal-width and says nothing about which would take an extra octet, and a laxer guard would still have truncated the value (#608). - **Added** -- `SOLUTION` parameter width coverage in `tests/protocols/internet/test_hip_unit.py`: eight widths asserting the declared length, the reader's acceptance and the construct-pack-parse cycle, plus a 57-bit case pinning the 20-octet length [RFC 5201 Section 5.2.5](https://datatracker.ietf.org/doc/html/rfc5201#section-5.2.5) requires of HIPv1. Five of the eight widths -- 1, 9, 12, 17 and 25 bits -- are deliberately *not* multiples of eight, because at a multiple of eight the defective `ceil(bits / 4)` coincides with the correct width, which is why every fixture that reached this builder passed through it unharmed; the round-trip case in `examples/generators/options.py` supplies no `random` or `solution` at all, so it exercised the formula at zero bits. 57 bits is what discriminates on the HIPv1 path for the same reason 64 does not: both formulas give 20 at a full-width value (#608). diff --git a/docs/source/changelog/1.5.0.rst b/docs/source/changelog/1.5.0.rst index 8a76ff0d5..dccc907cb 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). * **Fixed** -- the HIP ``SOLUTION`` builder sized the parameter with ``4 + math.ceil(max(random.bit_length(), solution.bit_length()) / 4)``, which is an invalid shorthand for the two fields it actually has to describe. 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."""