Skip to content

Commit 61a68ed

Browse files
authored
Merge branch 'main' into fix/864-get-default-no-mint
2 parents 1607126 + 3173c4f commit 61a68ed

5 files changed

Lines changed: 243 additions & 8 deletions

File tree

‎pcapkit/const/pcapng/record_type.py‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,5 +39,4 @@ def _missing_(cls, value: 'int') -> 'RecordType':
3939
"""
4040
if not (isinstance(value, int) and 0 <= value <= 0xFFFF):
4141
raise ValueError('%r is not a valid %s' % (value, cls.__name__))
42-
cls._unregistered_member(value, 'Unassigned')
43-
return cls(value)
42+
return cls._unregistered_member(value, 'Unassigned')

‎pcapkit/const/pcapng/secrets_type.py‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,4 @@ def _missing_(cls, value: 'int') -> 'SecretsType':
3838
"""
3939
if not (isinstance(value, int) and 0x00000000 <= value <= 0xFFFFFFFF):
4040
raise ValueError('%r is not a valid %s' % (value, cls.__name__))
41-
cls._unregistered_member(value, 'Unassigned')
42-
return cls(value)
41+
return cls._unregistered_member(value, 'Unassigned')

‎pcapkit/vendor/pcapng/record_type.py‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,7 @@ def process(self, data: 'list[Tag]') -> 'tuple[list[str], list[str]]':
6464
"""
6565
enum = [] # type: list[str]
6666
miss = [
67-
"cls._unregistered_member(value, 'Unassigned')",
68-
'return cls(value)'
67+
"return cls._unregistered_member(value, 'Unassigned')",
6968
]
7069
for content in data:
7170
name = content.select('td')[0].text.strip()

‎pcapkit/vendor/pcapng/secrets_type.py‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,7 @@ def process(self, data: 'dict[int, str]') -> 'tuple[list[str], list[str]]': # t
6868
"""
6969
enum = [] # type: list[str]
7070
miss = [
71-
"cls._unregistered_member(value, 'Unassigned')",
72-
'return cls(value)'
71+
"return cls._unregistered_member(value, 'Unassigned')",
7372
]
7473
for code, name in DATA.items():
7574
renm = self.rename(name, code) # type: ignore[arg-type]
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
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

Comments
 (0)