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__':