diff --git a/pcapkit/const/ftp/command.py b/pcapkit/const/ftp/command.py index adedba34df..96c2c20ab8 100644 --- a/pcapkit/const/ftp/command.py +++ b/pcapkit/const/ftp/command.py @@ -13,7 +13,9 @@ from typing import TYPE_CHECKING -from aenum import IntEnum, IntFlag, StrEnum, auto, extend_enum +from aenum import IntEnum, IntFlag, StrEnum, auto + +from pcapkit.corekit.enum import EnumRegistry if TYPE_CHECKING: from typing import Optional, Type @@ -21,9 +23,29 @@ __all__ = ['Command'] -class FEATCode(StrEnum): +class FEATCode(EnumRegistry, StrEnum): """Keyword returned in FEAT response line for this command/extension, - c.f., :rfc:`5797#secion-3`.""" + c.f., :rfc:`5797#secion-3`. + + .. note:: + + Declares every FEAT keyword the IANA registry's own ``FEAT code`` + column names -- the 5 group markers below plus the per-command + keywords generated after them (data-driven, not hand-picked; see + :meth:`~pcapkit.vendor.ftp.command.Command.process`) -- rather than + minting the per-command ones at import time as an incidental side + effect of building :class:`Command`'s own rows. GitHub issue #860: + that import-time mutation was the same defect shape #861 removed + from :class:`~pcapkit.const.pcapng.filter_type.FilterType`, just not + previously noticed here. ``_missing_`` still unmints for a keyword + that turns up on the wire but names none of these -- the + extendability the owner asked to keep, verbatim: *"If it is expected + to be handled as our current approach in industry convention, then + we keep it extendable as is."* No custom ``__new__`` here, so the + base's generic :meth:`~pcapkit.corekit.enum.EnumRegistry. + _unregistered_member` needs no override. + + """ #: FTP standard commands [:rfc:`0959`]. base = '' @@ -36,6 +58,36 @@ class FEATCode(StrEnum): #: FTP Extensions for NAT/IPv6 [:rfc:`2428`]. nat6 = '' + #: Authentication/Security Mechanism [2][:rfc:`2773`][:rfc:`4217`] + AUTH = 'AUTH' + + #: Hostname [:rfc:`7151`] + HOST = 'HOST' + + #: Language (for Server Messages) [:rfc:`2640`] + UTF8 = 'UTF8' + + #: File Modification Time [:rfc:`3659`] + MDTM = 'MDTM' + + #: List Directory (for machine) [:rfc:`3659`] + MLST = 'MLST' + + #: Protection Buffer Size [:rfc:`4217`] + PBSZ = 'PBSZ' + + #: Data Channel Protection Level [:rfc:`4217`] + PROT = 'PROT' + + #: Restart (for STREAM mode) [3][:rfc:`3659`] + REST = 'REST' + + #: File Size [:rfc:`3659`] + SIZE = 'SIZE' + + #: Trivial Virtual File Store [:rfc:`3659`] + TVFS = 'TVFS' + def __repr__(self) -> 'str': return f'<{self.__class__.__name__} [{self._name_}]>' @@ -49,7 +101,7 @@ def _missing_(cls, value: 'str') -> 'FEATCode': """ if not isinstance(value, str): raise ValueError(f'{value!r} is not a valid {cls.__name__}') - return extend_enum(cls, value.upper(), value) + return cls._unregistered_member(value, value.upper()) class CommandType(IntFlag): @@ -88,8 +140,22 @@ class ConformanceRequirement(IntEnum): H = auto() -class Command(StrEnum): - """[Command] FTP Command""" +class Command(EnumRegistry, StrEnum): + """[Command] FTP Command + + .. note:: + + Neither ``_missing_`` nor ``get()`` mints any more, per the owner's + ruling on GitHub issue #860: *"only IANA registered ones are legit + values and we need register to properly create new entries. get will + not have sufficient information to create new ones."* Concretely + true here -- a bare wire command word carries no + :attr:`feat`/:attr:`desc`/:attr:`type`/:attr:`conf`, so minting one + used to register a permanent member with all four hollowed out to + their defaults; :meth:`register` is the path that can actually supply + them. + + """ if TYPE_CHECKING: #: Feature code. Keyword returned in FEAT response line for this command/extension, @@ -137,7 +203,7 @@ def __repr__(self) -> 'str': APPE: 'Command' = 'APPE', FEATCode.base, 'Append (with create)', CommandType.S, ConformanceRequirement.M #: Authentication/Security Mechanism [2][:rfc:`2773`][:rfc:`4217`] - AUTH: 'Command' = 'AUTH', FEATCode('AUTH'), 'Authentication/Security Mechanism', CommandType.A, ConformanceRequirement.O + AUTH: 'Command' = 'AUTH', FEATCode.AUTH, 'Authentication/Security Mechanism', CommandType.A, ConformanceRequirement.O #: Clear Command Channel [:rfc:`2228`] CCC: 'Command' = 'CCC', FEATCode.secu, 'Clear Command Channel', CommandType.A, ConformanceRequirement.O @@ -170,10 +236,10 @@ def __repr__(self) -> 'str': HELP: 'Command' = 'HELP', FEATCode.base, 'Help', CommandType.S, ConformanceRequirement.M #: Hostname [:rfc:`7151`] - HOST: 'Command' = 'HOST', FEATCode('HOST'), 'Hostname', CommandType.A, ConformanceRequirement.O + HOST: 'Command' = 'HOST', FEATCode.HOST, 'Hostname', CommandType.A, ConformanceRequirement.O #: Language (for Server Messages) [:rfc:`2640`] - LANG: 'Command' = 'LANG', FEATCode('UTF8'), 'Language (for Server Messages)', CommandType.P, ConformanceRequirement.O + LANG: 'Command' = 'LANG', FEATCode.UTF8, 'Language (for Server Messages)', CommandType.P, ConformanceRequirement.O #: List [:rfc:`959`][:rfc:`1123`] LIST: 'Command' = 'LIST', FEATCode.base, 'List', CommandType.S, ConformanceRequirement.M @@ -185,7 +251,7 @@ def __repr__(self) -> 'str': LPSV: 'Command' = 'LPSV', FEATCode.hist, 'Passive Mode', CommandType.P, ConformanceRequirement.H #: File Modification Time [:rfc:`3659`] - MDTM: 'Command' = 'MDTM', FEATCode('MDTM'), 'File Modification Time', CommandType.S, ConformanceRequirement.O + MDTM: 'Command' = 'MDTM', FEATCode.MDTM, 'File Modification Time', CommandType.S, ConformanceRequirement.O #: Integrity Protected Command [:rfc:`2228`][:rfc:`2773`][:rfc:`4217`] MIC: 'Command' = 'MIC', FEATCode.secu, 'Integrity Protected Command', CommandType.A, ConformanceRequirement.O @@ -194,10 +260,10 @@ def __repr__(self) -> 'str': MKD: 'Command' = 'MKD', FEATCode.base, 'Make Directory', CommandType.S, ConformanceRequirement.O #: List Directory (for machine) [:rfc:`3659`] - MLSD: 'Command' = 'MLSD', FEATCode('MLST'), 'List Directory (for machine)', CommandType.S, ConformanceRequirement.O + MLSD: 'Command' = 'MLSD', FEATCode.MLST, 'List Directory (for machine)', CommandType.S, ConformanceRequirement.O #: List Single Object [:rfc:`3659`] - MLST: 'Command' = 'MLST', FEATCode('MLST'), 'List Single Object', CommandType.S, ConformanceRequirement.O + MLST: 'Command' = 'MLST', FEATCode.MLST, 'List Single Object', CommandType.S, ConformanceRequirement.O #: Transfer Mode [:rfc:`959`] MODE: 'Command' = 'MODE', FEATCode.base, 'Transfer Mode', CommandType.P, ConformanceRequirement.M @@ -218,13 +284,13 @@ def __repr__(self) -> 'str': PASV: 'Command' = 'PASV', FEATCode.base, 'Passive Mode', CommandType.P, ConformanceRequirement.M #: Protection Buffer Size [:rfc:`4217`] - PBSZ: 'Command' = 'PBSZ', FEATCode('PBSZ'), 'Protection Buffer Size', CommandType.P, ConformanceRequirement.O + PBSZ: 'Command' = 'PBSZ', FEATCode.PBSZ, 'Protection Buffer Size', CommandType.P, ConformanceRequirement.O #: Data Port [:rfc:`959`] PORT: 'Command' = 'PORT', FEATCode.base, 'Data Port', CommandType.P, ConformanceRequirement.M #: Data Channel Protection Level [:rfc:`4217`] - PROT: 'Command' = 'PROT', FEATCode('PROT'), 'Data Channel Protection Level', CommandType.P, ConformanceRequirement.O + PROT: 'Command' = 'PROT', FEATCode.PROT, 'Data Channel Protection Level', CommandType.P, ConformanceRequirement.O #: Print Directory [:rfc:`959`] PWD: 'Command' = 'PWD', FEATCode.base, 'Print Directory', CommandType.S, ConformanceRequirement.O @@ -236,7 +302,7 @@ def __repr__(self) -> 'str': REIN: 'Command' = 'REIN', FEATCode.base, 'Reinitialize', CommandType.A, ConformanceRequirement.M #: Restart (for STREAM mode) [3][:rfc:`3659`] - REST: 'Command' = 'REST', FEATCode('REST'), 'Restart (for STREAM mode)', CommandType.S | CommandType.P, ConformanceRequirement.M + REST: 'Command' = 'REST', FEATCode.REST, 'Restart (for STREAM mode)', CommandType.S | CommandType.P, ConformanceRequirement.M #: Retrieve [:rfc:`959`] RETR: 'Command' = 'RETR', FEATCode.base, 'Retrieve', CommandType.S, ConformanceRequirement.M @@ -254,7 +320,7 @@ def __repr__(self) -> 'str': SITE: 'Command' = 'SITE', FEATCode.base, 'Site Parameters', CommandType.S, ConformanceRequirement.M #: File Size [:rfc:`3659`] - SIZE: 'Command' = 'SIZE', FEATCode('SIZE'), 'File Size', CommandType.S, ConformanceRequirement.O + SIZE: 'Command' = 'SIZE', FEATCode.SIZE, 'File Size', CommandType.S, ConformanceRequirement.O #: Structure Mount [:rfc:`959`] SMNT: 'Command' = 'SMNT', FEATCode.base, 'Structure Mount', CommandType.A, ConformanceRequirement.O @@ -296,7 +362,46 @@ def __repr__(self) -> 'str': XRMD: 'Command' = 'XRMD', FEATCode.hist, None, CommandType.S, ConformanceRequirement.H #: Trivial Virtual File Store [:rfc:`3659`] - TVFS: 'Command' = 'TVFS', FEATCode('TVFS'), 'Trivial Virtual File Store', CommandType.P, ConformanceRequirement.O + TVFS: 'Command' = 'TVFS', FEATCode.TVFS, 'Trivial Virtual File Store', CommandType.P, ConformanceRequirement.O + + @classmethod + def _unregistered_member(cls, value: 'str', name: 'str') -> 'Command': + """Build a member absent from this registry's own lookup tables. + + Leaves :attr:`feat`, :attr:`desc`, :attr:`type` and :attr:`conf` at + the same defaults :meth:`__new__` itself would, rather than missing + entirely -- :meth:`~pcapkit.corekit.enum.EnumRegistry. + _unregistered_member` bypasses :meth:`__new__` (it calls + :class:`str`'s directly), so those four attributes would otherwise + be absent and :meth:`__repr__` (which reads :attr:`desc`) would + raise on the result. There is no more specific value to reconstruct + them from -- a bare wire command word carries none of the four, + which is exactly the owner's reasoning for why ``get()``/ + ``_missing_`` must not mint one: :meth:`register` is the path that + can actually supply them. + + Args: + value: Value to get enum item -- the convention here, shared with + :class:`~pcapkit.const.ftp.command.FEATCode` and + :class:`~pcapkit.const.http.method.Method`, is the caller's + own casing, unchanged: an unregistered member's *value* is + exactly what was observed on the wire, matching how a + *registered* member's own value is exactly what was + declared, never reformatted. Only :attr:`name` -- the + identifier, not the value -- is canonicalised. + name: Bare label for the unregistered member -- here, the + canonical upper-case form of ``value``, matching the name + every *registered* member of this class is looked up by, + since #860's open-vocabulary registries have no manufactured + placeholder label to fall back to. + + """ + obj = super()._unregistered_member(value, name) + obj.feat = None + obj.desc = None + obj.type = CommandType.undefined + obj.conf = ConformanceRequirement.O + return obj @staticmethod def get(key: 'str', default: 'Optional[str]' = None) -> 'Command': @@ -310,8 +415,20 @@ def get(key: 'str', default: 'Optional[str]' = None) -> 'Command': :meta private: """ name = key.upper() - if name not in Command._member_map_: # pylint: disable=no-member - return extend_enum(Command, name, default if default is not None else key) + if name not in Command._member_map_: # type: ignore[misc] # pylint: disable=no-member + # NOTE: the value is ``default`` if the caller supplied one, or + # else ``key`` exactly as given -- never ``name`` -- so an + # unregistered member's value is the caller's own casing, the + # same convention :meth:`_unregistered_member` documents and + # :class:`~pcapkit.const.ftp.command.FEATCode` already followed + # unchanged. Two calls naming the same command in different + # case, e.g. ``get('xyzw')`` and ``get('XYZW')``, therefore build + # results that are *not* equal -- each is exactly what its own + # caller passed, which minting's ``_member_map_`` cache used to + # paper over by returning the *first* casing seen for every + # later call regardless of case. Losing that is the one + # observable behaviour change in GitHub issue #860's conversion. + return Command._unregistered_member(default if default is not None else key, name) return Command[name] # type: ignore[misc] @classmethod @@ -328,4 +445,4 @@ def _missing_(cls, value: 'str') -> 'Command': name = value.upper() if name in cls._member_map_: return cls._member_map_[name] # type: ignore[return-value] - return extend_enum(cls, name, value) + return cls._unregistered_member(value, name) diff --git a/pcapkit/const/ftp/return_code.py b/pcapkit/const/ftp/return_code.py index 7adbd54220..b51afa0ddf 100644 --- a/pcapkit/const/ftp/return_code.py +++ b/pcapkit/const/ftp/return_code.py @@ -13,7 +13,9 @@ from typing import TYPE_CHECKING -from aenum import IntEnum, extend_enum +from aenum import IntEnum + +from pcapkit.corekit.enum import EnumRegistry if TYPE_CHECKING: from typing import Optional, Type @@ -31,7 +33,7 @@ } # type: dict[str, str] -class ResponseKind(IntEnum): +class ResponseKind(EnumRegistry, IntEnum): """Response kind; whether the response is good, bad or incomplete.""" PositivePreliminary = 1 @@ -50,11 +52,12 @@ def _missing_(cls, value: 'int') -> 'ResponseKind': """ if isinstance(value, int) and 0 <= value <= 9: - return extend_enum(cls, 'Unknown_%d' % value, value) + #: Unknown + return cls._unregistered_member(value, 'Unknown') return super()._missing_(value) -class GroupingInformation(IntEnum): +class GroupingInformation(EnumRegistry, IntEnum): """Grouping information.""" Syntax = 0 @@ -73,11 +76,12 @@ def _missing_(cls, value: 'int') -> 'GroupingInformation': """ if isinstance(value, int) and 0 <= value <= 9: - return extend_enum(cls, 'Unknown_%d' % value, value) + #: Unknown + return cls._unregistered_member(value, 'Unknown') return super()._missing_(value) -class ReturnCode(IntEnum): +class ReturnCode(EnumRegistry, IntEnum): """[ReturnCode] FTP Server Return Code""" if TYPE_CHECKING: @@ -280,28 +284,29 @@ def __str__(self) -> 'str': #: Confidentiality protected reply. CODE_633: 'ReturnCode' = 633, 'Confidentiality protected reply.' - @staticmethod - def get(key: 'int | str', default: 'int' = -1) -> 'ReturnCode': - """Backport support for original codes. + @classmethod + def _unregistered_member(cls, value: 'int', name: 'str') -> 'ReturnCode': + """Build a member absent from this registry's own lookup tables. + + Reconstructs :attr:`description`, :attr:`kind` and :attr:`group` the + same way :meth:`__new__` would, rather than leaving them unset -- + :meth:`~pcapkit.corekit.enum.EnumRegistry._unregistered_member` + bypasses :meth:`__new__` entirely (it calls :class:`int`'s directly), + so those three attributes would otherwise be missing from the result + and :meth:`__str__`/:meth:`__repr__` would raise on it. Args: - key: Key to get enum item. - default: Default value if not found. The placeholder ``-1`` stands - for *no default*, in which case an unresolvable key propagates - the lookup error instead of falling back. + value: Value to get enum item. + name: Bare label for the unregistered member, per the ranged + mint/unmint criterion. - :meta private: """ - if isinstance(key, int): - try: - return ReturnCode(key) - except ValueError: - if default == -1: - raise - return ReturnCode(default) - if key not in ReturnCode._member_map_: # pylint: disable=no-member - return extend_enum(ReturnCode, key, default) - return ReturnCode[key] # type: ignore[misc] + obj = super()._unregistered_member(value, name) + code = str(value) + obj.description = None + obj.kind = ResponseKind(int(code[0])) + obj.group = GroupingInformation(int(code[1])) + return obj @classmethod def _missing_(cls, value: 'int') -> 'ReturnCode': @@ -313,4 +318,5 @@ def _missing_(cls, value: 'int') -> 'ReturnCode': """ if not (isinstance(value, int) and 100 <= value <= 659): raise ValueError('%r is not a valid %s' % (value, cls.__name__)) - return extend_enum(cls, 'CODE_%s' % value, value) + #: Unassigned + return cls._unregistered_member(value, 'Unassigned') diff --git a/pcapkit/const/http/method.py b/pcapkit/const/http/method.py index 527561dd4f..5b0a5780d3 100644 --- a/pcapkit/const/http/method.py +++ b/pcapkit/const/http/method.py @@ -12,15 +12,30 @@ from typing import TYPE_CHECKING -from aenum import StrEnum, extend_enum +from aenum import StrEnum + +from pcapkit.corekit.enum import EnumRegistry if TYPE_CHECKING: from typing import Optional, Type __all__ = ['Method'] -class Method(StrEnum): - """[Method] HTTP Method""" +class Method(EnumRegistry, StrEnum): + """[Method] HTTP Method + + .. note:: + + Neither ``_missing_`` nor ``get()`` mints any more, per the owner's + ruling on GitHub issue #860: *"only IANA registered ones are legit + values and we need register to properly create new entries. get will + not have sufficient information to create new ones."* Concretely true + here -- a bare wire method verb carries no + :attr:`safe`/:attr:`idempotent`, so minting one used to register a + permanent member with both hollowed out to their defaults; + :meth:`register` is the path that can actually supply them. + + """ if TYPE_CHECKING: #: Safe method. @@ -162,6 +177,56 @@ def __repr__(self) -> 'str': #: VERSION-CONTROL [:rfc:`3253#section-3.5`] VERSION_CONTROL = 'VERSION-CONTROL', False, True + @classmethod + def _unregistered_member(cls, value: 'str', name: 'str') -> 'Method': + """Build a member absent from this registry's own lookup tables. + + Leaves :attr:`safe` and :attr:`idempotent` at the same defaults + :meth:`__new__` itself would, rather than missing entirely -- + :meth:`~pcapkit.corekit.enum.EnumRegistry._unregistered_member` + bypasses :meth:`__new__` (it calls :class:`str`'s directly), so + those two attributes would otherwise be absent. There is no more + specific value to reconstruct them from -- a bare wire method verb + carries neither, which is exactly the owner's reasoning for why + ``get()``/``_missing_`` must not mint one: :meth:`register` is the + path that can actually supply them. + + Args: + value: Value to get enum item -- the convention here, shared with + :class:`~pcapkit.const.ftp.command.FEATCode` and + :class:`~pcapkit.const.ftp.command.Command`, is the caller's + own casing, unchanged: an unregistered member's *value* is + exactly what was observed on the wire, matching how a + *registered* member's own value is exactly what was + declared, never reformatted. Only :attr:`name` -- the + identifier, not the value -- is canonicalised. + + This is deliberately *not* the same as what a *registered* + member of this class carries, though, and that asymmetry is + left as-is here rather than fixed: :meth:`__new__` itself is + untouched, and it calls ``str.__new__(cls)`` with no + argument at all, so every one of the 40 declared members' + underlying :class:`str` payload is permanently empty + regardless of ``value`` (``str(Method.GET) == ''``, and + ``Method.GET == 'GET'`` is :obj:`False`) -- true on ``main`` + as well as here. An *unregistered* member built through this + method, by contrast, now carries real content + (``str(Method('frob')) == 'frob'``). Tracked as GitHub issue + #870 rather than fixed in this PR: changing :meth:`__new__` + changes what all 40 public members compare equal to, which + is its own review. + name: Bare label for the unregistered member -- here, the + canonical upper-case form of ``value``, matching the name + every *registered* member of this class is looked up by, + since #860's open-vocabulary registries have no manufactured + placeholder label to fall back to. + + """ + obj = super()._unregistered_member(value, name) + obj.safe = False + obj.idempotent = False + return obj + @staticmethod def get(key: 'str', default: 'Optional[str]' = None) -> 'Method': """Backport support for original codes. @@ -174,8 +239,20 @@ def get(key: 'str', default: 'Optional[str]' = None) -> 'Method': :meta private: """ name = key.upper() - if name not in Method._member_map_: # pylint: disable=no-member - return extend_enum(Method, name, default if default is not None else key) + if name not in Method._member_map_: # type: ignore[misc] # pylint: disable=no-member + # NOTE: the value is ``default`` if the caller supplied one, or + # else ``key`` exactly as given -- never ``name`` -- so an + # unregistered member's value is the caller's own casing, the + # same convention :meth:`_unregistered_member` documents and + # :class:`~pcapkit.const.ftp.command.FEATCode` already followed + # unchanged. Two calls naming the same method in different + # case, e.g. ``get('frob')`` and ``get('FROB')``, therefore build + # results that are *not* equal -- each is exactly what its own + # caller passed, which minting's ``_member_map_`` cache used to + # paper over by returning the *first* casing seen for every + # later call regardless of case. Losing that is the one + # observable behaviour change in GitHub issue #860's conversion. + return Method._unregistered_member(default if default is not None else key, name) return Method[name] # type: ignore[misc] @classmethod @@ -192,4 +269,4 @@ def _missing_(cls, value: 'str') -> 'Method': name = value.upper() if name in cls._member_map_: return cls._member_map_[name] # type: ignore[return-value] - return extend_enum(cls, name, value) + return cls._unregistered_member(value, name) diff --git a/pcapkit/const/http/status_code.py b/pcapkit/const/http/status_code.py index acd191b903..3d0f86dc63 100644 --- a/pcapkit/const/http/status_code.py +++ b/pcapkit/const/http/status_code.py @@ -13,7 +13,9 @@ from typing import TYPE_CHECKING -from aenum import IntEnum, extend_enum +from aenum import IntEnum + +from pcapkit.corekit.enum import EnumRegistry if TYPE_CHECKING: from typing import Type @@ -21,7 +23,7 @@ __all__ = ['StatusCode'] -class StatusCode(IntEnum): +class StatusCode(EnumRegistry, IntEnum): """[StatusCode] HTTP Status Code""" if TYPE_CHECKING: @@ -246,28 +248,26 @@ def __str__(self) -> 'str': #: Network Authentication Required [:rfc:`6585`] CODE_511 = 511, 'Network Authentication Required' - @staticmethod - def get(key: 'int | str', default: 'int' = -1) -> 'StatusCode': - """Backport support for original codes. + @classmethod + def _unregistered_member(cls, value: 'int', name: 'str') -> 'StatusCode': + """Build a member absent from this registry's own lookup tables. + + Reconstructs :attr:`message` the same way :meth:`__new__` would, + rather than leaving it unset -- :meth:`~pcapkit.corekit.enum. + EnumRegistry._unregistered_member` bypasses :meth:`__new__` entirely + (it calls :class:`int`'s directly), so :attr:`message` would + otherwise be missing from the result and :meth:`__str__` would raise + on it. Args: - key: Key to get enum item. - default: Default value if not found. The placeholder ``-1`` stands - for *no default*, in which case an unresolvable key propagates - the lookup error instead of falling back. + value: Value to get enum item. + name: Bare label for the unregistered member, per the ranged + mint/unmint criterion. - :meta private: """ - if isinstance(key, int): - try: - return StatusCode(key) - except ValueError: - if default == -1: - raise - return StatusCode(default) - if key not in StatusCode._member_map_: # pylint: disable=no-member - extend_enum(StatusCode, key, default) - return StatusCode[key] # type: ignore[misc] + obj = super()._unregistered_member(value, name) + obj.message = name + return obj @classmethod def _missing_(cls, value: 'int') -> 'StatusCode': @@ -281,26 +281,26 @@ def _missing_(cls, value: 'int') -> 'StatusCode': raise ValueError('%r is not a valid %s' % (value, cls.__name__)) if 105 <= value <= 199: #: Unassigned - return extend_enum(cls, 'CODE_%d' % value, value, 'Unassigned') + return cls._unregistered_member(value, 'Unassigned') if 209 <= value <= 225: #: Unassigned - return extend_enum(cls, 'CODE_%d' % value, value, 'Unassigned') + return cls._unregistered_member(value, 'Unassigned') if 227 <= value <= 299: #: Unassigned - return extend_enum(cls, 'CODE_%d' % value, value, 'Unassigned') + return cls._unregistered_member(value, 'Unassigned') if 309 <= value <= 399: #: Unassigned - return extend_enum(cls, 'CODE_%d' % value, value, 'Unassigned') + return cls._unregistered_member(value, 'Unassigned') if 419 <= value <= 420: #: Unassigned - return extend_enum(cls, 'CODE_%d' % value, value, 'Unassigned') + return cls._unregistered_member(value, 'Unassigned') if 432 <= value <= 450: #: Unassigned - return extend_enum(cls, 'CODE_%d' % value, value, 'Unassigned') + return cls._unregistered_member(value, 'Unassigned') if 452 <= value <= 499: #: Unassigned - return extend_enum(cls, 'CODE_%d' % value, value, 'Unassigned') + return cls._unregistered_member(value, 'Unassigned') if 512 <= value <= 599: #: Unassigned - return extend_enum(cls, 'CODE_%d' % value, value, 'Unassigned') + return cls._unregistered_member(value, 'Unassigned') return super()._missing_(value) diff --git a/pcapkit/const/pcapng/option_type.py b/pcapkit/const/pcapng/option_type.py index 4c994b75be..8633c4e337 100644 --- a/pcapkit/const/pcapng/option_type.py +++ b/pcapkit/const/pcapng/option_type.py @@ -13,7 +13,9 @@ from collections import defaultdict from typing import TYPE_CHECKING -from aenum import StrEnum, extend_enum +from aenum import StrEnum + +from pcapkit.corekit.enum import EnumRegistry __all__ = ['OptionType'] @@ -21,7 +23,7 @@ from typing import Any, DefaultDict, Optional, Type -class OptionType(StrEnum): +class OptionType(EnumRegistry, StrEnum): """[OptionType] Option Types""" if TYPE_CHECKING: @@ -197,6 +199,41 @@ def __hash__(self) -> 'int': #: pack_hash pack_hash: 'OptionType' = 3, 'pack_hash' + @classmethod + def _unregistered_member(cls, value: 'int', name: 'str') -> 'OptionType': + """Build a member absent from this registry's own lookup tables. + + Cannot reuse the base's generic construction -- + :meth:`~pcapkit.corekit.enum.EnumRegistry._unregistered_member` calls + :class:`str`'s ``__new__`` directly on the raw ``value``, but + OptionType stores a *formatted* string (``'{name} [{value}]'``) as + its own :attr:`~aenum.Enum._value_`, not ``value`` itself, so that + approach would build a member whose value looks nothing like this + registry's real members. + + Deliberately does **not** add to :attr:`__members_ns__` either -- + that dict is this registry's own second lookup table, alongside + :attr:`~aenum.Enum._value2member_map_`, and growing it would defeat + the whole point of an *unregistered* member exactly as growing + ``_value2member_map_`` would. + + Args: + value: Value to get enum item. + name: Bare label for the unregistered member, per the ranged + mint/unmint criterion. + + """ + temp = '%s [%d]' % (name, value) + + obj = str.__new__(cls, temp) + obj._name_ = name # pylint: disable=attribute-defined-outside-init + obj._value_ = temp + + obj.opt_name = name + obj.opt_value = value + + return obj + @staticmethod def get(key: 'int | str', default: 'int' = -1, *, namespace: 'str' = 'opt') -> 'OptionType': """Backport support for original codes. @@ -213,10 +250,25 @@ def get(key: 'int | str', default: 'int' = -1, *, namespace: 'str' = 'opt') -> ' temp_ns.update(OptionType.__members_ns__.get(namespace, {})) if key in temp_ns: return temp_ns[key] - return extend_enum(OptionType, '%s_unknown_%d' % (namespace, key), key, '%s_unknown' % namespace) + # NOTE: GitHub issue #860. This used to extend_enum(...) a + # permanent member as a side effect of a mere lookup -- exactly + # the ``get``/``_missing_`` minting the owner's ruling forbids, + # here on the wire-facing path + # (:meth:`pcapkit.protocols.misc.pcapng.PCAPNG._make_pcapng_options` + # calls this with raw option-code bytes off the wire). + # ``_unregistered_member`` never touches ``__members_ns__`` + # either, so a genuinely unknown option code no longer grows + # either lookup table. + return OptionType._unregistered_member(key, '%s_unknown' % namespace) if key in OptionType.__members__: return getattr(OptionType, key) - return extend_enum(OptionType, key, default, key) + # NOTE: same ruling, the str-keyed path: this used to mint ``key`` + # itself as the member's name with ``default`` as its value. Neither + # ``get`` nor ``_missing_`` has enough information to register one + # properly -- there is no namespace, description or anything else + # IANA would attach to it -- so this now returns an unregistered + # pseudo-member with the same attributes the old mint would have set. + return OptionType._unregistered_member(default, key) @classmethod def _missing_(cls, value: 'int') -> 'OptionType': @@ -230,4 +282,4 @@ def _missing_(cls, value: 'int') -> 'OptionType': raise ValueError('%r is not a valid %s' % (value, cls.__name__)) if value in cls.__members_ns__.get('opt', {}): return cls.__members_ns__['opt'][value] - return extend_enum(cls, 'opt_unknown_%d' % value, value, 'opt_unknown') + return cls._unregistered_member(value, 'opt_unknown') diff --git a/pcapkit/corekit/enum.py b/pcapkit/corekit/enum.py index 6e2f6c479b..2df33b767b 100644 --- a/pcapkit/corekit/enum.py +++ b/pcapkit/corekit/enum.py @@ -290,10 +290,26 @@ def get(cls, key: 'Any', default: 'Any' = NO_DEFAULT) -> 'Self': matching what already happened for a name that resolves today. The value side of that check is a plain ``_value2member_map_`` lookup, not ``cls(key)``: on a registry whose own ``_missing_`` mints for an - unrecognised value -- :class:`~pcapkit.const.ftp.command.FEATCode`'s - does, directly, via :func:`~aenum.extend_enum` -- routing a failed - *name* lookup through the constructor would let a mere ``get()`` call - mint a permanent member where it previously just raised. Restricting + unrecognised value, routing a failed *name* lookup through the + constructor would let a mere ``get()`` call mint a permanent member + where it previously just raised. Defensive rather than observed: of + the 119 classes that mix in this base, the ``str``-valued ones + (:class:`~pcapkit.const.ftp.command.Command`, :class:`~pcapkit.const. + ftp.command.FEATCode`, :class:`~pcapkit.const.http.method.Method`, + :class:`~pcapkit.const.pcapng.option_type.OptionType`) no longer mint + on any path as of GitHub issue #860, so no live witness exists in + this tree today. The registries that still mint directly via + :func:`~aenum.extend_enum` -- + :class:`~pcapkit.const.ipx.socket.Socket`, + :class:`~pcapkit.const.mh.cga_type.CGAType` and + :class:`~pcapkit.const.reg.ethertype.EtherType` -- are all + :class:`int`-valued, so a ``str`` name could not reach their mint + branches even if this restriction did not exist; they are not + exceptions to it, just not reachable by it. This is about a future + ``str``-valued registry (or a present one whose ``_missing_`` + someday changes) reaching this base with a minting ``_missing_`` of + its own, which the restriction below is written to stay correct + for regardless. Restricting the value side of ``key`` to an already-registered value keeps *that side* non-minting on every ``str``-valued registry, not only the ones without a minting ``_missing_``. Since #864, that is no longer merely diff --git a/pcapkit/protocols/schema/misc/pcapng.py b/pcapkit/protocols/schema/misc/pcapng.py index 324eecac0d..4cbe91b294 100644 --- a/pcapkit/protocols/schema/misc/pcapng.py +++ b/pcapkit/protocols/schema/misc/pcapng.py @@ -573,19 +573,57 @@ def post_process(self, value: 'int | bytes', packet: 'dict[str, Any]') -> 'Enum_ issue #575. Notes: - :meth:`~pcapkit.const.pcapng.option_type.OptionType.get` mints a + Until GitHub issue #860, + :meth:`~pcapkit.const.pcapng.option_type.OptionType.get` minted a fresh member -- via :func:`aenum.extend_enum` -- for any code - neither namespace's row covers, and does so unconditionally on a - miss: unlike :class:`~pcapkit.const.reg.apptype.AppType`, its - ``_missing_`` never declines, so it cannot be consulted the way - :meth:`pcapkit.protocols.schema.transport.tcp.PortEnumField.post_process` - consults :class:`~pcapkit.const.reg.apptype.AppType`'s. This - instead replicates the read-only membership test - :meth:`~pcapkit.const.pcapng.option_type.OptionType.get` itself - runs first, and only calls it once that test finds the code - already declared, so an undeclared option type gets - :meth:`EnumField._unregistered_member` instead of a fresh - registry row. + neither namespace's row covers, unconditionally on a miss. #860 + converted that miss path to + :meth:`~pcapkit.corekit.enum.EnumRegistry._unregistered_member` + instead, so calling it now would no longer grow the registry + either. This still does not call it unconditionally, though, for + a reason that survives that fix: :meth:`EnumField. + _unregistered_member` (used below) carries its own + ``__reduce_ex__``, so a member built this way still round-trips + through :mod:`pickle` -- see that method's own docstring -- + which :meth:`~pcapkit.const.pcapng.option_type.OptionType. + _unregistered_member`'s own override does not: its + :attr:`~pcapkit.const.pcapng.option_type.OptionType._value_` is + the *formatted* display string (``'opt_unknown [8888]'``, not + ``8888``), so ``pickle.dumps`` on one succeeds but + ``pickle.loads`` of the result raises ``ValueError`` (measured + on Python 3.14.7) -- the default reduction reconstructs through + ``cls(self._value_)``, and ``_missing_``'s own int-only guard + rejects that formatted string outright. The base + :meth:`~pcapkit.corekit.enum.EnumRegistry._unregistered_member` + it overrides fails differently and earlier instead of not at + all: ``pickle.loads`` of its plain-built member's raw-code + ``_value_`` does succeed, but only by reconstructing through + ``_missing_`` -- and so through this override, not the base -- + which is why the result's own ``_value_`` comes back as the + *formatted* string rather than the original code, and comparing + the two directly raises ``AttributeError`` on ``opt_value``. + What actually fails immediately on the base path is ``repr()`` + itself, since + :attr:`~pcapkit.const.pcapng.option_type.OptionType.opt_name`/ + :attr:`~pcapkit.const.pcapng.option_type.OptionType.opt_value` + are never set (measured) -- which is *why* the override exists + at all, choosing to render correctly over round-tripping + correctly. This method still reaches for + :meth:`EnumField._unregistered_member` rather than either of + :class:`OptionType`'s own two paths, base or override, because + it is the only one of the three that gets both right at once. + :func:`copy.deepcopy` is not the difference -- it succeeds on + both, unaffected by any of that: :class:`aenum.Enum` subclasses + the standard library's :class:`enum.Enum`, and it is *that* + stdlib base (not :class:`aenum.Enum` itself) that defines + ``__copy__``/``__deepcopy__`` to return ``self`` outright, so + deep-copying any member of either kind never reaches + ``__reduce_ex__`` in the first place. So this replicates the read-only + membership test :meth:`~pcapkit.const.pcapng.option_type. + OptionType.get` itself runs first, and only calls it once that + test finds the code already declared, so an undeclared option + type still gets :meth:`EnumField._unregistered_member` instead of + a registry row -- now for pickle-safety, not to avoid minting. """ value = super(EnumField, self).post_process(value, packet) diff --git a/pcapkit/vendor/ftp/command.py b/pcapkit/vendor/ftp/command.py index d80c9c7d63..87c6420aed 100644 --- a/pcapkit/vendor/ftp/command.py +++ b/pcapkit/vendor/ftp/command.py @@ -37,7 +37,7 @@ } # type: dict[str, str] #: Default constant template of enumerate registry from IANA CSV. -LINE = lambda NAME, DOCS, ENUM, MODL: f'''\ +LINE = lambda NAME, DOCS, ENUM, FEAT, MODL: f'''\ # -*- coding: utf-8 -*- # mypy: disable-error-code=assignment # pylint: disable=line-too-long @@ -53,7 +53,9 @@ from typing import TYPE_CHECKING -from aenum import IntEnum, IntFlag, StrEnum, auto, extend_enum +from aenum import IntEnum, IntFlag, StrEnum, auto + +from pcapkit.corekit.enum import EnumRegistry if TYPE_CHECKING: from typing import Optional, Type @@ -61,9 +63,29 @@ __all__ = ['{NAME}'] -class FEATCode(StrEnum): +class FEATCode(EnumRegistry, StrEnum): """Keyword returned in FEAT response line for this command/extension, - c.f., :rfc:`5797#secion-3`.""" + c.f., :rfc:`5797#secion-3`. + + .. note:: + + Declares every FEAT keyword the IANA registry's own ``FEAT code`` + column names -- the 5 group markers below plus the per-command + keywords generated after them (data-driven, not hand-picked; see + :meth:`~pcapkit.vendor.ftp.command.Command.process`) -- rather than + minting the per-command ones at import time as an incidental side + effect of building :class:`Command`'s own rows. GitHub issue #860: + that import-time mutation was the same defect shape #861 removed + from :class:`~pcapkit.const.pcapng.filter_type.FilterType`, just not + previously noticed here. ``_missing_`` still unmints for a keyword + that turns up on the wire but names none of these -- the + extendability the owner asked to keep, verbatim: *"If it is expected + to be handled as our current approach in industry convention, then + we keep it extendable as is."* No custom ``__new__`` here, so the + base's generic :meth:`~pcapkit.corekit.enum.EnumRegistry. + _unregistered_member` needs no override. + + """ #: FTP standard commands [:rfc:`0959`]. base = '' @@ -76,6 +98,8 @@ class FEATCode(StrEnum): #: FTP Extensions for NAT/IPv6 [:rfc:`2428`]. nat6 = '' + {FEAT} + def __repr__(self) -> 'str': return f'<{{self.__class__.__name__}} [{{self._name_}}]>' @@ -89,7 +113,7 @@ def _missing_(cls, value: 'str') -> 'FEATCode': """ if not isinstance(value, str): raise ValueError(f'{{value!r}} is not a valid {{cls.__name__}}') - return extend_enum(cls, value.upper(), value) + return cls._unregistered_member(value, value.upper()) class CommandType(IntFlag): @@ -128,8 +152,22 @@ class ConformanceRequirement(IntEnum): H = auto() -class {NAME}(StrEnum): - """[{NAME}] {DOCS}""" +class {NAME}(EnumRegistry, StrEnum): + """[{NAME}] {DOCS} + + .. note:: + + Neither ``_missing_`` nor ``get()`` mints any more, per the owner's + ruling on GitHub issue #860: *"only IANA registered ones are legit + values and we need register to properly create new entries. get will + not have sufficient information to create new ones."* Concretely + true here -- a bare wire command word carries no + :attr:`feat`/:attr:`desc`/:attr:`type`/:attr:`conf`, so minting one + used to register a permanent member with all four hollowed out to + their defaults; :meth:`register` is the path that can actually supply + them. + + """ if TYPE_CHECKING: #: Feature code. Keyword returned in FEAT response line for this command/extension, @@ -160,6 +198,45 @@ def __repr__(self) -> 'str': {ENUM} + @classmethod + def _unregistered_member(cls, value: 'str', name: 'str') -> '{NAME}': + """Build a member absent from this registry's own lookup tables. + + Leaves :attr:`feat`, :attr:`desc`, :attr:`type` and :attr:`conf` at + the same defaults :meth:`__new__` itself would, rather than missing + entirely -- :meth:`~pcapkit.corekit.enum.EnumRegistry. + _unregistered_member` bypasses :meth:`__new__` (it calls + :class:`str`'s directly), so those four attributes would otherwise + be absent and :meth:`__repr__` (which reads :attr:`desc`) would + raise on the result. There is no more specific value to reconstruct + them from -- a bare wire command word carries none of the four, + which is exactly the owner's reasoning for why ``get()``/ + ``_missing_`` must not mint one: :meth:`register` is the path that + can actually supply them. + + Args: + value: Value to get enum item -- the convention here, shared with + :class:`~pcapkit.const.ftp.command.FEATCode` and + :class:`~pcapkit.const.http.method.Method`, is the caller's + own casing, unchanged: an unregistered member's *value* is + exactly what was observed on the wire, matching how a + *registered* member's own value is exactly what was + declared, never reformatted. Only :attr:`name` -- the + identifier, not the value -- is canonicalised. + name: Bare label for the unregistered member -- here, the + canonical upper-case form of ``value``, matching the name + every *registered* member of this class is looked up by, + since #860's open-vocabulary registries have no manufactured + placeholder label to fall back to. + + """ + obj = super()._unregistered_member(value, name) + obj.feat = None + obj.desc = None + obj.type = CommandType.undefined + obj.conf = ConformanceRequirement.O + return obj + @staticmethod def get(key: 'str', default: 'Optional[str]' = None) -> '{NAME}': """Backport support for original codes. @@ -172,8 +249,20 @@ def get(key: 'str', default: 'Optional[str]' = None) -> '{NAME}': :meta private: """ name = key.upper() - if name not in {NAME}._member_map_: # pylint: disable=no-member - return extend_enum({NAME}, name, default if default is not None else key) + if name not in {NAME}._member_map_: # type: ignore[misc] # pylint: disable=no-member + # NOTE: the value is ``default`` if the caller supplied one, or + # else ``key`` exactly as given -- never ``name`` -- so an + # unregistered member's value is the caller's own casing, the + # same convention :meth:`_unregistered_member` documents and + # :class:`~pcapkit.const.ftp.command.FEATCode` already followed + # unchanged. Two calls naming the same command in different + # case, e.g. ``get('xyzw')`` and ``get('XYZW')``, therefore build + # results that are *not* equal -- each is exactly what its own + # caller passed, which minting's ``_member_map_`` cache used to + # paper over by returning the *first* casing seen for every + # later call regardless of case. Losing that is the one + # observable behaviour change in GitHub issue #860's conversion. + return {NAME}._unregistered_member(default if default is not None else key, name) return {NAME}[name] # type: ignore[misc] @classmethod @@ -190,8 +279,8 @@ def _missing_(cls, value: 'str') -> '{NAME}': name = value.upper() if name in cls._member_map_: return cls._member_map_[name] # type: ignore[return-value] - return extend_enum(cls, name, value) -'''.strip() # type: Callable[[str, str, str, str], str] + return cls._unregistered_member(value, name) +'''.strip() # type: Callable[[str, str, str, str, str], str] class Command(Vendor): @@ -200,20 +289,26 @@ class Command(Vendor): #: Link to registry. LINK = 'https://www.iana.org/assignments/ftp-commands-extensions/ftp-commands-extensions-2.csv' - def process(self, data: 'list[str]') -> 'list[str]': # type: ignore[override] + def process(self, data: 'list[str]') -> 'tuple[list[str], list[str]]': """Process CSV data. Args: data: CSV data. Returns: - Enumeration fields. + :class:`Command`'s enumeration fields, and every distinct + per-command keyword the ``FEAT code`` column names -- + :class:`FEATCode` must declare these as real members (GitHub + issue #860), rather than the old approach of minting one as an + incidental side effect the first time a :class:`Command` row + referencing it was evaluated at import time. """ reader = csv.reader(data) next(reader) # header enum = collections.OrderedDict() # type: OrderedDict[str, str] + feat_codes = collections.OrderedDict() # type: OrderedDict[str, str] for item in reader: cmmd = item[0].strip('+') feat = item[1] or None @@ -235,16 +330,23 @@ def process(self, data: 'list[str]') -> 'list[str]': # type: ignore[override] cmmd = cast('str', feat) if isinstance(feat, str): - if not feat.isupper(): - feat = f'FEATCode.{feat}' - else: - feat = f'FEATCode({feat!r})' + # NOTE: GitHub issue #860. Every FEAT code the registry + # names -- lower-case group marker or upper-case per-command + # keyword alike -- is now a declared member of FEATCode (see + # feat_codes below and FEATCode's own template), so both + # cases resolve by plain attribute access; neither ever + # calls FEATCode(...) at import time any more, which used to + # mint the upper-case ones as a side effect of evaluating + # this very module. + if feat.isupper() and feat not in feat_codes: + feat_codes[feat] = f'#: {cmmt}\n {feat} = {feat!r}' + feat = f'FEATCode.{feat}' pres = f"{cmmd}: 'Command' = {cmmd!r}, {feat}, {desc!r}, {kind or 0}, {conf}" sufs = f'#: {cmmt}' enum[cmmd] = f'{sufs}\n {pres}' - return list(enum.values()) + return list(enum.values()), list(feat_codes.values()) def context(self, data: 'list[str]') -> 'str': """Generate constant context. @@ -256,10 +358,11 @@ def context(self, data: 'list[str]') -> 'str': Constant context. """ - enum = self.process(data) + enum, feat_codes = self.process(data) ENUM = '\n\n '.join(map(lambda s: s.rstrip(), enum)).strip() + FEAT = '\n\n '.join(map(lambda s: s.rstrip(), feat_codes)).strip() - return LINE(self.NAME, self.DOCS, ENUM, self.__module__) + return LINE(self.NAME, self.DOCS, ENUM, FEAT, self.__module__) if __name__ == '__main__': diff --git a/pcapkit/vendor/ftp/return_code.py b/pcapkit/vendor/ftp/return_code.py index 89aba1886a..f0d0cdc96f 100644 --- a/pcapkit/vendor/ftp/return_code.py +++ b/pcapkit/vendor/ftp/return_code.py @@ -42,7 +42,9 @@ from typing import TYPE_CHECKING -from aenum import IntEnum, extend_enum +from aenum import IntEnum + +from pcapkit.corekit.enum import EnumRegistry if TYPE_CHECKING: from typing import Optional, Type @@ -60,7 +62,7 @@ }} # type: dict[str, str] -class ResponseKind(IntEnum): +class ResponseKind(EnumRegistry, IntEnum): """Response kind; whether the response is good, bad or incomplete.""" PositivePreliminary = 1 @@ -79,11 +81,12 @@ def _missing_(cls, value: 'int') -> 'ResponseKind': """ if isinstance(value, int) and 0 <= value <= 9: - return extend_enum(cls, 'Unknown_%d' % value, value) + #: Unknown + return cls._unregistered_member(value, 'Unknown') return super()._missing_(value) -class GroupingInformation(IntEnum): +class GroupingInformation(EnumRegistry, IntEnum): """Grouping information.""" Syntax = 0 @@ -102,11 +105,12 @@ def _missing_(cls, value: 'int') -> 'GroupingInformation': """ if isinstance(value, int) and 0 <= value <= 9: - return extend_enum(cls, 'Unknown_%d' % value, value) + #: Unknown + return cls._unregistered_member(value, 'Unknown') return super()._missing_(value) -class {NAME}(IntEnum): +class {NAME}(EnumRegistry, IntEnum): """[{NAME}] {DOCS}""" if TYPE_CHECKING: @@ -136,28 +140,29 @@ def __str__(self) -> 'str': {ENUM} - @staticmethod - def get(key: 'int | str', default: 'int' = -1) -> '{NAME}': - """Backport support for original codes. + @classmethod + def _unregistered_member(cls, value: 'int', name: 'str') -> '{NAME}': + """Build a member absent from this registry's own lookup tables. + + Reconstructs :attr:`description`, :attr:`kind` and :attr:`group` the + same way :meth:`__new__` would, rather than leaving them unset -- + :meth:`~pcapkit.corekit.enum.EnumRegistry._unregistered_member` + bypasses :meth:`__new__` entirely (it calls :class:`int`'s directly), + so those three attributes would otherwise be missing from the result + and :meth:`__str__`/:meth:`__repr__` would raise on it. Args: - key: Key to get enum item. - default: Default value if not found. The placeholder ``-1`` stands - for *no default*, in which case an unresolvable key propagates - the lookup error instead of falling back. + value: Value to get enum item. + name: Bare label for the unregistered member, per the ranged + mint/unmint criterion. - :meta private: """ - if isinstance(key, int): - try: - return {NAME}(key) - except ValueError: - if default == -1: - raise - return {NAME}(default) - if key not in {NAME}._member_map_: # pylint: disable=no-member - return extend_enum({NAME}, key, default) - return {NAME}[key] # type: ignore[misc] + obj = super()._unregistered_member(value, name) + code = str(value) + obj.description = None + obj.kind = ResponseKind(int(code[0])) + obj.group = GroupingInformation(int(code[1])) + return obj @classmethod def _missing_(cls, value: 'int') -> '{NAME}': @@ -169,7 +174,8 @@ def _missing_(cls, value: 'int') -> '{NAME}': """ if not ({FLAG}): raise ValueError('%r is not a valid %s' % (value, cls.__name__)) - return extend_enum(cls, 'CODE_%s' % value, value) + #: Unassigned + return cls._unregistered_member(value, 'Unassigned') ''' # type: Callable[[str, str, str, str, str], str] diff --git a/pcapkit/vendor/http/method.py b/pcapkit/vendor/http/method.py index 8e5f7a792d..f8121500bc 100644 --- a/pcapkit/vendor/http/method.py +++ b/pcapkit/vendor/http/method.py @@ -36,15 +36,30 @@ from typing import TYPE_CHECKING -from aenum import StrEnum, extend_enum +from aenum import StrEnum + +from pcapkit.corekit.enum import EnumRegistry if TYPE_CHECKING: from typing import Optional, Type __all__ = ['{NAME}'] -class {NAME}(StrEnum): - """[{NAME}] {DOCS}""" +class {NAME}(EnumRegistry, StrEnum): + """[{NAME}] {DOCS} + + .. note:: + + Neither ``_missing_`` nor ``get()`` mints any more, per the owner's + ruling on GitHub issue #860: *"only IANA registered ones are legit + values and we need register to properly create new entries. get will + not have sufficient information to create new ones."* Concretely true + here -- a bare wire method verb carries no + :attr:`safe`/:attr:`idempotent`, so minting one used to register a + permanent member with both hollowed out to their defaults; + :meth:`register` is the path that can actually supply them. + + """ if TYPE_CHECKING: #: Safe method. @@ -67,6 +82,56 @@ def __repr__(self) -> 'str': {ENUM} + @classmethod + def _unregistered_member(cls, value: 'str', name: 'str') -> '{NAME}': + """Build a member absent from this registry's own lookup tables. + + Leaves :attr:`safe` and :attr:`idempotent` at the same defaults + :meth:`__new__` itself would, rather than missing entirely -- + :meth:`~pcapkit.corekit.enum.EnumRegistry._unregistered_member` + bypasses :meth:`__new__` (it calls :class:`str`'s directly), so + those two attributes would otherwise be absent. There is no more + specific value to reconstruct them from -- a bare wire method verb + carries neither, which is exactly the owner's reasoning for why + ``get()``/``_missing_`` must not mint one: :meth:`register` is the + path that can actually supply them. + + Args: + value: Value to get enum item -- the convention here, shared with + :class:`~pcapkit.const.ftp.command.FEATCode` and + :class:`~pcapkit.const.ftp.command.Command`, is the caller's + own casing, unchanged: an unregistered member's *value* is + exactly what was observed on the wire, matching how a + *registered* member's own value is exactly what was + declared, never reformatted. Only :attr:`name` -- the + identifier, not the value -- is canonicalised. + + This is deliberately *not* the same as what a *registered* + member of this class carries, though, and that asymmetry is + left as-is here rather than fixed: :meth:`__new__` itself is + untouched, and it calls ``str.__new__(cls)`` with no + argument at all, so every one of the 40 declared members' + underlying :class:`str` payload is permanently empty + regardless of ``value`` (``str(Method.GET) == ''``, and + ``Method.GET == 'GET'`` is :obj:`False`) -- true on ``main`` + as well as here. An *unregistered* member built through this + method, by contrast, now carries real content + (``str(Method('frob')) == 'frob'``). Tracked as GitHub issue + #870 rather than fixed in this PR: changing :meth:`__new__` + changes what all 40 public members compare equal to, which + is its own review. + name: Bare label for the unregistered member -- here, the + canonical upper-case form of ``value``, matching the name + every *registered* member of this class is looked up by, + since #860's open-vocabulary registries have no manufactured + placeholder label to fall back to. + + """ + obj = super()._unregistered_member(value, name) + obj.safe = False + obj.idempotent = False + return obj + @staticmethod def get(key: 'str', default: 'Optional[str]' = None) -> '{NAME}': """Backport support for original codes. @@ -79,8 +144,20 @@ def get(key: 'str', default: 'Optional[str]' = None) -> '{NAME}': :meta private: """ name = key.upper() - if name not in {NAME}._member_map_: # pylint: disable=no-member - return extend_enum({NAME}, name, default if default is not None else key) + if name not in {NAME}._member_map_: # type: ignore[misc] # pylint: disable=no-member + # NOTE: the value is ``default`` if the caller supplied one, or + # else ``key`` exactly as given -- never ``name`` -- so an + # unregistered member's value is the caller's own casing, the + # same convention :meth:`_unregistered_member` documents and + # :class:`~pcapkit.const.ftp.command.FEATCode` already followed + # unchanged. Two calls naming the same method in different + # case, e.g. ``get('frob')`` and ``get('FROB')``, therefore build + # results that are *not* equal -- each is exactly what its own + # caller passed, which minting's ``_member_map_`` cache used to + # paper over by returning the *first* casing seen for every + # later call regardless of case. Losing that is the one + # observable behaviour change in GitHub issue #860's conversion. + return {NAME}._unregistered_member(default if default is not None else key, name) return {NAME}[name] # type: ignore[misc] @classmethod @@ -97,7 +174,7 @@ def _missing_(cls, value: 'str') -> '{NAME}': name = value.upper() if name in cls._member_map_: return cls._member_map_[name] # type: ignore[return-value] - return extend_enum(cls, name, value) + return cls._unregistered_member(value, name) '''.strip() # type: Callable[[str, str, str, str], str] diff --git a/pcapkit/vendor/http/status_code.py b/pcapkit/vendor/http/status_code.py index 59ad3ae965..2f01e20c5c 100644 --- a/pcapkit/vendor/http/status_code.py +++ b/pcapkit/vendor/http/status_code.py @@ -38,7 +38,9 @@ from typing import TYPE_CHECKING -from aenum import IntEnum, extend_enum +from aenum import IntEnum + +from pcapkit.corekit.enum import EnumRegistry if TYPE_CHECKING: from typing import Type @@ -46,7 +48,7 @@ __all__ = ['{NAME}'] -class {NAME}(IntEnum): +class {NAME}(EnumRegistry, IntEnum): """[{NAME}] {DOCS}""" if TYPE_CHECKING: @@ -69,28 +71,26 @@ def __str__(self) -> 'str': {ENUM} - @staticmethod - def get(key: 'int | str', default: 'int' = -1) -> '{NAME}': - """Backport support for original codes. + @classmethod + def _unregistered_member(cls, value: 'int', name: 'str') -> '{NAME}': + """Build a member absent from this registry's own lookup tables. + + Reconstructs :attr:`message` the same way :meth:`__new__` would, + rather than leaving it unset -- :meth:`~pcapkit.corekit.enum. + EnumRegistry._unregistered_member` bypasses :meth:`__new__` entirely + (it calls :class:`int`'s directly), so :attr:`message` would + otherwise be missing from the result and :meth:`__str__` would raise + on it. Args: - key: Key to get enum item. - default: Default value if not found. The placeholder ``-1`` stands - for *no default*, in which case an unresolvable key propagates - the lookup error instead of falling back. + value: Value to get enum item. + name: Bare label for the unregistered member, per the ranged + mint/unmint criterion. - :meta private: """ - if isinstance(key, int): - try: - return {NAME}(key) - except ValueError: - if default == -1: - raise - return {NAME}(default) - if key not in {NAME}._member_map_: # pylint: disable=no-member - extend_enum({NAME}, key, default) - return {NAME}[key] # type: ignore[misc] + obj = super()._unregistered_member(value, name) + obj.message = name + return obj @classmethod def _missing_(cls, value: 'int') -> '{NAME}': @@ -167,7 +167,7 @@ def process(self, data: 'list[str]') -> 'tuple[list[str], list[str]]': miss.append(f'if {start} <= value <= {stop}:') miss.append(f' #: {desc}') - miss.append(f" return extend_enum(cls, 'CODE_%d' % value, value, {name!r})") + miss.append(f" return cls._unregistered_member(value, {name!r})") return enum, miss def context(self, data: 'list[str]') -> 'str': diff --git a/pcapkit/vendor/pcapng/option_type.py b/pcapkit/vendor/pcapng/option_type.py index a501374273..fbb7165279 100644 --- a/pcapkit/vendor/pcapng/option_type.py +++ b/pcapkit/vendor/pcapng/option_type.py @@ -41,7 +41,9 @@ from collections import defaultdict from typing import TYPE_CHECKING -from aenum import StrEnum, extend_enum +from aenum import StrEnum + +from pcapkit.corekit.enum import EnumRegistry __all__ = ['{NAME}'] @@ -49,7 +51,7 @@ from typing import Any, DefaultDict, Optional, Type -class {NAME}(StrEnum): +class {NAME}(EnumRegistry, StrEnum): """[{NAME}] {DOCS}""" if TYPE_CHECKING: @@ -107,6 +109,41 @@ def __hash__(self) -> 'int': {ENUM} + @classmethod + def _unregistered_member(cls, value: 'int', name: 'str') -> '{NAME}': + """Build a member absent from this registry's own lookup tables. + + Cannot reuse the base's generic construction -- + :meth:`~pcapkit.corekit.enum.EnumRegistry._unregistered_member` calls + :class:`str`'s ``__new__`` directly on the raw ``value``, but + {NAME} stores a *formatted* string (``'{{name}} [{{value}}]'``) as + its own :attr:`~aenum.Enum._value_`, not ``value`` itself, so that + approach would build a member whose value looks nothing like this + registry's real members. + + Deliberately does **not** add to :attr:`__members_ns__` either -- + that dict is this registry's own second lookup table, alongside + :attr:`~aenum.Enum._value2member_map_`, and growing it would defeat + the whole point of an *unregistered* member exactly as growing + ``_value2member_map_`` would. + + Args: + value: Value to get enum item. + name: Bare label for the unregistered member, per the ranged + mint/unmint criterion. + + """ + temp = '%s [%d]' % (name, value) + + obj = str.__new__(cls, temp) + obj._name_ = name # pylint: disable=attribute-defined-outside-init + obj._value_ = temp + + obj.opt_name = name + obj.opt_value = value + + return obj + @staticmethod def get(key: 'int | str', default: 'int' = -1, *, namespace: 'str' = 'opt') -> '{NAME}': """Backport support for original codes. @@ -123,10 +160,25 @@ def get(key: 'int | str', default: 'int' = -1, *, namespace: 'str' = 'opt') -> ' temp_ns.update({NAME}.__members_ns__.get(namespace, {{}})) if key in temp_ns: return temp_ns[key] - return extend_enum({NAME}, '%s_unknown_%d' % (namespace, key), key, '%s_unknown' % namespace) + # NOTE: GitHub issue #860. This used to extend_enum(...) a + # permanent member as a side effect of a mere lookup -- exactly + # the ``get``/``_missing_`` minting the owner's ruling forbids, + # here on the wire-facing path + # (:meth:`pcapkit.protocols.misc.pcapng.PCAPNG._make_pcapng_options` + # calls this with raw option-code bytes off the wire). + # ``_unregistered_member`` never touches ``__members_ns__`` + # either, so a genuinely unknown option code no longer grows + # either lookup table. + return {NAME}._unregistered_member(key, '%s_unknown' % namespace) if key in {NAME}.__members__: return getattr({NAME}, key) - return extend_enum({NAME}, key, default, key) + # NOTE: same ruling, the str-keyed path: this used to mint ``key`` + # itself as the member's name with ``default`` as its value. Neither + # ``get`` nor ``_missing_`` has enough information to register one + # properly -- there is no namespace, description or anything else + # IANA would attach to it -- so this now returns an unregistered + # pseudo-member with the same attributes the old mint would have set. + return {NAME}._unregistered_member(default, key) @classmethod def _missing_(cls, value: 'int') -> '{NAME}': @@ -196,7 +248,7 @@ def process(self, data: 'dict[str, list[Tag]]') -> 'tuple[list[str], list[str]]' """ enum = [] # type: list[str] miss = [ - "return extend_enum(cls, 'opt_unknown_%d' % value, value, 'opt_unknown')", + "return cls._unregistered_member(value, 'opt_unknown')", ] # type: list[str] for content in data['table-1']: diff --git a/tests/const/test_const_enum_builtin_parity.py b/tests/const/test_const_enum_builtin_parity.py index 5f6101bd5d..7d604687c2 100644 --- a/tests/const/test_const_enum_builtin_parity.py +++ b/tests/const/test_const_enum_builtin_parity.py @@ -629,34 +629,56 @@ def test_a_bounded_flag_matches_the_built_in_under_its_strict_boundary(self) -> class ConstEnumRegisterFallbackTests(unittest.TestCase): - """The one sanctioned divergence: look up, miss, then register. - - In the owner's words, the const enums mirror the built-in "with one - exception: they contain the missing then register fallback (mutable enums)". - A guard that turned a registration into a rejection would be the regression - GitHub issues #584 and #623 are both about, so it is pinned here rather than - left to the guard tests above to imply. + """A once-sanctioned divergence, now retired for these three specific + registries. + + In the owner's words, the const enums used to mirror the built-in "with + one exception: they contain the missing then register fallback (mutable + enums)". :class:`~pcapkit.const.http.method.Method`, + :class:`~pcapkit.const.ftp.command.Command` and + :class:`~pcapkit.const.ftp.command.FEATCode` were, until GitHub issue + #860 step 2, the last three registries still living that divergence for + an *unrecognised* value (every numeric registry had already lost it + under #775/#847's ruling). The owner's #860 ruling retired it for these + three too, verbatim: *"I think we should not mint on get still + actually. For all three, only IANA registered ones are legit values and + we need register to properly create new entries. get will not have + sufficient information to create new ones."* Concretely, + :class:`Command` needs :attr:`~pcapkit.const.ftp.command.Command.feat`/ + :attr:`~pcapkit.const.ftp.command.Command.desc`/ + :attr:`~pcapkit.const.ftp.command.Command.type`/ + :attr:`~pcapkit.const.ftp.command.Command.conf` and + :class:`Method` needs :attr:`~pcapkit.const.http.method.Method.safe`/ + :attr:`~pcapkit.const.http.method.Method.idempotent`, neither of which a + bare unrecognised string carries, so the old fallback used to register a + permanently hollowed-out member; :meth:`register` is the path that can + supply them properly now. What GitHub issues #584 and #623 guarded + against -- a guard turning a registration into an outright crash rather + than a clean rejection -- is still checked below, just as "resolves to + an equal, unregistered pseudo-member" rather than "registers". """ def setUp(self) -> None: purge_modules(['pcapkit']) def tearDown(self) -> None: - # Every test here registers members on module-global classes. + # Every test here may register members on module-global classes. purge_modules(['pcapkit']) - def test_a_string_registry_still_registers_an_unknown_name(self) -> None: + def test_a_string_registry_no_longer_registers_an_unknown_name(self) -> None: from pcapkit.const.ftp.command import Command, FEATCode from pcapkit.const.http.method import Method for obj, unknown in ((Method, 'FROBNICATE'), (Command, 'XYZZY'), (FEATCode, '')): with self.subTest(enum=_qualname(obj), value=unknown): before = len(obj.__members__) - registered = obj(unknown) - self.assertGreater(len(obj.__members__), before, - f'{_qualname(obj)}({unknown!r}) did not register; ' - f'see GitHub issue #647') - self.assertIs(obj(unknown), registered) + first = obj(unknown) + self.assertEqual(len(obj.__members__), before, + f'{_qualname(obj)}({unknown!r}) registered; ' + f'see GitHub issue #860') + second = obj(unknown) + self.assertEqual(first, second) + self.assertIsNot(first, second) def test_a_string_registry_still_matches_case_insensitively(self) -> None: from pcapkit.const.ftp.command import Command @@ -1016,7 +1038,11 @@ def test_the_converted_generators_emit_the_same_comment_text(self) -> None: vendor_ftp = importlib.import_module('pcapkit.vendor.ftp.command') command = vendor_ftp.Command.__new__(vendor_ftp.Command) command.record = collections.Counter() - emitted = command.process([ + # GitHub issue #860: process() now returns (enum rows, distinct + # per-command FEAT codes) rather than just the enum rows, since + # FEATCode must declare the latter as real members instead of + # minting them as a side effect of evaluating Command's own rows. + emitted, _feat_codes = command.process([ 'h0,h1,h2,h3,h4,h5', 'ABOR,base,Abort a transfer,s,m,[RFC959]', ]) @@ -1029,7 +1055,7 @@ def test_the_converted_generators_emit_the_same_comment_text(self) -> None: # ``desc`` is Optional[str], and ``%s`` renders None as 'None'; the # f-string has to agree, so the empty-description row is exercised too. - emitted_none = command.process([ + emitted_none, _feat_codes_none = command.process([ 'h0,h1,h2,h3,h4,h5', 'ABOR,base,,s,m,[RFC959]', ]) @@ -1089,17 +1115,41 @@ def test_the_issue_804_templates_render_the_committed_modules(self) -> None: const_module.__file__ # type: ignore[arg-type] ).read_text(encoding='utf-8') + # GitHub issue #860: both classes now mix in EnumRegistry, + # and both now close their enumeration block with a + # ``_unregistered_member`` classmethod (the override each + # needed for its own extra per-member attributes) rather + # than falling straight through to the ``get()`` + # staticmethod each still keeps. block = re.compile( - rf'class {cls_name}\(StrEnum\):\n """.*?\n\n (#:.*?)' - r'\n\n @staticmethod', re.S) + rf'class {cls_name}\(EnumRegistry, StrEnum\):\n """.*?\n\n (#:.*?)' + r'\n\n @classmethod', re.S) enum_block = block.search(committed) self.assertIsNotNone(enum_block, f'no enumeration block in {const_name}') - rendered = _normalize(vendor_module.LINE( - vendor_class.__name__, vendor_class.__doc__, - enum_block.group(1), # type: ignore[union-attr] - vendor_name, - )) + if cls_name == 'Command': + # GitHub issue #860: Command's LINE gained a fifth + # positional argument, FEAT -- the per-command FEATCode + # members FEATCode must now declare as real members + # rather than mint as an import-time side effect (see + # BespokeOpenVocabularyUnmintConvertedTests). Read back + # out of the committed module the same way as ENUM. + feat_block_re = re.compile( + r"nat6 = ''\n\n (#:.*?)\n\n def __repr__", re.S) + feat_block = feat_block_re.search(committed) + self.assertIsNotNone(feat_block, f'no FEAT block in {const_name}') + rendered = _normalize(vendor_module.LINE( + vendor_class.__name__, vendor_class.__doc__, + enum_block.group(1), # type: ignore[union-attr] + feat_block.group(1), # type: ignore[union-attr] + vendor_name, + )) + else: + rendered = _normalize(vendor_module.LINE( + vendor_class.__name__, vendor_class.__doc__, + enum_block.group(1), # type: ignore[union-attr] + vendor_name, + )) self.assertIn("def __repr__(self) -> 'str':", rendered) self.assertNotIn('consider-using-f-string', rendered) diff --git a/tests/const/test_const_enum_get.py b/tests/const/test_const_enum_get.py index 0238b1f9b8..c2e2c89994 100644 --- a/tests/const/test_const_enum_get.py +++ b/tests/const/test_const_enum_get.py @@ -20,10 +20,25 @@ remaining eight never raise on an out-of-range integer because they auto-extend the whole space, and five carry no ``get(key, default)`` at all. -GitHub issue #647 moved one of those three into the sweep, so the arithmetic is -now 111 + 2 + 5. :class:`~pcapkit.const.tcp.flags.Flags` resolved every integer -only because it defined no ``_missing_`` to bound its domain; it does now, so its -``get`` has a failure for ``default`` to fall back from like the other 110. +GitHub issue #647 moved one of those three into the sweep, so the arithmetic +was 111 + 2 + 5. :class:`~pcapkit.const.tcp.flags.Flags` resolved every +integer only because it defined no ``_missing_`` to bound its domain; it does +now, so its ``get`` has a failure for ``default`` to fall back from like the +other 110. + +GitHub issue #860 step 2 then brought :class:`~pcapkit.const.ftp.return_code. +GroupingInformation` and :class:`~pcapkit.const.ftp.return_code.ResponseKind` +onto :class:`~pcapkit.corekit.enum.EnumRegistry`, giving each the base +``get(key, default)`` it never had before -- two fewer of the five with no +integer ``default`` at all, two more in the main sweep. The full arithmetic, +spelled out completely rather than left to imply a total that silently +dropped :class:`~pcapkit.const.pcapng.filter_type.FilterType` (in +:data:`EXPECTED_WITHOUT_A_CACHEABLE_FALLBACK`, excused from the main sweep +for a separate reason and covered by its own test below) the way the "111 + +2 + 5" phrasing above always did: 112 (main sweep) + 2 +(:data:`EXPECTED_TO_RESOLVE_ANYTHING`) + 3 +(:data:`EXPECTED_WITHOUT_AN_INTEGER_DEFAULT`) + 1 (``FilterType``) = 118, +matching :meth:`test_the_sweep_size_is_pinned`. Two more registries are outside this sweep because they are :class:`~aenum.StrEnum` rather than integer enums, and both were deliberately @@ -94,16 +109,20 @@ }) #: Enums carrying no ``get(key, default)``, so there is no ``default`` to drop. -#: The first four are helper enums describing a registry's columns rather than +#: The first two are helper enums describing a registry's columns rather than #: registries themselves and have no ``get`` at all; #: :class:`~pcapkit.const.reg.apptype.TransportProtocol` has a ``get`` whose #: signature takes no ``default`` -- which is why the rewrite had to check the #: signature rather than pattern-match the body. +#: +#: :class:`~pcapkit.const.ftp.return_code.GroupingInformation` and +#: :class:`~pcapkit.const.ftp.return_code.ResponseKind` were here too until +#: GitHub issue #860 step 2 brought them onto +#: :class:`~pcapkit.corekit.enum.EnumRegistry` -- they inherit the base +#: ``get(key, default)`` now, so they moved into the main sweep below instead. EXPECTED_WITHOUT_AN_INTEGER_DEFAULT = frozenset({ 'pcapkit.const.ftp.command.CommandType', 'pcapkit.const.ftp.command.ConformanceRequirement', - 'pcapkit.const.ftp.return_code.GroupingInformation', - 'pcapkit.const.ftp.return_code.ResponseKind', 'pcapkit.const.reg.apptype.apptype.TransportProtocol', }) @@ -332,8 +351,12 @@ def test_every_integer_path_consults_the_default(self) -> None: # it used to be counted here only because an import-time side effect # (see ``EXPECTED_WITHOUT_A_CACHEABLE_FALLBACK``) happened to leave it # one real member to use as ``fallback``, and the ruling removed that - # side effect along with the mint it came from. - self.assertEqual(covered, 110) + # side effect along with the mint it came from; plus 2 for GitHub + # issue #860 step 2, which gave ``GroupingInformation`` and + # ``ResponseKind`` the base ``get(key, default)`` they never had + # before, moving them out of ``EXPECTED_WITHOUT_AN_INTEGER_DEFAULT`` + # and into this sweep. + self.assertEqual(covered, 112) def test_the_always_resolving_registries_have_nothing_to_fall_back_to(self) -> None: """The two registries excused from the sweep, and why. diff --git a/tests/const/test_const_enum_no_mint.py b/tests/const/test_const_enum_no_mint.py index 1aa7c66a36..ebf194104a 100644 --- a/tests/const/test_const_enum_no_mint.py +++ b/tests/const/test_const_enum_no_mint.py @@ -43,6 +43,70 @@ for a different reason: its ``Tag_`` mint is not an IANA-style range at all (see its own module for why), so the ruling never touched it. +GitHub issue #860 (step 2, PR 1) has now brought 8 of those 9 classes onto +:class:`~pcapkit.corekit.enum.EnumRegistry` and converted their branches -- +15 in total, see :data:`BESPOKE_UNMINT_CONVERTED_REGISTRIES` and +:class:`BespokeOpenVocabularyUnmintConvertedTests` below. Two distinct +shapes: + +Five range-bounded registries whose old mint used a manufactured +placeholder label (``Unassigned``, ``Unknown_%d``, ``opt_unknown_%d``): +:class:`~pcapkit.const.http.status_code.StatusCode` (8), +:class:`~pcapkit.const.ftp.return_code.ReturnCode`, +:class:`~pcapkit.const.ftp.return_code.ResponseKind` and +:class:`~pcapkit.const.ftp.return_code.GroupingInformation` (1 each), and +:class:`~pcapkit.const.pcapng.option_type.OptionType` (1) -- 12 branches, +unambiguous under the mint/unmint criterion from the first measurement. +Each carries a custom ``__new__`` with extra per-member attributes (unlike +any of the 82 tier-2 registries above), so each needed its own +``_unregistered_member`` override reconstructing those attributes rather +than the shared base's generic one, and two of the five +(:class:`StatusCode`, :class:`ReturnCode`) had their own hand-written +``get()`` replaced by the base's -- see :class:`BespokeGetReplacementTests` +for the ``default == -1`` -> ``NO_DEFAULT`` behaviour change that implies. +:class:`OptionType` keeps its own ``get()`` (genuine multi-namespace +dispatch the base does not replicate), but round 2 review found that +``get()`` still minted on both its int/namespace path and its ``str`` path +-- the same "leaving ``get()`` minting while ``_missing_`` does not +contradicts the ruling" argument that converted :class:`Command`'s and +:class:`Method`'s own ``get()`` mint sites below, just missed the first +time round for the third registry that has one. Both are now +``_unregistered_member`` calls too; see +:meth:`BespokeUnmintConvertedRegistriesTests. +test_optiontype_get_int_path_no_longer_mints`, +``..._namespace_path_...`` and ``..._str_path_...``. + +Three open-vocabulary registries whose old mint used the exact, unmodified +observed value as its own name rather than any manufactured label: +:class:`~pcapkit.const.ftp.command.FEATCode`, +:class:`~pcapkit.const.ftp.command.Command` and +:class:`~pcapkit.const.http.method.Method` -- 1 ``_missing_`` branch each, +initially left minting pending the owner's ruling (this measurement's own +report flagged them as genuinely ambiguous under the criterion, since +nothing about them is a manufactured placeholder). The owner's ruling on +#860, verbatim: *"I think we should not mint on get still actually. For all +three, only IANA registered ones are legit values and we need register to +properly create new entries. get will not have sufficient information to +create new ones."* That reasoning reaches ``get()`` as well as +``_missing_`` -- :class:`Command` needs ``feat``/``desc``/``type``/``conf`` +and :class:`Method` needs ``safe``/``idempotent``, neither of which a bare +wire string carries -- so both classes' own ``get()`` (a second, independent +mint site bypassing ``_missing_`` entirely) converts too, alongside +``_missing_``; :class:`FEATCode` has no custom ``__new__`` and no ``get()`` +of its own, so only its one ``_missing_`` branch was in play. + +Left untouched: :class:`~pcapkit.const.reg.apptype.apptype.AppType` (766 +``_missing_`` branches plus one more in its own ``get()``, tracked as #860 +step 2's PR 2), and the two classes the owner also floated a rename/base +change for and this programme pushed back on with measurements -- +:class:`~pcapkit.const.ftp.command.CommandType` (``IntFlag`` -> ``IntEnum`` +would break its real ``A/P`` composites) and +:class:`~pcapkit.const.reg.apptype.apptype.TransportProtocol` +(``auto()`` would make ``tcp | udp == sctp``) -- both re-opened as +`needs: decision` on #860 and explicitly out of this PR's scope; see +:class:`BespokeOpenVocabularyUnmintConvertedTests`'s own +``test_commandtype_and_transportprotocol_are_untouched``. + """ from __future__ import annotations @@ -768,6 +832,257 @@ def test_builds_an_absent_member_on_every_registry(self) -> None: self.assertNotIn(name, cls.__members__) +def _combining_operands(expr: 'ast.expr') -> 'Optional[list[ast.expr]]': + """Every sub-expression ``expr`` *combines* with other, fixed text to + build a new string, for the five shapes this tree's ``name`` arguments + are ever manufactured from: ``%``-formatting, string concatenation, an + f-string, ``str.format``, and ``sep.join([...])``. :obj:`None` if + ``expr`` is not one of those five combining shapes *at its own top + level* -- callers that want to see through a wrapping call + (``.upper()``, ``str(...)``) around one of these do that separately; + this function only recognises the combination itself. + + """ + if isinstance(expr, ast.BinOp) and isinstance(expr.op, ast.Mod): + rhs = expr.right + return list(rhs.elts) if isinstance(rhs, ast.Tuple) else [rhs] + if isinstance(expr, ast.BinOp) and isinstance(expr.op, ast.Add): + return [expr.left, expr.right] + if isinstance(expr, ast.JoinedStr): + # Both the interpolated value and its format spec can carry a + # combined operand -- ``f'x_{y:{value}}'`` embeds ``value`` in + # ``y``'s format spec, not in ``y`` itself. + operands = [] # type: list[ast.expr] + for part in expr.values: + if isinstance(part, ast.FormattedValue): + operands.append(part.value) + if part.format_spec is not None: + operands.append(part.format_spec) + return operands + if isinstance(expr, ast.Call) and isinstance(expr.func, ast.Attribute): + if expr.func.attr == 'format': + return list(expr.args) + [keyword.value for keyword in expr.keywords] + if (expr.func.attr == 'join' and expr.args + and isinstance(expr.args[0], (ast.List, ast.Tuple))): + # ``sep.join([...])`` combines every element of the list/tuple + # it is given -- ``sep`` itself (``expr.func.value``) is not an + # operand in the same sense (it is the glue, not a part being + # glued), so it is deliberately not included here. + return list(expr.args[0].elts) + return None + + +#: The conventional parameter names, across every ``_missing_``/``get()`` in +#: :mod:`pcapkit.const`, for the datum being looked up -- the one thing a +#: manufactured label must never be built *from*, since that is exactly what +#: risks an unbounded ``__members__`` collision. Anything else a combined +#: operand might reference (a namespace prefix, a fixed default, ...) is +#: provably not the value and is left alone. +_VALUE_PARAMETER_NAMES = frozenset({'value', 'key'}) + + +def is_manufactured(name_arg: 'Optional[ast.expr]') -> 'bool': + """Whether an ``_unregistered_member(...)`` call's ``name`` argument is + built *from the value being looked up*, rather than being independent of + it -- the one shape #775/#860's ruling forbids, because a label that + varies with the value risks the exact ``__members__`` collision minting + used to risk. Keyed on *which operand is combined*, not on the format + spec: ``'%s_unknown' % namespace`` on :class:`~pcapkit.const.pcapng. + option_type.OptionType`'s ``get()`` is a ``%``-format and is not + flagged, because the combined operand is ``namespace`` (one of a + handful of known prefixes) and not the value. ``'%s_unknown' % value`` + would be exactly the same shape and *would* be flagged, because there + the combined operand is the value itself -- checking the format spec + alone (e.g. "only ``%d`` is dangerous") would have missed that, and + missed the identical risk written as ``'x_' + str(value)``, + ``'x_{}'.format(value)`` or an f-string. + + ``sep.join([...])`` is a fifth combining shape in its own right -- + :func:`_combining_operands` treats every element of the list/tuple + ``.join()`` is given as something combined, the same as a ``%`` + operand -- and recurses through any *other* wrapping call -- + ``('x_%s' % value).upper()``, ``str('x_%s' % value)``, ``f'x_{value}'. + upper()`` -- rather than stopping at the outermost node: a + ``.upper()``/``.zfill()``/``str(...)`` call around a combining shape + does not undo the combination underneath it, so the receiver and every + argument of *any* call are checked in turn. A bare + reference to the value alone (``value``, ``value.upper()``, + ``value.upper().zfill(4)``) is different in kind and is not flagged -- + nothing is combined with anything else there, which is exactly why the + open-vocabulary registries (:class:`~pcapkit.const.ftp.command. + FEATCode`/:class:`~pcapkit.const.ftp.command.Command`/ + :class:`~pcapkit.const.http.method.Method`) may use the value itself, + case-folded, as the name: the ruling's target is a *manufactured* + label, and folding case manufactures nothing. + + A bare string constant carries nothing substituted into it at all, so + it is never flagged regardless of what characters it happens to + contain. An :class:`~ast.IfExp` choosing between two safe branches, and + a ``super()`` forward of the caller's own already-checked ``name``, are + likewise left alone by recursing into their own sub-expressions rather + than being hardcoded exemptions -- so a combining shape hidden inside + either would still be caught. + + Round 5's review found that a call's *arguments* need a plainer rule + than its receiver does: a bare ``value``/``key`` passed as data to + ``str(...)``, ``operator.mod(...)``, ``.format_map(...)``, or nested + inside a set/generator/list-comprehension/``map(...)`` argument to + ``.join(...)``, is a reference this function used to clear the same + way it clears a receiver -- wrongly, since an argument being the value + at all is already how each of those shapes glues it to something + else. Every argument of any call is therefore walked bluntly for + ``value``/``key`` regardless of how deeply it is wrapped, while a + receiver keeps the narrower, recursive check that preserves + ``value.upper()``'s exemption. + + Known latent gap, deliberately not closed here: ``'x_%s' % + self._value_`` (or ``cls._value_``) refers to the same datum through + the enum machinery's own attribute rather than through the + conventional ``value``/``key`` parameter name, which this function has + no way to recognise without hardcoding attribute names as well as + parameter names -- a broader heuristic than round 5 asked for. Nothing + under :mod:`pcapkit.const` writes a ``name`` argument this way today. + + """ + if name_arg is None: + return True + if isinstance(name_arg, ast.Constant): + return False + if isinstance(name_arg, ast.Name): + return False + + combined = _combining_operands(name_arg) + if combined is not None: + return any( + isinstance(node, ast.Name) and node.id in _VALUE_PARAMETER_NAMES + for operand in combined + for node in ast.walk(operand) + ) + + if isinstance(name_arg, ast.Call): + # The receiver of a method call (``.upper()``) is + # checked recursively, the same as the top-level expression would + # be: a bare reference to the value alone stays exempt there (that + # is the open-vocabulary case), while a combining shape hiding + # behind it (``('x_%s' % value).upper()``) is still caught by + # recursing into ``_combining_operands`` again. + if isinstance(name_arg.func, ast.Attribute) and is_manufactured(name_arg.func.value): + return True + # Every argument, by contrast, is walked *bluntly* for a value/key + # reference anywhere inside it, however deeply wrapped (a set, + # generator or list comprehension, ``map(...)``, a further call, + # ...) -- deliberately blunt, not because an argument can never be + # an innocent reference, but because the failure direction is the + # safe one (a false positive is loud and gets a fixture; a false + # negative ships quietly), and no real call site under + # ``pcapkit/const``/``pcapkit/vendor`` is affected either way + # (measured: the sweep stays at 244/0). It genuinely does over-flag: + # ``value.upper()`` alone is exempt (it is the receiver case just + # above), but the exact same expression as an *argument* -- + # ``str(value.upper())``, ``helper(value.upper())`` -- flags, so + # wrapping an already-exempt transform in one more call changes the + # verdict. ``NAMES.get(value, 'unknown')`` flags while the + # semantically identical ``NAMES[value]`` does not, because one is + # an ``ast.Call`` and the other an ``ast.Subscript`` -- this walk + # only triggers on the former. A comprehension's loop variable or a + # ``lambda``'s parameter that merely happens to be *spelled* + # ``value``/``key`` also flags even when it is bound to something + # entirely unrelated (``map(lambda value: value, namespace)``), since + # nothing here tracks binding, only spelling. A keyword *name* that + # happens to be ``value`` (``helper(value=namespace)``) does not + # flag -- only the keyword's *own* value expression is walked, never + # its argument name. If this fires on a real, innocent site: check + # that the substituted operand truly is independent of the + # looked-up datum, then add that site to the negative fixtures -- + # do not loosen this walk to make one site pass, since the whole + # point of blunt-on-arguments is to keep it that way. + for argument in list(name_arg.args) + [keyword.value for keyword in name_arg.keywords]: + for node in ast.walk(argument): + if isinstance(node, ast.Name) and node.id in _VALUE_PARAMETER_NAMES: + return True + return False + + # Anything else -- an IfExp's test/body/orelse, a container literal + # such as the list argument to ``.join([...])``, ... -- recurse into + # its immediate children rather than giving up, since a combining + # shape may be nested inside without the wrapper itself being one of + # the shapes handled above. + return any(is_manufactured(child) for child in ast.iter_child_nodes(name_arg)) + + +#: Fixtures for :func:`is_manufactured`'s own self-check, run before it is +#: pointed at the real tree -- same discipline as :mod:`tests.const. +#: test_const_enum_builtin_parity`'s ``REPR_PERCENT_WALK_FIXTURES``: a +#: detector not shown to fire on a positive on purpose, and to stay quiet on +#: a negative, is not trustworthy on real code either. ``(label, source, +#: expected)`` where ``source`` is a full ``return cls._unregistered_member( +#: value, )`` statement parsed for its own ``name`` argument. +IS_MANUFACTURED_FIXTURES = ( + # The four manufactured shapes the round-2 review named explicitly -- + # each embeds ``value``, the thing being looked up, into the label. + ('percent-s-value', "cls._unregistered_member(value, 'x_%s' % value)", True), + ('percent-x-value', "cls._unregistered_member(value, 'x_%x' % value)", True), + ('concat-str-value', "cls._unregistered_member(value, 'x_' + str(value))", True), + ('format-method-value', "cls._unregistered_member(value, 'x_{}'.format(value))", True), + # An f-string embedding value is the same shape as the four above. + ('fstring-value', "cls._unregistered_member(value, f'x_{value}')", True), + # Round-3 review's gap: a wrapping call around a combining shape -- + # hoisting a .upper()/.zfill()/str()/.join() outside the %/+/f-string + # must not silently disable the guard. + ('percent-value-then-upper', "cls._unregistered_member(value, ('x_%s' % value).upper())", True), + ('percent-value-then-zfill', "cls._unregistered_member(value, ('x_%d' % value).zfill(8))", True), + ('str-wrapping-percent', "cls._unregistered_member(value, str('x_%s' % value))", True), + ('fstring-then-upper', "cls._unregistered_member(value, f'x_{value}'.upper())", True), + ('join-list-with-value', "cls._unregistered_member(value, ''.join(['x_', str(value)]))", True), + # Round-5 review's gap: on the generic call-recursion path, a bare + # ``ast.Name`` argument (as opposed to the outermost expression) was + # treated as exempt the same way ``value`` alone is at the top level -- + # which missed the value being glued in as *data* to a call rather than + # merely transformed. None of these nine exists under pcapkit/const/ + # today (latent, not live), but the census that found them is exactly + # what this fixture set exists to keep honest. + ('join-set-with-value', "cls._unregistered_member(value, ''.join({'x_', str(value)}))", True), + ('join-genexp-with-value', + "cls._unregistered_member(value, ''.join(str(v) for v in [value]))", True), + ('join-listcomp-with-value', + "cls._unregistered_member(value, ''.join([str(v) for v in [value]]))", True), + ('join-map-with-value', + "cls._unregistered_member(value, ''.join(map(str, ['x_', value])))", True), + ('dunder-mod-value', "cls._unregistered_member(value, 'x_%s'.__mod__(value))", True), + ('operator-mod-value', + "cls._unregistered_member(value, operator.mod('x_%s', value))", True), + ('functools-reduce-value', + "cls._unregistered_member(value, functools.reduce(operator.add, ['x_', str(value)]))", True), + ('format-map-value', + "cls._unregistered_member(value, 'x_{v}'.format_map({'v': value}))", True), + ('fstring-format-spec-value', + "cls._unregistered_member(value, f'x_{y:{value}}')", True), + # The one real exemption: substitutes a bounded, non-value operand. + ('percent-s-namespace', "cls._unregistered_member(key, '%s_unknown' % namespace)", False), + # Every other real call site in the tree: nothing substituted at all. + ('bare-literal', "cls._unregistered_member(value, 'Unassigned')", False), + ('bare-name', "cls._unregistered_member(value, name)", False), + ('upper-call', "cls._unregistered_member(value, value.upper())", False), + ('upper-zfill-chain', "cls._unregistered_member(value, value.upper().zfill(4))", False), + ('ifexp-names', "cls._unregistered_member(value, default if default is not None else name)", False), + ('super-forward', "super()._unregistered_member(value, name)", False), +) + + +class IsManufacturedSelfCheckTests(unittest.TestCase): + """:func:`is_manufactured` against its own fixtures, before it is + trusted against the real tree below.""" + + def test_self_check(self) -> None: + for label, source, expected in IS_MANUFACTURED_FIXTURES: + with self.subTest(fixture=label): + call = ast.parse(source, mode='eval').body + assert isinstance(call, ast.Call) + name_arg = call.args[1] + self.assertEqual(is_manufactured(name_arg), expected, + f'{label}: {source!r}') + + class UnregisteredMemberNameIsBareTests(unittest.TestCase): """#775's Q1 follow-up, the maintainer's ruling verbatim: *"Q1 - bare it is."* Asked whether the non-minting path should honour the registry's @@ -781,20 +1096,61 @@ class UnregisteredMemberNameIsBareTests(unittest.TestCase): This walks every generated :mod:`pcapkit.const` module by AST -- rather than pinning one example -- and asserts that every - ``cls._unregistered_member(...)`` call site passes a plain string - literal with no ``%`` formatting. It therefore covers all 49 call sites - tier 1's follow-up touched, plus the 172 tier 2's #775/#847 ruling added - (221 total, measured on this tree -- see :data:`RULING_CONVERTED_ - REGISTRIES` above), and any added by a later regeneration, without caring - which registry they belong to. A call site still using ``extend_enum(...)`` - -- the 9 classes across 6 files tier 2 left untouched because they are not - :class:`~pcapkit.corekit.enum.EnumRegistry` yet (:class:`~pcapkit.const. - reg.apptype.apptype.AppType` and friends, see the module docstring), plus + ``cls._unregistered_member(...)`` call site's ``name`` argument is not a + *manufactured* numeric-suffixed label: a bare string constant containing + ``%`` (the old ``'Unassigned_%d' % value`` shape, spelled out as a + literal), or an equivalent ``%``-formatted :class:`~ast.BinOp` or + f-string. It therefore covers all 49 call sites tier 1's follow-up + touched, the 172 tier 2's #775/#847 ruling added, and -- since #860 + step 2's PR 1 -- the 15 more across :class:`~pcapkit.const.http. + status_code.StatusCode`, :class:`~pcapkit.const.ftp.return_code. + ReturnCode`, :class:`~pcapkit.const.ftp.return_code.ResponseKind`, + :class:`~pcapkit.const.ftp.return_code.GroupingInformation`, + :class:`~pcapkit.const.pcapng.option_type.OptionType`, + :class:`~pcapkit.const.ftp.command.FEATCode`, + :class:`~pcapkit.const.ftp.command.Command` and + :class:`~pcapkit.const.http.method.Method` (see + :data:`BESPOKE_UNMINT_CONVERTED_REGISTRIES` above), plus every + ``super()._unregistered_member(value, name)`` forwarding call each of + those five ``__new__``-carrying overrides makes internally -- not a + fresh call site with a label of its own, just relaying whatever the true + call site already passed, so it is walked and counted here too rather + than specially excluded -- plus 2 more round 2 review found still + minting on :class:`OptionType`'s own ``get()`` (its int/namespace path + and its ``str`` path, both independent of ``_missing_``), for 244 total. + ``'%s_unknown' % namespace`` on the first of those two is deliberately + *not* flagged as manufactured despite being a ``%``-formatted + :class:`~ast.BinOp`: unlike ``'Unassigned_%d' % value``, the substituted + operand is a bounded namespace prefix, not the value being minted, so it + carries none of the numeric-suffix collision risk the check exists to + catch -- see :func:`is_manufactured`'s own docstring below. + + The last three of those fifteen are a genuinely different shape from + every other converted registry: the ``name`` argument (the identifier + each is looked up by, canonicalised to upper case for all three -- + matching how every *registered* member of :class:`Command`/ + :class:`Method`/:class:`FEATCode` is already looked up) is not a + manufactured placeholder at all, it is the exact wire keyword itself, + just upper-cased -- so the argument is a bare :class:`~ast.Name` or a + plain (non-``%``) expression rather than a string constant. The + ``value`` argument, by contrast, is deliberately *not* touched on any + of the three: it stays exactly the caller's own casing, matching what + :meth:`~pcapkit.const.ftp.command.Command._unregistered_member`'s own + docstring documents as the shared convention (see + :meth:`BespokeOpenVocabularyUnmintConvertedTests. + test_command_missing_no_longer_mints` and its siblings). Neither + argument is *formatted* with a numeric suffix on any of the three, + which is the one thing that would risk a genuine ``__members__`` + collision if these were ever minted instead of built unregistered. + + A call site still using ``extend_enum(...)`` instead of + ``_unregistered_member(...)`` at all -- :class:`~pcapkit.const.reg. + apptype.apptype.AppType` (766 branches, not yet touched -- PR 2), plus the handful of still-minting branches on :class:`~pcapkit.const.reg. - ethertype.EtherType` and :class:`~pcapkit.const.ipx.socket.Socket` that the - ruling kept, plus :class:`~pcapkit.const.mh.cga_type.CGAType` -- is out of - scope and untouched by this sweep, since a minted name still needs its - numeric suffix to avoid a genuine ``__members__`` collision. + ethertype.EtherType` and :class:`~pcapkit.const.ipx.socket.Socket` that + the ruling kept, plus :class:`~pcapkit.const.mh.cga_type.CGAType` -- is + out of scope and untouched by this sweep entirely, since it never calls + ``_unregistered_member`` in the first place. (Corrected from an earlier draft of this docstring, which estimated "~92 registries still mint" and named ``pcapkit.const.mh`` and @@ -804,7 +1160,7 @@ class UnregisteredMemberNameIsBareTests(unittest.TestCase): """ - def test_every_unregistered_member_call_passes_a_bare_name(self) -> None: + def test_every_unregistered_member_call_passes_a_non_manufactured_name(self) -> None: repo_root = pathlib.Path(__file__).resolve().parents[2] const_root = repo_root / 'pcapkit' / 'const' self.assertTrue(const_root.is_dir(), f'{const_root} is not a directory') @@ -825,23 +1181,22 @@ def test_every_unregistered_member_call_passes_a_bare_name(self) -> None: call_count += 1 args = node.args name_arg = args[1] if len(args) > 1 else None - is_bare_literal = ( - isinstance(name_arg, ast.Constant) - and isinstance(name_arg.value, str) - and '%' not in name_arg.value - ) - if not is_bare_literal: + if is_manufactured(name_arg): segment = ast.get_source_segment(source, node) offenders.append(f'{path.relative_to(repo_root)}:{node.lineno}: {segment}') # Sanity: the sweep itself must actually be exercising something -- # tier 1's follow-up touched exactly 49 call sites across 21 files, - # and tier 2's #775/#847 ruling added 172 more across 82 files, for - # 221 total measured on this tree. - self.assertGreaterEqual(call_count, 221, - f'expected at least 221 _unregistered_member call sites, found {call_count}') + # tier 2's #775/#847 ruling added 172 more across 82 files, and + # #860 step 2's PR 1 added 11 more (true call sites plus each + # override's own ``super()`` forward, plus the 2 round-2 review found + # still minting on OptionType.get()'s own two paths) across 2 files, + # for 244 total measured on this tree. + self.assertGreaterEqual(call_count, 244, + f'expected at least 244 _unregistered_member call sites, found {call_count}') self.assertEqual(offenders, [], - 'found _unregistered_member call(s) with a non-bare name:\n' + '\n'.join(offenders)) + 'found _unregistered_member call(s) with a manufactured name:\n' + + '\n'.join(offenders)) class RulingConversionDoesNotMintTests(unittest.TestCase): @@ -1046,5 +1401,677 @@ def test_registered_by_xerox_still_mints(self) -> None: self.assertIs(Socket(value), member) +#: The 5 (of #860's original 9 bespoke) classes step 2's PR 1 brought onto +#: :class:`~pcapkit.corekit.enum.EnumRegistry` and converted: (module, class +#: name, a probe value inside the converted range, the bare label the +#: conversion uses). Every one of these five has a custom ``__new__`` with +#: extra per-member attributes, unlike any of the 82 tier-2 registries in +#: :data:`RULING_CONVERTED_REGISTRIES` above, which is exactly why each needed +#: its own ``_unregistered_member`` override rather than the shared generic +#: one -- see :class:`BespokeUnmintConvertedRegistriesTests`. +BESPOKE_UNMINT_CONVERTED_REGISTRIES = ( + ('pcapkit.const.http.status_code', 'StatusCode', 105, 'Unassigned'), + ('pcapkit.const.ftp.return_code', 'ReturnCode', 199, 'Unassigned'), + ('pcapkit.const.ftp.return_code', 'ResponseKind', 9, 'Unknown'), + ('pcapkit.const.ftp.return_code', 'GroupingInformation', 9, 'Unknown'), + ('pcapkit.const.pcapng.option_type', 'OptionType', 65000, 'opt_unknown'), +) + + +class BespokeUnmintConvertedRegistriesTests(unittest.TestCase): + """GitHub issue #860 step 2, PR 1: 5 of the 9 bespoke, non- + :class:`~pcapkit.corekit.enum.EnumRegistry` classes brought onto the base + and converted. Unlike every registry above, each of these five carries a + custom ``__new__`` setting extra attributes (``message``; + ``description``/``kind``/``group``; ``opt_name``/``opt_value``), so the + base's generic :meth:`~pcapkit.corekit.enum.EnumRegistry. + _unregistered_member` -- which calls the member type's ``__new__`` + directly and sets only ``_name_``/``_value_`` -- would leave those + attributes unset and make ``str()``/``repr()`` raise on the result. Each + class therefore overrides :meth:`_unregistered_member` to reconstruct + them the same way ``__new__`` would. These tests exercise that + reconstruction directly, not just that lookup no longer mints. + + """ + + def setUp(self) -> None: + snapshot = snapshot_modules(ISOLATED_PREFIXES) + purge_modules(['pcapkit']) + self.addCleanup(restore_modules, snapshot, ISOLATED_PREFIXES) + + def test_unassigned_value_resolves_without_minting(self) -> None: + """Swept across all 5: resolves, does not mint, repeated lookup is + equal but not identical -- the same shape as + :class:`UnassignedRangeDoesNotMintTests` above, generalised to + registries whose ``_unregistered_member`` is not the generic one.""" + for module_name, class_name, value, label in BESPOKE_UNMINT_CONVERTED_REGISTRIES: + with self.subTest(registry=class_name): + cls = getattr(importlib.import_module(module_name), class_name) + + self.assertNotIn(value, cls._value2member_map_) # type: ignore[attr-defined] + before = len(cls.__members__) + + first = cls(value) + after_one = len(cls.__members__) + second = cls(value) + after_two = len(cls.__members__) + + self.assertEqual(before, after_one) + self.assertEqual(before, after_two) + self.assertEqual(first, second) + self.assertIsNot(first, second) + self.assertEqual(first.name, label) + self.assertNotIn(value, cls._value2member_map_) # type: ignore[attr-defined] + self.assertNotIn(label, cls.__members__) + + def test_out_of_bound_value_still_fails(self) -> None: + for module_name, class_name, _, _label in BESPOKE_UNMINT_CONVERTED_REGISTRIES: + with self.subTest(registry=class_name): + cls = getattr(importlib.import_module(module_name), class_name) + with self.assertRaises(ValueError): + cls(1 << 32) + + def test_statuscode_unregistered_member_displays_correctly(self) -> None: + """Pins that :attr:`message` -- set only by ``__new__`` on the base + template -- is reconstructed, since :meth:`__str__` reads it and + would raise :exc:`AttributeError` on a member built the generic way.""" + from pcapkit.const.http.status_code import StatusCode + + member = StatusCode(105) + self.assertEqual(member.message, 'Unassigned') + self.assertEqual(repr(member), '') + self.assertEqual(str(member), '[105] Unassigned') + + def test_statuscode_every_unassigned_range_resolves_without_minting(self) -> None: + """All 8 of :meth:`~pcapkit.const.http.status_code.StatusCode. + _missing_`'s ``if`` branches, not just the first -- each is its own + source line and its own converted call, so covering only one leaves + seven untested.""" + from pcapkit.const.http.status_code import StatusCode + + for value in (105, 209, 227, 309, 419, 432, 452, 512): + with self.subTest(value=value): + before = len(StatusCode.__members__) + member = StatusCode(value) + self.assertEqual(len(StatusCode.__members__), before) + self.assertEqual(member.message, 'Unassigned') + self.assertEqual(member.value, value) + self.assertNotIn(value, StatusCode._value2member_map_) # type: ignore[attr-defined] + + def test_returncode_unregistered_member_displays_correctly(self) -> None: + """Pins that :attr:`description`, :attr:`kind` and :attr:`group` are + all reconstructed -- :attr:`kind`/:attr:`group` are themselves derived + by looking the two code digits up on :class:`ResponseKind` and + :class:`GroupingInformation`, which must resolve (through their own + conversion above) without minting either.""" + from pcapkit.const.ftp.return_code import (GroupingInformation, ReturnCode, + ResponseKind) + + rk_before = len(ResponseKind.__members__) + gi_before = len(GroupingInformation.__members__) + + member = ReturnCode(199) + self.assertIsNone(member.description) + self.assertIsInstance(member.kind, ResponseKind) + self.assertEqual(member.kind, 1) + self.assertIsInstance(member.group, GroupingInformation) + self.assertEqual(member.group, 9) + self.assertEqual(repr(member), '') + self.assertEqual(str(member), '[199] None') + + # The nested ResponseKind(1)/GroupingInformation(9) resolutions must + # not mint on either sub-registry either -- 1 is a real ResponseKind + # member (PositivePreliminary) so it resolves directly, but 9 is + # itself in GroupingInformation's own unassigned range and must go + # through *its* conversion above rather than minting. + self.assertEqual(gi_before, len(GroupingInformation.__members__)) + self.assertEqual(rk_before, len(ResponseKind.__members__)) + + def test_responsekind_and_groupinginformation_unregistered_member_bare_name(self) -> None: + """Neither has a custom ``__new__``, so the base's generic + ``_unregistered_member`` needs no override for either -- pinned here + so a future edit that adds one notices it changed something that + used to be free.""" + from pcapkit.const.ftp.return_code import GroupingInformation, ResponseKind + + self.assertNotIn('_unregistered_member', ResponseKind.__dict__) + self.assertNotIn('_unregistered_member', GroupingInformation.__dict__) + + rk = ResponseKind(9) + self.assertEqual(rk.name, 'Unknown') + gi = GroupingInformation(9) + self.assertEqual(gi.name, 'Unknown') + + def test_optiontype_unregistered_member_displays_correctly(self) -> None: + from pcapkit.const.pcapng.option_type import OptionType + + member = OptionType(65000) + self.assertEqual(member.opt_name, 'opt_unknown') + self.assertEqual(member.opt_value, 65000) + self.assertEqual(repr(member), '') + self.assertEqual(str(member), 'opt_unknown [65000]') + + def test_optiontype_members_ns_is_not_corrupted(self) -> None: + """The maintainer's own second lookup table, :attr:`__members_ns__`, + sits alongside ``_value2member_map_`` and must not grow from an + unregistered lookup either -- growing it would silently defeat the + whole point of *unregistered* through a side channel the generic + ``_value2member_map_``/``__members__`` assertions above cannot see. + This is deliberately the opposite of what the pre-conversion mint did + (it *did* add to ``__members_ns__``, every time), so it is pinned + both ways: the table must not grow, and the value must not appear in + it, either directly under the resolving namespace.""" + from pcapkit.const.pcapng.option_type import OptionType + + before = {ns: dict(members) for ns, members in OptionType.__members_ns__.items()} + + first = OptionType(65000) + second = OptionType(65000) + + after = {ns: dict(members) for ns, members in OptionType.__members_ns__.items()} + self.assertEqual(before, after) + for members in OptionType.__members_ns__.values(): + self.assertNotIn(65000, members) + # And, since it never entered the table, two lookups build two + # independent (equal, non-identical) objects rather than the single + # cached one a real namespace entry would have returned. + self.assertEqual(first, second) + self.assertIsNot(first, second) + + def test_optiontype_get_int_path_no_longer_mints(self) -> None: + """:meth:`OptionType.get` used to mint directly on a miss (bypassing + ``_missing_`` entirely) -- a second, independent mint site this PR's + first pass left alone. Round 2 review measured it live on the + wire-facing path (:meth:`pcapkit.protocols.misc.pcapng.PCAPNG. + _make_pcapng_options` calls ``get()`` with raw option-code bytes), + and the owner's ruling names ``get`` explicitly -- the same reason + :class:`~pcapkit.const.ftp.command.Command`/:class:`~pcapkit.const. + http.method.Method`'s own ``get()`` mint sites converted. Leaving + this one minting while ``_missing_`` did not would have been the + exact contradiction that conversion was for.""" + from pcapkit.const.pcapng.option_type import OptionType + + before = len(OptionType.__members__) + ns_before = {ns: dict(members) for ns, members in OptionType.__members_ns__.items()} + + first = OptionType.get(65001) + after = len(OptionType.__members__) + ns_after = {ns: dict(members) for ns, members in OptionType.__members_ns__.items()} + + self.assertEqual(after, before) + self.assertEqual(ns_before, ns_after) + self.assertNotIn(65001, OptionType.__members_ns__.get('opt', {})) + + second = OptionType.get(65001) + self.assertEqual(first, second) + self.assertIsNot(first, second) + + def test_optiontype_get_namespace_path_no_longer_mints(self) -> None: + """The same call, through a non-default ``namespace=`` -- a + different branch of the same ``if isinstance(key, int)`` block.""" + from pcapkit.const.pcapng.option_type import OptionType + + before = len(OptionType.__members__) + ns_before = {ns: dict(members) for ns, members in OptionType.__members_ns__.items()} + + first = OptionType.get(65002, namespace='if') + after = len(OptionType.__members__) + ns_after = {ns: dict(members) for ns, members in OptionType.__members_ns__.items()} + + self.assertEqual(after, before) + self.assertEqual(ns_before, ns_after) + self.assertNotIn(65002, OptionType.__members_ns__.get('if', {})) + self.assertEqual(first.opt_name, 'if_unknown') + + second = OptionType.get(65002, namespace='if') + self.assertEqual(first, second) + self.assertIsNot(first, second) + + def test_optiontype_get_str_path_no_longer_mints(self) -> None: + """The subtler of the two: ``get()``'s ``str``-keyed branch used to + mint ``key`` itself as the member's *name*, with ``default`` as its + value -- the same "no information to register one properly" + reasoning as the int path, just with the roles of ``key`` and + ``default`` swapped in the old ``extend_enum`` call.""" + from pcapkit.const.pcapng.option_type import OptionType + + before = len(OptionType.__members__) + ns_before = {ns: dict(members) for ns, members in OptionType.__members_ns__.items()} + + first = OptionType.get('pypcapkit_860_probe', 42) + after = len(OptionType.__members__) + ns_after = {ns: dict(members) for ns, members in OptionType.__members_ns__.items()} + + self.assertEqual(after, before) + self.assertEqual(ns_before, ns_after) + self.assertNotIn('pypcapkit_860_probe', OptionType.__members__) + self.assertEqual(first.opt_name, 'pypcapkit_860_probe') + self.assertEqual(first.opt_value, 42) + + second = OptionType.get('pypcapkit_860_probe', 42) + self.assertEqual(first, second) + self.assertIsNot(first, second) + + +class BespokeOpenVocabularyUnmintConvertedTests(unittest.TestCase): + """The 3 open-vocabulary ``StrEnum`` classes -- unlike every other unmint + branch converted above or on tier 2, none of these three minted a + synthetic numeric placeholder under a procedural label + (``Unassigned_%d``, ``Unknown_%d``); each minted the literal, exact + string it was asked to resolve, as its own name. That initially read as + a case for keeping them minting (the label was never manufactured), but + the owner's ruling on #860 settled it the other way, verbatim: *"I think + we should not mint on get still actually. For all three, only IANA + registered ones are legit values and we need register to properly + create new entries. get will not have sufficient information to create + new ones."* Concretely: :class:`~pcapkit.const.ftp.command.Command` + needs ``feat``/``desc``/``type``/``conf`` and + :class:`~pcapkit.const.http.method.Method` needs + ``safe``/``idempotent``, neither of which a bare wire string carries, so + minting used to register a permanently hollowed-out member for each. + :class:`~pcapkit.const.ftp.command.FEATCode` has no custom ``__new__`` + at all and needed no override. + + Both ``_missing_`` and each class's own ``get()`` (a *second*, + independent mint site bypassing ``_missing_`` entirely, the same shape + as :class:`~pcapkit.const.pcapng.option_type.OptionType`'s) are + converted, since the owner's reasoning names ``get`` explicitly and + leaving it minting while ``_missing_`` did not would have been a + direct contradiction. ``Command``/``Method``'s ``_unregistered_member`` + override canonicalises the constructed value to the same upper-case + ``name`` used for the lookup (rather than the caller's incidental input + casing), which is also what makes ``get('frob')`` and ``get('FROB')`` + compare *equal* even though, with nothing cached any more, they can + never again be *identical* -- see the two production regression tests + this forced: ``tests/protocols/application/test_ftp_unit.py:: + FTPTestCase::test_command_get_is_case_insensitive`` and + ``tests/protocols/application/test_http_unit.py::HTTPTestCase:: + test_method_get_is_case_insensitive``, both updated from ``assertIs`` to + ``assertEqual`` + ``assertIsNot`` for exactly this reason. + + """ + + def setUp(self) -> None: + snapshot = snapshot_modules(ISOLATED_PREFIXES) + purge_modules(['pcapkit']) + self.addCleanup(restore_modules, snapshot, ISOLATED_PREFIXES) + + def test_featcode_import_mints_nothing(self) -> None: + """The guard against the import-time mutation coming back -- count- + agnostic on purpose. + + Before this fix, importing :mod:`pcapkit.const.ftp.command` minted + :class:`~pcapkit.const.ftp.command.FEATCode` members as a side + effect of evaluating :class:`~pcapkit.const.ftp.command.Command`'s + own class body -- each row referencing an upper-case ``FEAT code`` + called ``FEATCode('AUTH')`` etc., and :meth:`FEATCode._missing_` + minted one the first time. The generator now declares every ``FEAT + code`` the live IANA registry's own column names as a real member, + so every :class:`Command` row references one by plain attribute + access and nothing is minted merely by importing the module. + + Deliberately not a literal member count: the owner's own words, + *"this might break when IANA updated their list. i dont like this + guard on the test cases"* -- a regeneration that correctly picks up + a newly-registered FEAT code would fail a hardcoded number for being + *right*. The invariant that survives a table update instead: every + name in ``__members__`` is a real declaration in the generated + source, not something built by a call at import time. A + regeneration moves declarations and members together; only an + import-time mint would leave a member with no matching declaration. + """ + from pcapkit.const.ftp import command + from pcapkit.const.ftp.command import FEATCode + + source = pathlib.Path(command.__file__).read_text(encoding='utf-8') + self.assertGreater(len(FEATCode.__members__), 0) + for name in FEATCode.__members__: + with self.subTest(member=name): + self.assertRegex( + source, rf'(?m)^\s+{re.escape(name)} = ', + f'{name!r} is in FEATCode.__members__ but is not declared in ' + f'{command.__file__!r}, so something minted it at import time') + + def test_command_rows_reference_declared_featcode_members(self) -> None: + """Every :class:`~pcapkit.const.ftp.command.Command` row naming an + upper-case ``FEAT code`` must resolve to the *same* declared + :class:`~pcapkit.const.ftp.command.FEATCode` member as every other + row naming the same code -- not a fresh, unregistered one apiece, + which is what calling ``FEATCode(...)`` at class-body-evaluation + time used to build.""" + from pcapkit.const.ftp.command import Command, FEATCode + + self.assertIs(Command.AUTH.feat, FEATCode.AUTH) # type: ignore[attr-defined] + self.assertIs(Command.HOST.feat, FEATCode.HOST) # type: ignore[attr-defined] + self.assertIs(Command.LANG.feat, FEATCode.UTF8) # type: ignore[attr-defined] + # Two different commands sharing one FEAT code resolve to the one + # declared member, not two distinct unregistered ones. + self.assertIs(Command.MLSD.feat, FEATCode.MLST) # type: ignore[attr-defined] + self.assertIs(Command.MLST.feat, FEATCode.MLST) # type: ignore[attr-defined] + self.assertIs(Command.MLSD.feat, Command.MLST.feat) # type: ignore[attr-defined] + + def test_featcode_missing_no_longer_mints(self) -> None: + """The convention pinned across all three open-vocabulary classes: + an unregistered member's *value* is the caller's own casing, + unchanged -- ``FEATCode`` never minted any other way, and + :class:`~pcapkit.const.ftp.command.Command`/:class:`~pcapkit.const. + http.method.Method` are pinned to match it below.""" + from pcapkit.const.ftp.command import FEATCode + + before = len(FEATCode.__members__) + first = FEATCode('pypcapkit860probe') + after = len(FEATCode.__members__) + second = FEATCode('pypcapkit860probe') + + self.assertEqual(before, after) + self.assertNotIn('PYPCAPKIT860PROBE', FEATCode.__members__) + self.assertEqual(first.value, 'pypcapkit860probe') + self.assertEqual(first, 'pypcapkit860probe') + self.assertEqual(first, second) + self.assertIsNot(first, second) + self.assertEqual(repr(first), '') + + def test_command_missing_no_longer_mints(self) -> None: + """Same convention as :class:`~pcapkit.const.ftp.command.FEATCode`'s: + the *value* is exactly what was observed (``value == 'wire casing'`` + holds), matching ``main``'s own pre-#860 behaviour for this class -- + only the *name* is canonicalised. Verified directly against a + measured regression risk: swapping the value for the canonicalised + name (as an earlier revision of this conversion did) would make + ``Command('xyzw') == 'xyzw'`` false, which never held on ``main`` + and would have been this PR's one real behaviour break.""" + from pcapkit.const.ftp.command import Command, CommandType, ConformanceRequirement + + before = len(Command.__members__) + first = Command('pypcapkit860probe') + after = len(Command.__members__) + second = Command('pypcapkit860probe') + + self.assertEqual(before, after) + self.assertNotIn('PYPCAPKIT860PROBE', Command.__members__) + self.assertEqual(first.value, 'pypcapkit860probe') + self.assertEqual(first, 'pypcapkit860probe') + self.assertEqual(first, second) + self.assertIsNot(first, second) + + # Every attribute __new__ would have set, read without raising. + self.assertIsNone(first.feat) + self.assertIsNone(first.desc) + self.assertEqual(first.type, CommandType.undefined) + self.assertEqual(first.conf, ConformanceRequirement.O) + self.assertEqual(repr(first), '') + + def test_command_get_unknown_no_longer_mints(self) -> None: + """The value keeps the caller's own casing (see :meth:`Command. + _unregistered_member`'s own docstring) -- so a repeated call with + the *same* casing is equal but not identical, while a *different* + casing is a genuinely different value and correctly not equal. On + ``main`` minting's cache made both calls return the identical, + first-seen-casing object regardless; losing that is #860's one + observable behaviour change here, not a casing change.""" + from pcapkit.const.ftp.command import Command + + before = len(Command.__members__) + first = Command.get('pypcapkit860probe2') + after = len(Command.__members__) + repeated = Command.get('pypcapkit860probe2') + different_case = Command.get('PYPCAPKIT860PROBE2') + + self.assertEqual(before, after) + self.assertNotIn('PYPCAPKIT860PROBE2', Command.__members__) + self.assertEqual(first, 'pypcapkit860probe2') + self.assertEqual(first, repeated) + self.assertIsNot(first, repeated) + self.assertEqual(different_case, 'PYPCAPKIT860PROBE2') + self.assertNotEqual(first, different_case) + + def test_method_missing_no_longer_mints(self) -> None: + """Same convention as :class:`~pcapkit.const.ftp.command.FEATCode`'s + and :class:`~pcapkit.const.ftp.command.Command`'s: an *unregistered* + member's value is the caller's own casing, with real :class:`str` + content. That leaves a genuine asymmetry with every *registered* + member of this class, tracked as GitHub issue #870 rather than + fixed here: :meth:`Method.__new__` is untouched, and it calls + ``str.__new__(cls)`` with no argument at all, so all 40 declared + members' own :class:`str` payload is permanently empty regardless + of value (``str(Method.GET) == ''``, ``Method.GET == 'GET'`` is + :obj:`False`) -- on ``main`` as well as here. This test is about + the *unregistered* path only, which -- because it bypasses + ``__new__`` entirely rather than being routed through its bug -- + gets real content where a *minted* lookup of the same unrecognised + word used to get the same permanently-empty payload on ``main`` + too.""" + from pcapkit.const.http.method import Method + + # The asymmetry itself, pinned directly: a registered member's + # payload is still empty, unchanged and not addressed by this PR. + self.assertEqual(str(Method.GET), '') + self.assertNotEqual(Method.GET, 'GET') + + before = len(Method.__members__) + first = Method('pypcapkit860probe') + after = len(Method.__members__) + second = Method('pypcapkit860probe') + + self.assertEqual(before, after) + self.assertNotIn('PYPCAPKIT860PROBE', Method.__members__) + self.assertEqual(str(first), 'pypcapkit860probe') + self.assertEqual(first, 'pypcapkit860probe') + self.assertEqual(first, second) + self.assertIsNot(first, second) + + # Every attribute __new__ would have set, read without raising. + self.assertFalse(first.safe) + self.assertFalse(first.idempotent) + # Method.__repr__ reads _value_, not _name_ (unlike Command's, + # which reads _name_ -- see the difference reflected here), so the + # caller's own casing shows through in the repr too. + self.assertEqual(repr(first), '') + + def test_method_get_unknown_no_longer_mints(self) -> None: + """Same convention and same reasoning as :meth:`Command. + _unregistered_member`'s -- the value keeps the caller's own + casing, so same-casing repeats are equal-not-identical and a + different casing is correctly not equal.""" + from pcapkit.const.http.method import Method + + before = len(Method.__members__) + first = Method.get('pypcapkit860probe2') + after = len(Method.__members__) + repeated = Method.get('pypcapkit860probe2') + different_case = Method.get('PYPCAPKIT860PROBE2') + + self.assertEqual(before, after) + self.assertNotIn('PYPCAPKIT860PROBE2', Method.__members__) + self.assertEqual(first, 'pypcapkit860probe2') + self.assertEqual(first, repeated) + self.assertIsNot(first, repeated) + self.assertEqual(different_case, 'PYPCAPKIT860PROBE2') + self.assertNotEqual(first, different_case) + + def test_registered_lookups_are_still_unaffected(self) -> None: + """A word IANA already assigned still resolves to the same, + genuinely-registered, identical member every time -- this + conversion only changes what happens for a word that is not one of + those.""" + from pcapkit.const.ftp.command import Command + from pcapkit.const.http.method import Method + + self.assertIs(Command('RETR'), Command.RETR) # type: ignore[attr-defined] + self.assertIs(Command.get('retr'), Command.RETR) # type: ignore[attr-defined] + self.assertIs(Method('GET'), Method.GET) # type: ignore[attr-defined] + self.assertIs(Method.get('get'), Method.GET) # type: ignore[attr-defined] + + def test_all_three_carry_the_registry_protocol(self) -> None: + """#842's ruling is that ``get``/``get_all``/``register``/ + ``register_alias`` exist on every registry -- #860 step 2 is what + actually delivers that for these three.""" + from pcapkit.const.ftp.command import Command, FEATCode + from pcapkit.const.http.method import Method + from pcapkit.corekit.enum import EnumRegistry + + for cls in (FEATCode, Command, Method): + with self.subTest(registry=cls.__name__): + self.assertTrue(issubclass(cls, EnumRegistry)) + self.assertTrue(callable(getattr(cls, 'get_all', None))) + self.assertTrue(callable(getattr(cls, 'register', None))) + self.assertTrue(callable(getattr(cls, 'register_alias', None))) + + def test_commandtype_and_transportprotocol_are_untouched(self) -> None: + """The owner's ruling also floated ``CommandType`` -> ``IntEnum`` and + ``TransportProtocol`` -> ``auto()``. Both are re-opened as + `needs: decision` on #860 and are explicitly out of PR 1's scope -- + ``CommandType`` composes for real (measured: 2 occurrences in the + generated data join two kinds with ``/``, e.g. access *and* + parameter, which a plain ``IntEnum`` cannot represent), and + ``TransportProtocol`` is already ``IntEnum`` (#808 dropped + ``IntFlag``), so only ``auto()`` would apply there -- and under + ``auto()`` ``tcp | udp == 3 == sctp``, silently misrouting a + composite #836 documented as refused outright. Pinned here so a + future change to either is noticed as a scope change rather than + folded silently into this PR.""" + from pcapkit.const.ftp.command import CommandType + from pcapkit.const.reg.apptype.apptype import TransportProtocol + from pcapkit.corekit.enum import EnumRegistry + + self.assertFalse(issubclass(CommandType, EnumRegistry)) + self.assertFalse(issubclass(TransportProtocol, EnumRegistry)) + self.assertEqual(CommandType.A | CommandType.P, 3) + self.assertEqual(int(TransportProtocol.tcp) | int(TransportProtocol.udp), 3) + + +class BespokeGetReplacementTests(unittest.TestCase): + """:class:`~pcapkit.const.http.status_code.StatusCode` and + :class:`~pcapkit.const.ftp.return_code.ReturnCode` are the two of the + five converted classes whose hand-written ``get()`` -- on the + ``default == -1`` convention #857/#859 already retired everywhere else + -- was removed in favour of the base's :meth:`~pcapkit.corekit.enum. + EnumRegistry.get`, which uses :data:`~pcapkit.corekit.enum.NO_DEFAULT` + and never mints while resolving ``default`` (#864). + + Verified before this replacement that no caller in :mod:`pcapkit` or + :mod:`tests` depends on the retired form: ``grep`` found exactly one + production call site each (:mod:`pcapkit.protocols.application.httpv1` + and :mod:`pcapkit.protocols.application.ftp`), neither passing a + ``default`` or a ``str`` key -- both call ``get()`` with no default, + which raises identically either way on an unresolvable key. Their own + ``str``-key ``get()`` branch (look up-or-mint *by name*, keyed on + ``default``) had no caller anywhere in this tree and is simply gone; the + base's ``str``-key path does an ordinary name-then-value lookup instead + and never mints. + + """ + + def setUp(self) -> None: + snapshot = snapshot_modules(ISOLATED_PREFIXES) + purge_modules(['pcapkit']) + self.addCleanup(restore_modules, snapshot, ISOLATED_PREFIXES) + + def test_statuscode_get_omitted_default_still_raises(self) -> None: + """The one production call site's shape: no default passed, so an + unresolvable key must still raise -- true under both the old + ``default == -1`` convention and the new ``NO_DEFAULT`` one.""" + from pcapkit.const.http.status_code import StatusCode + + with self.assertRaises(ValueError): + StatusCode.get(9999) + + def test_statuscode_get_minus_one_is_now_an_ordinary_default(self) -> None: + """Behaviour change, stated plainly: ``-1`` used to be the sentinel + for *no default*; it is now just an :class:`int` that -- like any + other -- is only honoured if it already names a registered member. + ``-1`` never has, on this registry, so passing it explicitly now + raises the *original* key's error instead of re-raising because it + matched the old sentinel.""" + from pcapkit.const.http.status_code import StatusCode + + with self.assertRaises(ValueError) as caught: + StatusCode.get(9999, -1) + self.assertIn('9999', str(caught.exception)) + + def test_statuscode_get_default_never_mints(self) -> None: + """#864's ruling, on this registry for the first time: a ``default`` + landing inside a now-converted unassigned range does not resolve to + an unregistered member the way ``key`` does -- it simply does not + resolve, and ``key``'s own error propagates.""" + from pcapkit.const.http.status_code import StatusCode + + before = len(StatusCode.__members__) + with self.assertRaises(ValueError) as caught: + StatusCode.get(9999, 105) # 105 is itself in the Unassigned range + self.assertIn('9999', str(caught.exception)) + self.assertEqual(before, len(StatusCode.__members__)) + + def test_returncode_get_omitted_default_still_raises(self) -> None: + from pcapkit.const.ftp.return_code import ReturnCode + + with self.assertRaises(ValueError): + ReturnCode.get(9999) + + def test_returncode_get_default_resolves_to_a_real_member(self) -> None: + """The common, still-supported case: an unresolvable key falls back + to a ``default`` that already names a real member.""" + from pcapkit.const.ftp.return_code import ReturnCode + + result = ReturnCode.get(9999, 226) + self.assertIs(result, ReturnCode.CODE_226) # type: ignore[attr-defined] + + def test_statuscode_and_returncode_gained_get_all_register(self) -> None: + from pcapkit.const.http.status_code import StatusCode + from pcapkit.const.ftp.return_code import ReturnCode + + for cls in (StatusCode, ReturnCode): + with self.subTest(registry=cls.__name__): + self.assertTrue(callable(getattr(cls, 'get_all', None))) + self.assertTrue(callable(getattr(cls, 'register', None))) + self.assertTrue(callable(getattr(cls, 'register_alias', None))) + + +class BespokeGetUnchangedTests(unittest.TestCase): + """:class:`~pcapkit.const.ftp.command.Command`, + :class:`~pcapkit.const.http.method.Method` and + :class:`~pcapkit.const.pcapng.option_type.OptionType` keep their own + hand-written ``get()`` in PR 1, because each does real dispatch the + base's generic ``get()`` does not replicate and at least two of the three + are pinned, tested behaviour already: :class:`Command`/:class:`Method` + resolve case-insensitively (GitHub issues #582/#583 -- ``Command. + get('abor')`` must return :attr:`Command.ABOR`, not raise, and + ``Method.get('Get')`` must return :attr:`Method.GET` with its + :attr:`safe`/:attr:`idempotent` attributes intact), which the base's + plain ``_member_map_``/``_value2member_map_`` lookup does not do -- + swapping in the base would silently reintroduce #582/#583. + :class:`OptionType`'s ``get()`` does its own multi-namespace dispatch via + :attr:`__members_ns__` with no base equivalent at all. These are + regression guards, not new coverage. + + """ + + def setUp(self) -> None: + snapshot = snapshot_modules(ISOLATED_PREFIXES) + purge_modules(['pcapkit']) + self.addCleanup(restore_modules, snapshot, ISOLATED_PREFIXES) + + def test_command_get_is_still_case_insensitive(self) -> None: + from pcapkit.const.ftp.command import Command + + for key in ('RETR', 'retr', 'ReTr', 'rEtR'): + with self.subTest(key=key): + self.assertIs(Command.get(key), Command.RETR) # type: ignore[attr-defined] + + def test_method_get_is_still_case_insensitive(self) -> None: + from pcapkit.const.http.method import Method + + for key in ('GET', 'Get', 'get', 'gEt'): + with self.subTest(key=key): + self.assertIs(Method.get(key), Method.GET) # type: ignore[attr-defined] + self.assertTrue(Method.get('Get').safe) + + def test_optiontype_get_namespace_dispatch_is_unchanged(self) -> None: + from pcapkit.const.pcapng.option_type import OptionType + + self.assertIs(OptionType.get(2, namespace='if'), OptionType.if_name) # type: ignore[attr-defined] + self.assertIs(OptionType.get(2, namespace='epb'), OptionType.epb_flags) # type: ignore[attr-defined] + + if __name__ == '__main__': unittest.main() diff --git a/tests/const/test_const_registry_protocol.py b/tests/const/test_const_registry_protocol.py index f28bac0ee3..94e396a6bb 100644 --- a/tests/const/test_const_registry_protocol.py +++ b/tests/const/test_const_registry_protocol.py @@ -695,18 +695,24 @@ class _Str(EnumRegistry, StrEnum): ) #: Const modules tier 3 deliberately leaves alone. The first six override -#: their crawler's own ``process()``/``context()`` with a bespoke, -#: mint-on-lookup ``get()``/``_missing_`` pair that does not share -#: :mod:`pcapkit.vendor.default`'s template at all -- converting any of them -#: onto :class:`~pcapkit.corekit.enum.EnumRegistry` would still silently -#: change behaviour rather than just move it, since four of the six are -#: :class:`~aenum.StrEnum` registries whose own ``__new__`` attaches further -#: attributes ``_unregistered_member`` does not set. GitHub issue #860 fixed +#: their crawler's own ``process()``/``context()`` with a bespoke ``get()``/ +#: ``_missing_`` pair that does not share :mod:`pcapkit.vendor.default`'s +#: template at all -- mixing :class:`~pcapkit.corekit.enum.EnumRegistry` in +#: without further care would still have silently changed behaviour rather +#: than just moved it, since four of the six are :class:`~aenum.StrEnum` +#: registries whose own ``__new__`` attaches further attributes the base's +#: generic ``_unregistered_member`` does not set. GitHub issue #860 fixed #: the base ``get()``'s own str-key-never-tries-the-value-path limitation #: this comment used to cite -- measured on a synthetic registry in -#: :class:`StrEnumValueFallbackTests` below -- but that fix only reaches a -#: registry that already mixes in the base; actually converting these six is -#: issue #860's still-open step 2, not this tier. The remaining four +#: :class:`StrEnumValueFallbackTests` below -- and issue #860's step 2 has +#: since actually converted five of these six (``FEATCode``, ``Command``, +#: ``Method``, ``ReturnCode``/``ResponseKind``/``GroupingInformation``, +#: ``StatusCode`` and ``OptionType``, each with its own +#: ``_unregistered_member`` override where a custom ``__new__`` needed one -- +#: PR 1), leaving only ``AppType`` (PR 2). They stay excluded from *this* +#: file's tier-3 sweep regardless, because the exclusion here is about their +#: bespoke ``process()``/``get()`` shape not sharing the generated template, +#: which conversion onto the base does not change. The remaining four #: (``reg/apptype``'s transport subclasses) plus ``AppType``/ #: ``TransportProtocol`` themselves are excluded for the separate reason #: :mod:`pcapkit.corekit.enum`'s own module docstring gives: they are tier 2 @@ -1098,20 +1104,31 @@ class StrEnumValueFallbackTests(unittest.TestCase): :class:`~pcapkit.corekit.enum.EnumRegistry` itself (added by tier 1, #855, which never converted a :class:`~aenum.StrEnum` registry either), found during #858's review on a synthetic registry rather than a real - one, since none of the four bespoke ``StrEnum`` const registries mix in - this base yet (:data:`EXCLUDED_STILL_BESPOKE` above) -- that conversion - is #860's separate, still-open step 2. + one, since at the time none of the four bespoke ``StrEnum`` const + registries mixed in this base yet (:data:`EXCLUDED_STILL_BESPOKE` above, + which still holds them out of *this* file's tier-3 sweep for a different + reason -- their bespoke ``__new__``/``get()`` shapes, not their base) -- + that conversion was #860's separate step 2, landed by PR 1 for three of + the four (``FEATCode``, ``Command``, ``Method``) and still open for the + fourth (``AppType``, PR 2). Fixed here by falling back to a plain ``_value2member_map_`` lookup, not - ``cls(key)``: :class:`~pcapkit.const.ftp.command.FEATCode`'s own - ``_missing_`` mints directly via :func:`~aenum.extend_enum` for any - unrecognised value (that minting call reproduced verbatim in + ``cls(key)``: at the time this was written, :class:`~pcapkit.const.ftp. + command.FEATCode`'s own ``_missing_`` minted directly via + :func:`~aenum.extend_enum` for any unrecognised value (that minting call + reproduced verbatim in :meth:`test_get_never_mints_on_a_registry_whose_missing_mints_directly` - below), so routing the value fallback through the constructor would let - a failed *name* lookup mint a permanent member the moment #860's step 2 - converts a registry like it onto this base -- exactly the "never mints" - defect :meth:`~pcapkit.corekit.enum.EnumRegistry.get`'s own docstring - rules out. A raw dict lookup can never reach ``_missing_``, so it cannot + below), so routing the value fallback through the constructor would have + let a failed *name* lookup mint a permanent member the moment #860's + step 2 converted a registry like it onto this base -- exactly the + "never mints" defect :meth:`~pcapkit.corekit.enum.EnumRegistry.get`'s + own docstring rules out. Step 2 has since landed and converted + ``FEATCode`` itself (its ``_missing_`` now calls + ``_unregistered_member`` rather than ``extend_enum``, per the owner's + #860 ruling), which is why the fixture below is a synthetic local class + reproducing the *old* shape rather than importing the real one -- the + defect this test guards against is general, not tied to one now-fixed + example. A raw dict lookup can never reach ``_missing_``, so it cannot mint regardless of what a subclass's own ``_missing_`` does. """ @@ -1171,11 +1188,17 @@ class _Str(EnumRegistry, StrEnum): def test_get_never_mints_on_a_registry_whose_missing_mints_directly(self) -> None: """The crux this fix has to get right: the minting half of this - fixture's ``_missing_`` is verbatim - :meth:`~pcapkit.const.ftp.command.FEATCode._missing_` - (``pcapkit/const/ftp/command.py:52``) -- ``extend_enum(cls, - value.upper(), value)`` for any unrecognised string. Its non-``str`` - branch differs (delegates to ``super()._missing_`` rather than + fixture's ``_missing_`` was, at the time this test was written, + verbatim :meth:`~pcapkit.const.ftp.command.FEATCode._missing_`'s own + body -- ``extend_enum(cls, value.upper(), value)`` for any + unrecognised string. GitHub issue #860 step 2 has since converted + the real ``FEATCode`` onto ``_unregistered_member`` (per the owner's + ruling that ``get``/``_missing_`` should not mint there either), so + this fixture is kept as a synthetic, self-contained reproduction of + the *old* shape rather than updated to import the real class -- + the point of this test is the general defect class, which a fixed + example can no longer demonstrate. Its non-``str`` branch differs + from that old shape (delegates to ``super()._missing_`` rather than ``FEATCode``'s own explicit ``ValueError``), which is immaterial to what this test proves. A naive ``return cls(key)`` fallback would mint a permanent member from a mere failed lookup; the diff --git a/tests/protocols/application/test_ftp_unit.py b/tests/protocols/application/test_ftp_unit.py index d7f801fd9c..14b088de94 100644 --- a/tests/protocols/application/test_ftp_unit.py +++ b/tests/protocols/application/test_ftp_unit.py @@ -92,11 +92,30 @@ def test_command_get_is_case_insensitive(self) -> None: # form raised too. self.assertIs(Command('retr'), Command.RETR) - # A genuinely unknown command still registers, under its canonical - # upper-case name, and does not create a case-variant duplicate. + # GitHub issue #860: a genuinely unknown command no longer registers + # at all -- the owner's ruling is that ``get``/``_missing_`` have no + # way to supply ``feat``/``desc``/``type``/``conf``, so minting one + # would register a permanently hollowed-out member; only + # ``register()`` can do that properly. The *value* keeps the + # caller's own casing (unchanged from before #860, and the same + # convention :class:`~pcapkit.const.ftp.command.FEATCode` already + # used) -- only the *name* is canonicalised -- so a repeated call + # with the same casing is *equal* but never *identical* (nothing is + # cached to be identical to any more), while a different-case call + # is a genuinely different value and is correctly *not* equal: on + # ``main`` minting's cache silently returned the first casing seen + # for every later call regardless of case, which is exactly the + # "registered enum out of an unrecognised value" #860 removes. unknown = Command.get('xyzw') self.assertEqual(unknown._name_, 'XYZW') - self.assertIs(Command.get('XYZW'), unknown) + self.assertEqual(unknown, 'xyzw') + repeated = Command.get('xyzw') + self.assertEqual(repeated, unknown) + self.assertIsNot(repeated, unknown) + different_case = Command.get('XYZW') + self.assertEqual(different_case, 'XYZW') + self.assertNotEqual(different_case, unknown) + self.assertNotIn('XYZW', Command.__members__) self.assertEqual([name for name in Command._member_map_ if name.upper() == 'RETR'], ['RETR']) diff --git a/tests/protocols/application/test_http_unit.py b/tests/protocols/application/test_http_unit.py index 3865e160e4..d99170544b 100644 --- a/tests/protocols/application/test_http_unit.py +++ b/tests/protocols/application/test_http_unit.py @@ -1497,9 +1497,30 @@ def test_method_get_is_case_insensitive(self) -> None: self.assertEqual([name for name in Method._member_map_ if name.upper() == 'GET'], ['GET']) + # GitHub issue #860: a genuinely unknown method no longer registers + # at all -- the owner's ruling is that ``get``/``_missing_`` have no + # way to supply ``safe``/``idempotent``, so minting one would + # register a permanently hollowed-out member; only ``register()`` + # can do that properly. The *value* keeps the caller's own casing + # (unchanged from before #860, and the same convention + # :class:`~pcapkit.const.ftp.command.FEATCode` already used) -- + # only the *name* is canonicalised -- so a repeated call with the + # same casing is *equal* but never *identical* (nothing is cached + # to be identical to any more), while a different-case call is a + # genuinely different value and is correctly *not* equal: on + # ``main`` minting's cache silently returned the first casing seen + # for every later call regardless of case, which is exactly the + # "registered enum out of an unrecognised value" #860 removes. unknown = Method.get('frob') self.assertEqual(unknown._name_, 'FROB') - self.assertIs(Method.get('FROB'), unknown) + self.assertEqual(unknown, 'frob') + repeated = Method.get('frob') + self.assertEqual(repeated, unknown) + self.assertIsNot(repeated, unknown) + different_case = Method.get('FROB') + self.assertEqual(different_case, 'FROB') + self.assertNotEqual(different_case, unknown) + self.assertNotIn('FROB', Method.__members__) def test_httpv1_method_regex_is_anchored(self) -> None: """``_RE_METHOD`` was unanchored and :func:`re.match` anchors only at the diff --git a/tests/vendor/test_ftp_return_code_unit.py b/tests/vendor/test_ftp_return_code_unit.py index 368394e5f1..89b9b1ba08 100644 --- a/tests/vendor/test_ftp_return_code_unit.py +++ b/tests/vendor/test_ftp_return_code_unit.py @@ -188,7 +188,8 @@ def test_context_survives_the_cell_less_row(self) -> None: self.assertIn("CODE_110: 'ReturnCode' = 110", context) self.assertIn("CODE_200: 'ReturnCode' = 200", context) - self.assertIn('class ReturnCode(IntEnum):', context) + # GitHub issue #860: ReturnCode now mixes in EnumRegistry. + self.assertIn('class ReturnCode(EnumRegistry, IntEnum):', context) if __name__ == '__main__':