From 47849d1fc4e27866d4484903627a8cfa35c34511 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Fri, 25 Sep 2026 00:02:21 -0400 Subject: [PATCH] fix(reg): cast TransportProtocol.undefined so mypy infers its own type (#770) - mypy has no aenum plugin, so TransportProtocol is a plain class to it. undefined = 0 is a bare int literal, inferring as int, while the auto()-valued siblings infer as Any -- the only member that then disagreed with the four sites defaulting to it: the class attribute __transport__, and the proto parameter on __new__, get and get_all. - Wrap the literal in typing.cast in the BASE template (pcapkit/vendor/reg/apptype/apptype.py) so mypy infers TransportProtocol instead. cast is the identity function at run time, so undefined stays exactly 0 and still composes, and regenerating from a byte-identical cached IANA fetch changes only this member's comment and assignment in pcapkit/const/reg/apptype/apptype.py -- tcp/udp/sctp/dccp.py are untouched, member counts (6147/6143/91/10) unchanged, and every member's name/port/svc/proto/value is unchanged. - Give the member a one-line #: contract instead of the mypy rationale: Sphinx autodocs it with :undoc-members:, and would otherwise publish the cast's reasoning as the member's own rendered description. - Add tests pinning the fix: source-text shape (bounded failure messages, not raw assertIn/assertNotRegex against the ~200 KiB generated module), a direct mypy.api.run check, and a runtime guard that undefined is still 0 and composes. The mypy check skips inline when mypy is unavailable -- it is a Pipfile dev-package, not a pyproject.toml extra -- rather than a tracked HAS_MYPY gate, which was measured to break tests/test_tier_guard.py's DependencyGateCoverageTests (mypy has no MODULE_PROVIDERS entry); #779 tracks that gap. mypy on the whole package: 116 errors/39 files -> 112/38, matching lint.yml's existing pin (measured at 932cb48d1, unaffected by this). --- pcapkit/const/reg/apptype/apptype.py | 16 +- pcapkit/vendor/reg/apptype/apptype.py | 16 +- .../test_vendor_reg_apptype_generator_unit.py | 150 +++++++++++++++++- 3 files changed, 174 insertions(+), 8 deletions(-) diff --git a/pcapkit/const/reg/apptype/apptype.py b/pcapkit/const/reg/apptype/apptype.py index 896995920..b0e110666 100644 --- a/pcapkit/const/reg/apptype/apptype.py +++ b/pcapkit/const/reg/apptype/apptype.py @@ -9,7 +9,7 @@ which is automatically generated from :class:`pcapkit.vendor.reg.apptype.apptype.AppType`. """ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from aenum import IntFlag, StrEnum, auto, extend_enum @@ -26,7 +26,19 @@ class TransportProtocol(IntFlag): """Transport layer protocol.""" - undefined = 0 + # mypy has no aenum plugin, so this class is a plain class to it: a bare + # ``0`` here infers as int while the auto()-valued members below infer as + # Any, and only this member then disagrees with the TransportProtocol + # annotations that use it. cast is the identity function at run time, so + # this changes nothing that runs -- see GitHub issue #770. mypy.ini sets + # warn_redundant_casts, so if aenum ever ships type stubs letting it infer + # TransportProtocol on its own, this cast starts erroring instead of + # lingering as dead scaffolding. + #: No transport protocol. ``TransportProtocol(0) is undefined`` and + #: ``bool(undefined)`` is ``False``; it is the ``proto`` sentinel default + #: for ``__transport__``, ``__new__``, ``get`` and ``get_all``, and what + #: the base registry's ``_missing_`` extends unassigned/reserved rows from. + undefined = cast('TransportProtocol', 0) #: Transmission Control Protocol. tcp = auto() diff --git a/pcapkit/vendor/reg/apptype/apptype.py b/pcapkit/vendor/reg/apptype/apptype.py index ee387356d..abd97c150 100644 --- a/pcapkit/vendor/reg/apptype/apptype.py +++ b/pcapkit/vendor/reg/apptype/apptype.py @@ -102,7 +102,7 @@ which is automatically generated from :class:`{MODL}.{NAME}`. """ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from aenum import IntFlag, StrEnum, auto, extend_enum @@ -119,7 +119,19 @@ class TransportProtocol(IntFlag): """Transport layer protocol.""" - undefined = 0 + # mypy has no aenum plugin, so this class is a plain class to it: a bare + # ``0`` here infers as int while the auto()-valued members below infer as + # Any, and only this member then disagrees with the TransportProtocol + # annotations that use it. cast is the identity function at run time, so + # this changes nothing that runs -- see GitHub issue #770. mypy.ini sets + # warn_redundant_casts, so if aenum ever ships type stubs letting it infer + # TransportProtocol on its own, this cast starts erroring instead of + # lingering as dead scaffolding. + #: No transport protocol. ``TransportProtocol(0) is undefined`` and + #: ``bool(undefined)`` is ``False``; it is the ``proto`` sentinel default + #: for ``__transport__``, ``__new__``, ``get`` and ``get_all``, and what + #: the base registry's ``_missing_`` extends unassigned/reserved rows from. + undefined = cast('TransportProtocol', 0) #: Transmission Control Protocol. tcp = auto() diff --git a/tests/vendor/test_vendor_reg_apptype_generator_unit.py b/tests/vendor/test_vendor_reg_apptype_generator_unit.py index 8d43215e9..4e1d408e4 100644 --- a/tests/vendor/test_vendor_reg_apptype_generator_unit.py +++ b/tests/vendor/test_vendor_reg_apptype_generator_unit.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""Two mechanical shapes :mod:`pcapkit.vendor.reg.apptype.apptype` must keep. +"""Three mechanical shapes :mod:`pcapkit.vendor.reg.apptype.apptype` must keep. GitHub issue #744: every one of :class:`~pcapkit.const.reg.apptype.apptype.AppType`'s ~12,391 members used to be an *annotated* assignment -- @@ -28,10 +28,56 @@ source. The generator only ever emits the five declared names (``TRANSPORTS + ('undefined',)``), so the attribute form cannot miss. -Both are pinned two ways: directly against the generator's own +GitHub issue #770: ``undefined = 0`` in the ``BASE`` template is a bare int +literal, so mypy -- which has no :mod:`aenum` plugin and so treats this class +as a plain one -- infers the class attribute's type as ``int`` while the +``auto()``-valued siblings (``tcp``, ``udp``, ``sctp``, ``dccp``) infer as +``Any``. That is what made ``TransportProtocol.undefined`` disagree with its +own ``'TransportProtocol'`` annotation at the four sites that default to it -- +the class attribute ``__transport__``, and the ``proto`` parameter on +``__new__``, ``get`` and ``get_all``. The fix wraps the literal in +:func:`~typing.cast` so mypy infers ``TransportProtocol`` there too, without +changing what runs: :func:`~typing.cast` is the identity function at run time, +so ``TransportProtocol.undefined`` stays the same ``0`` -- ``TransportProtocol(0) +is undefined`` and ``bool(undefined)`` is ``False`` -- and the same genuine +``TransportProtocol`` member it always was: the ``proto`` sentinel default at +all four sites, and what the base registry's ``_missing_`` extends +unassigned/reserved rows from. Two rejected alternatives, measured rather than +argued: an explicit member annotation (``undefined: 'TransportProtocol' = 0``) +does not fix this at all, it only *relocates* the error -- mypy still reports +one ``[assignment]`` at the member's own declaration line, for 113 errors in +39 files rather than 116, so ``cast`` is preferred 0/38 against 1/39, not on +parity. ``auto() & 0`` is run-time identical to the bare literal -- value +``0``, no burned counter slot, ``tcp``/``udp``/``sctp``/``dccp`` unaffected -- +and is rejected only because it leans on :mod:`aenum`'s undocumented internal +order of operations for resolving a composed ``auto()``, not because it +misbehaves. + +The first two are pinned two ways: directly against the generator's own :meth:`~pcapkit.vendor.reg.apptype.apptype.AppType.flag`, which needs no network access since it is a ``@staticmethod``, and against the five files it -actually produced, committed under :mod:`pcapkit.const.reg.apptype`. +actually produced, committed under :mod:`pcapkit.const.reg.apptype`. #770 is +pinned against that same base module -- a plain source-text assertion that the +member stays wrapped in ``cast`` rather than reverting to a bare literal, plus +a direct run through mypy's own API when :mod:`mypy` is importable, since the +source-text shape alone cannot tell a correct fix from a differently-worded +one that stops mypy agreeing. That second pin skips outright when mypy is not +importable, the same shape +:class:`~tests.project.test_isort_clean.TestIsortIsCleanOnThePackage` uses for +isort: mypy is a :file:`Pipfile` ``[dev-packages]`` entry (line 48) and is in +no :file:`pyproject.toml` extra, so no ``pytest`` job in +:file:`.github/workflows/unit-tests.yml` -- every one installs ``.[test,...]`` +-- ever has it importable, and this test skips there rather than erroring. +That inline skip is, by #766's own description of the shape, invisible to +:mod:`tests._dependency_gates`'s own guard: gating it instead with a +``HAS_MYPY`` flag and ``@unittest.skipUnless`` was measured and rejected, not +merely not attempted -- ``mypy`` has no entry in that module's +``MODULE_PROVIDERS`` table and is in no :file:`pyproject.toml` extra to add +one for, so doing so breaks +:class:`~tests.test_tier_guard.DependencyGateCoverageTests` outright: a +``KeyError`` from :func:`~tests._dependency_gates.extras_providing` plus two +more failures raising ``AssertionError``, measured as 4 errors and 2 failures +of its 9 tests. #779 tracks closing that gap generally. """ @@ -41,7 +87,7 @@ class AppTypeGeneratorShapeTests(unittest.TestCase): - """Pins to the two mechanical changes #744 and #768 made.""" + """Pins to the three mechanical changes #744, #768 and #770 made.""" def test_flag_emits_attribute_access_not_get_calls(self) -> None: """GitHub issue #768, against the generator method directly. @@ -115,6 +161,102 @@ def test_no_transport_protocol_get_calls_survive_in_generated_output(self) -> No with self.subTest(module=mod.__name__): self.assertEqual(inspect.getsource(mod).count("TransportProtocol.get("), 0) + def test_undefined_member_is_cast_rather_than_a_bare_literal(self) -> None: + """GitHub issue #770, against the generated source text itself. + + ``undefined = 0`` is a bare int literal, so mypy infers the class + attribute's type as ``int`` while the ``auto()``-valued siblings + infer as ``Any`` -- mypy has no :mod:`aenum` plugin, so it never sees + this as an enum at all. Wrapping the literal in + :func:`~typing.cast` is what makes mypy infer ``TransportProtocol`` + instead, so the member has to stay wrapped rather than reverting to + the bare form that reintroduces the four ``[assignment]`` errors. + """ + import inspect + import re + + import pcapkit.const.reg.apptype.apptype as base_mod + + source = inspect.getsource(base_mod) + + # Neither check uses assertIn/assertNotRegex directly: both format + # their default failure message from the *whole* ~200 KiB generated + # module (assertIn via unittest.util.safe_repr(source, short=False), + # assertNotRegex the same way) -- measured at 219,113 chars for the + # first check alone. A plain containment test plus a bounded + # snippet on failure keeps a real failure's message short instead, + # for both checks alike. + if "undefined = cast('TransportProtocol', 0)" not in source: + self.fail("the cast is gone; expected \"undefined = cast('TransportProtocol', 0)\"") + + match = re.search(r'\n[ \t]*undefined = 0[ \t]*\n', source) + if match is not None: + self.fail('found a bare literal near: %r' + % source[max(0, match.start() - 40):match.end() + 40]) + + def test_undefined_member_infers_as_transport_protocol_under_mypy(self) -> None: + """GitHub issue #770, run directly through mypy's own API. + + The source-text shape checked above cannot tell a correct fix from a + differently-worded one that stops mypy agreeing, so this runs mypy + itself against just the generated base module and requires a clean + result -- the flags below are :file:`Makefile`'s own, but mypy also + picks up :file:`mypy.ini` by discovery from the current working + directory, exactly as ``make mypy`` does, so the effective config is + stricter than these four flags alone would suggest. Before the fix + this reproduces the issue's own repro exactly: 4 errors, all + ``[assignment]``, at the class attribute ``__transport__`` and the + ``proto`` default on ``__new__``, ``get`` and ``get_all``. + + This is not in :file:`.github/workflows/unit-tests.yml`'s reach -- + mypy is a :file:`Pipfile` ``[dev-packages]`` entry, not a + :file:`pyproject.toml` extra, so no ``pytest`` job there ever has it + importable -- and skips outright rather than erroring when it is not + installed. See this module's own docstring for why that inline skip, + rather than a tracked ``HAS_MYPY`` gate, is the deliberate choice. + """ + try: + from mypy import api as mypy_api + except ImportError: + self.skipTest('mypy is not installed') + + import pcapkit.const.reg.apptype.apptype as base_mod + + stdout, stderr, status = mypy_api.run([ + '--follow-imports=silent', + '--ignore-missing-imports', + '--show-column-numbers', + '--show-error-codes', + # Runs against a real file under the package, not a throwaway + # string, so leaving the default cache directory on would write + # a ~20 MiB .mypy_cache/ into the tree for one unit test -- most + # of this method's run time, and disk this test has no business + # spending. /dev/null is mypy's own documented "no cache" sentinel. + '--cache-dir=/dev/null', + base_mod.__file__, + ]) + + self.assertEqual(status, 0, msg=stdout + stderr) + self.assertNotIn('[assignment]', stdout) + + def test_transport_protocol_undefined_still_zero_and_composes(self) -> None: + """GitHub issue #770: the ``cast`` changes nothing at run time. + + :func:`~typing.cast` is the identity function at run time, so + ``TransportProtocol.undefined`` has to stay the same genuine + ``TransportProtocol`` member with value ``0`` -- ``TransportProtocol(0) + is undefined`` and ``bool(undefined)`` is ``False`` -- rather than + merely being *typed* as one. There is no flag composition naming + ``undefined`` in this module or its siblings to preserve; what the + value has to keep is its role as the ``proto`` sentinel default and + its neutrality under ``|``, checked directly below. + """ + from pcapkit.const.reg.apptype.apptype import TransportProtocol + + self.assertIsInstance(TransportProtocol.undefined, TransportProtocol) + self.assertEqual(int(TransportProtocol.undefined), 0) + self.assertIs(TransportProtocol.tcp | TransportProtocol.undefined, TransportProtocol.tcp) + if __name__ == '__main__': unittest.main()