|
| 1 | +# -*- coding: utf-8 -*- |
| 2 | +"""A hard-coded ``_missing_`` body must end in a ``return``, and no emitted |
| 3 | +line may re-enter ``_missing_`` for the same value. |
| 4 | +
|
| 5 | +GitHub issue #866. A vendor crawler renders the body of the generated |
| 6 | +registry's :meth:`~enum.Enum._missing_` as a list of source lines, held in the |
| 7 | +local ``miss``. Two crawlers -- |
| 8 | +:mod:`pcapkit.vendor.pcapng.record_type` and |
| 9 | +:mod:`pcapkit.vendor.pcapng.secrets_type` -- emitted a **two**-line body whose |
| 10 | +first line called :meth:`~pcapkit.corekit.enum.EnumRegistry._unregistered_member` |
| 11 | +without returning it and whose second line was ``return cls(value)``:: |
| 12 | +
|
| 13 | + cls._unregistered_member(value, 'Unassigned') |
| 14 | + return cls(value) |
| 15 | +
|
| 16 | +That shape was harmless while the first line was ``extend_enum(...)``, which |
| 17 | +*registers* the member, so the following ``cls(value)`` found it and returned. |
| 18 | +#861 replaced the ``extend_enum`` call with ``_unregistered_member``, which |
| 19 | +deliberately does **not** register -- so ``cls(value)`` missed again, re-entered |
| 20 | +``_missing_``, and recursed until ``RecursionError``. |
| 21 | +
|
| 22 | +The invariant is deliberately not "every emitted line returns": the base |
| 23 | +crawler itself emits legitimate non-returning lines for a range-guarded body. |
| 24 | +:meth:`pcapkit.vendor.default.Vendor.process` (see |
| 25 | +:file:`pcapkit/vendor/default.py:341-343`) renders such a body as three |
| 26 | +lines -- an ``if`` header, a ``#:`` comment, then an indented ``return`` -- |
| 27 | +and only the third of the three returns:: |
| 28 | +
|
| 29 | + miss.append(f'if {start} <= value <= {stop}:') |
| 30 | + miss.append(f' #: {desc}') |
| 31 | + miss.append(f" return cls._unregistered_member(value, '{self.safe_name(name)}')") |
| 32 | +
|
| 33 | +Measured: 53 crawlers emit 109 such non-returning lines between them, and |
| 34 | +three registries under :mod:`pcapkit.const` carry exactly that shape today. |
| 35 | +A crawler hard-coding a range-guarded body as a list literal is writing |
| 36 | +something valid, so the guard below checks only that a hard-coded body |
| 37 | +*ends* in a ``return`` and that no emitted line re-enters ``_missing_`` for |
| 38 | +the same value -- not that every line returns. |
| 39 | +
|
| 40 | +#861 only *half*-corrected the two generated files, and never touched the two |
| 41 | +crawlers. It replaced the non-returning ``extend_enum(...)`` call with a |
| 42 | +returning ``_unregistered_member(...)`` call, but the ``return cls(value)`` |
| 43 | +line that followed was left in place, now unreachable dead code:: |
| 44 | +
|
| 45 | + - extend_enum(cls, 'Unassigned_0x%08x' % value, value) |
| 46 | + + return cls._unregistered_member(value, 'Unassigned') |
| 47 | + return cls(value) # <- still there, now unreachable |
| 48 | +
|
| 49 | +That is why #861's own CI was green: the first ``return`` short-circuits, so |
| 50 | +the dead second line never ran. (A statement following a ``return`` in |
| 51 | +generated output is itself worth noticing on its own -- it means the |
| 52 | +generator that will overwrite the file disagrees with what the file |
| 53 | +currently does.) Because the crawlers were never touched, a later |
| 54 | +regeneration -- ``e58618bdf`` ("Bumped version to 1.5.0b5") -- reproduced the |
| 55 | +original two-line body from the stale crawlers and reintroduced the |
| 56 | +recursion, turning **12** tests red across **three** files: |
| 57 | +:file:`tests/const/test_const_enum_lookup.py`, |
| 58 | +:file:`tests/const/test_const_enum_no_mint.py` |
| 59 | +(``RulingConversionDoesNotMintTests.test_converted_value_does_not_mint`` and |
| 60 | +``test_repeated_lookup_does_not_grow_members``, two registries each), and |
| 61 | +:file:`tests/protocols/misc/test_pcapng_unit.py` (7 of the 12) -- all twelve |
| 62 | +``RecursionError``. |
| 63 | +
|
| 64 | +The guard here is deliberately at the **crawler** layer rather than the |
| 65 | +generated one. ``tests/const/test_const_enum_lookup.py`` already exercises every |
| 66 | +committed registry and is what caught the regression; what nothing covered was |
| 67 | +the source a crawler *emits*, so a fix to a generated file could silently |
| 68 | +disagree with the generator that will overwrite it. This module needs no network |
| 69 | +and no regeneration: it reads the crawlers' own ASTs. |
| 70 | +
|
| 71 | +""" |
| 72 | +from __future__ import annotations |
| 73 | + |
| 74 | +import ast |
| 75 | +import pathlib |
| 76 | +import unittest |
| 77 | + |
| 78 | +#: Root of the vendor crawler package, as a path rather than an import, so this |
| 79 | +#: module reads the same tree it is checked out in regardless of which |
| 80 | +#: ``pcapkit`` an editable install happens to resolve. |
| 81 | +VENDOR_ROOT = pathlib.Path(__file__).resolve().parents[2] / 'pcapkit' / 'vendor' |
| 82 | + |
| 83 | +#: How many crawlers emit at least one *hard-coded* line into ``miss`` as a |
| 84 | +#: string-constant list literal. Pinned so a crawler that leaves this shape -- |
| 85 | +#: switching to ``.append()``, or dropping its unassigned range -- is visible |
| 86 | +#: here rather than silently shrinking the sweep below. It does **not** catch |
| 87 | +#: the opposite drift: a brand-new crawler that starts in a shape this guard |
| 88 | +#: never covered joins invisibly, because nothing here shrinks when one is |
| 89 | +#: added. |
| 90 | +#: |
| 91 | +#: Measured on ``main`` at ``946b84e83``: 96 crawler modules define |
| 92 | +#: ``process()``. Of those, 35 assign ``miss`` as a list literal of string |
| 93 | +#: constants -- the ones this test can see -- 53 build it by ``.append()`` |
| 94 | +#: instead, and 8 never return an ``(enum, miss)`` pair at all. That leaves |
| 95 | +#: **61 of the 96 invisible to this guard**, not merely the two crawlers used |
| 96 | +#: as an example in :func:`_constant_miss_lines`'s docstring below. A crawler |
| 97 | +#: that assigns ``miss`` from a comprehension, from a variable, or under a |
| 98 | +#: different variable name entirely is skipped by construction too, same as |
| 99 | +#: one built by ``.append()``. All 61 are covered behaviourally, not |
| 100 | +#: statically, by :file:`tests/const/test_const_enum_lookup.py`. |
| 101 | +EXPECTED_CRAWLERS_WITH_CONSTANT_MISS_LINES = 35 |
| 102 | + |
| 103 | + |
| 104 | +def _constant_miss_lines() -> 'dict[str, list[str]]': |
| 105 | + """Collect every constant line each crawler assigns to ``miss``. |
| 106 | +
|
| 107 | + Returns a mapping of repository-relative crawler path to the list of |
| 108 | + string constants in its ``miss`` list literal. A crawler that builds |
| 109 | + ``miss`` by ``.append()``, assigns it from a comprehension or a variable, |
| 110 | + or never returns an ``(enum, miss)`` pair at all, is absent from the |
| 111 | + returned mapping entirely -- not present with an empty list. That is |
| 112 | + **61 of the 96** crawler modules defining ``process()``, not just the two |
| 113 | + examples this docstring used to single out |
| 114 | + (:mod:`pcapkit.vendor.reg.ethertype`, :mod:`pcapkit.vendor.ipx.socket`); |
| 115 | + see :data:`EXPECTED_CRAWLERS_WITH_CONSTANT_MISS_LINES` for the full |
| 116 | + breakdown. All 61 are covered behaviourally, not statically, by |
| 117 | + :file:`tests/const/test_const_enum_lookup.py`. |
| 118 | +
|
| 119 | + """ |
| 120 | + found = {} # type: dict[str, list[str]] |
| 121 | + for path in sorted(VENDOR_ROOT.rglob('*.py')): |
| 122 | + tree = ast.parse(path.read_text(encoding='utf-8')) |
| 123 | + for node in ast.walk(tree): |
| 124 | + if not isinstance(node, ast.Assign): |
| 125 | + continue |
| 126 | + if not any(isinstance(t, ast.Name) and t.id == 'miss' for t in node.targets): |
| 127 | + continue |
| 128 | + if not isinstance(node.value, ast.List): |
| 129 | + continue |
| 130 | + lines = [elt.value for elt in node.value.elts |
| 131 | + if isinstance(elt, ast.Constant) and isinstance(elt.value, str)] |
| 132 | + if lines: |
| 133 | + found[str(path.relative_to(VENDOR_ROOT.parents[1]))] = lines |
| 134 | + return found |
| 135 | + |
| 136 | + |
| 137 | +class VendorMissingBodyTests(unittest.TestCase): |
| 138 | + """Static guards over what the crawlers render into ``_missing_``.""" |
| 139 | + |
| 140 | + @classmethod |
| 141 | + def setUpClass(cls) -> None: |
| 142 | + cls.miss_lines = _constant_miss_lines() # type: ignore[attr-defined] |
| 143 | + |
| 144 | + def test_the_sweep_size_is_pinned(self) -> None: |
| 145 | + """A crawler leaving or joining the sweep must be deliberate.""" |
| 146 | + self.assertEqual(len(self.miss_lines), # type: ignore[attr-defined] |
| 147 | + EXPECTED_CRAWLERS_WITH_CONSTANT_MISS_LINES, |
| 148 | + 'the set of crawlers emitting a constant ``_missing_`` body ' |
| 149 | + 'changed; update EXPECTED_CRAWLERS_WITH_CONSTANT_MISS_LINES ' |
| 150 | + 'once you have checked the new ones still return') |
| 151 | + |
| 152 | + def test_the_hard_coded_body_ends_in_a_return(self) -> None: |
| 153 | + """The invariant #866 is about, restated to the shape that is true. |
| 154 | +
|
| 155 | + Not every emitted line has to ``return`` -- the range-guarded shape |
| 156 | + in :meth:`pcapkit.vendor.default.Vendor.process` legitimately emits |
| 157 | + an ``if`` header and a ``#:`` comment before its ``return``, and 53 |
| 158 | + crawlers do exactly that (see the module docstring). What has to be |
| 159 | + true is narrower: a hard-coded body must *end* on a ``return``, so |
| 160 | + control never falls off the end of ``miss`` into a fallthrough. |
| 161 | +
|
| 162 | + This test does **not** catch #866 on its own: #866's body ended on |
| 163 | + ``return cls(value)``, which does return, so it passes this check |
| 164 | + both before and after the fix. It is |
| 165 | + :meth:`test_no_crawler_emits_a_self_recursive_lookup` that catches |
| 166 | + the cycle -- this test only guards against a body that does not |
| 167 | + return at all. |
| 168 | +
|
| 169 | + """ |
| 170 | + for crawler, lines in sorted(self.miss_lines.items()): # type: ignore[attr-defined] |
| 171 | + last = lines[-1] |
| 172 | + with self.subTest(crawler=crawler, line=last): |
| 173 | + self.assertTrue(last.lstrip().startswith('return '), |
| 174 | + f'{crawler} ends its hard-coded ``_missing_`` body on ' |
| 175 | + f'{last!r}, which does not return; a body that falls ' |
| 176 | + f'through does nothing observable, see GitHub issue #866') |
| 177 | + |
| 178 | + def test_no_crawler_emits_a_self_recursive_lookup(self) -> None: |
| 179 | + """``return cls(value)`` inside ``_missing_`` is unconditionally wrong. |
| 180 | +
|
| 181 | + Named separately from :meth:`test_the_hard_coded_body_ends_in_a_return` |
| 182 | + because this line *does* return and so passes that check, yet re-enters |
| 183 | + ``_missing_`` for the same value. It was the second half of #866's |
| 184 | + two-line body and is the line that actually recursed. |
| 185 | +
|
| 186 | + """ |
| 187 | + for crawler, lines in sorted(self.miss_lines.items()): # type: ignore[attr-defined] |
| 188 | + for line in lines: |
| 189 | + with self.subTest(crawler=crawler, line=line): |
| 190 | + self.assertNotEqual(line.replace(' ', ''), 'returncls(value)', |
| 191 | + f'{crawler} emits ``return cls(value)`` inside ' |
| 192 | + f'``_missing_``, which re-enters ``_missing_`` ' |
| 193 | + f'for the same value; see GitHub issue #866') |
| 194 | + |
| 195 | + def test_the_two_regressed_crawlers_emit_a_single_returning_line(self) -> None: |
| 196 | + """The specific pair from #866, pinned by name. |
| 197 | +
|
| 198 | + Kept alongside the general sweeps because a regression here is the one |
| 199 | + that reached ``main``, and a named test says which file to look at |
| 200 | + without having to read a subTest label. |
| 201 | +
|
| 202 | + """ |
| 203 | + for crawler in ('pcapkit/vendor/pcapng/record_type.py', |
| 204 | + 'pcapkit/vendor/pcapng/secrets_type.py'): |
| 205 | + with self.subTest(crawler=crawler): |
| 206 | + lines = self.miss_lines[crawler] # type: ignore[attr-defined] |
| 207 | + self.assertEqual( |
| 208 | + lines, ["return cls._unregistered_member(value, 'Unassigned')"], |
| 209 | + f'{crawler} must emit exactly one returning line') |
| 210 | + |
| 211 | + |
| 212 | +class RegeneratedRegistryLookupTests(unittest.TestCase): |
| 213 | + """The behaviour the broken body produced, pinned on the generated files. |
| 214 | +
|
| 215 | + :file:`tests/const/test_const_enum_lookup.py` sweeps every registry and so |
| 216 | + already covers this; these two cases are named for #866 so the reported |
| 217 | + symptom is findable from the issue number. |
| 218 | +
|
| 219 | + """ |
| 220 | + |
| 221 | + def test_record_type_resolves_an_unassigned_value(self) -> None: |
| 222 | + from pcapkit.const.pcapng.record_type import RecordType |
| 223 | + |
| 224 | + member = RecordType(0xFFFF) |
| 225 | + self.assertEqual(member.name, 'Unassigned') |
| 226 | + self.assertEqual(int(member), 0xFFFF) |
| 227 | + self.assertNotIn('Unassigned', RecordType.__members__) |
| 228 | + |
| 229 | + def test_secrets_type_resolves_an_unassigned_value(self) -> None: |
| 230 | + from pcapkit.const.pcapng.secrets_type import SecretsType |
| 231 | + |
| 232 | + member = SecretsType(0) |
| 233 | + self.assertEqual(member.name, 'Unassigned') |
| 234 | + self.assertEqual(int(member), 0) |
| 235 | + self.assertNotIn('Unassigned', SecretsType.__members__) |
| 236 | + |
| 237 | + |
| 238 | +if __name__ == '__main__': |
| 239 | + unittest.main() |
0 commit comments