[Python-checkins] [3.10] bpo-44242: [Enum] remove missing bits test from Flag creation (GH-26586) (GH-26635)

ethanfurman webhook-mailer at python.org
Thu Jun 10 10:24:26 EDT 2021


https://github.com/python/cpython/commit/749648609de89f14581190ea34b9c0968787a701
commit: 749648609de89f14581190ea34b9c0968787a701
branch: 3.10
author: Ethan Furman <ethan at stoneleaf.us>
committer: ethanfurman <ethan at stoneleaf.us>
date: 2021-06-10T07:24:20-07:00
summary:

[3.10] bpo-44242: [Enum] remove missing bits test from Flag creation (GH-26586) (GH-26635)

Move the check for missing named flags in flag aliases from Flag creation
to a new *verify* decorator..

(cherry picked from commit eea8148b7dff5ffc7b84433859ac819b1d92a74d)

Co-authored-by: Ethan Furman <ethan at stoneleaf.us>

files:
A Misc/NEWS.d/next/Library/2021-06-07-10-26-14.bpo-44242.MKeMCQ.rst
M Doc/library/enum.rst
M Lib/enum.py
M Lib/test/test_enum.py

diff --git a/Doc/library/enum.rst b/Doc/library/enum.rst
index 137fe500c9569..2d19ef6f25657 100644
--- a/Doc/library/enum.rst
+++ b/Doc/library/enum.rst
@@ -28,8 +28,8 @@ An enumeration:
 
 * is a set of symbolic names (members) bound to unique values
 * can be iterated over to return its members in definition order
-* uses :meth:`call` syntax to return members by value
-* uses :meth:`index` syntax to return members by name
+* uses *call* syntax to return members by value
+* uses *index* syntax to return members by name
 
 Enumerations are created either by using the :keyword:`class` syntax, or by
 using function-call syntax::
@@ -91,6 +91,12 @@ Module Contents
       the bitwise operators without losing their :class:`IntFlag` membership.
       :class:`IntFlag` members are also subclasses of :class:`int`.
 
+   :class:`EnumCheck`
+
+      An enumeration with the values ``CONTINUOUS``, ``NAMED_FLAGS``, and
+      ``UNIQUE``, for use with :func:`verify` to ensure various constraints
+      are met by a given enumeration.
+
    :class:`FlagBoundary`
 
       An enumeration with the values ``STRICT``, ``CONFORM``, ``EJECT``, and
@@ -117,9 +123,14 @@ Module Contents
 
       Enum class decorator that ensures only one name is bound to any one value.
 
+   :func:`verify`
+
+      Enum class decorator that checks user-selectable constraints on an
+      enumeration.
+
 
 .. versionadded:: 3.6  ``Flag``, ``IntFlag``, ``auto``
-.. versionadded:: 3.10  ``StrEnum``
+.. versionadded:: 3.10  ``StrEnum``, ``EnumCheck``, ``FlagBoundary``
 
 
 Data Types
@@ -514,6 +525,65 @@ Data Types
    Using :class:`auto` with :class:`IntFlag` results in integers that are powers
    of two, starting with ``1``.
 
+.. class:: EnumCheck
+
+   *EnumCheck* contains the options used by the :func:`verify` decorator to ensure
+   various constraints; failed constraints result in a :exc:`TypeError`.
+
+   .. attribute:: UNIQUE
+
+      Ensure that each value has only one name::
+
+         >>> from enum import Enum, verify, UNIQUE
+         >>> @verify(UNIQUE)
+         ... class Color(Enum):
+         ...     RED = 1
+         ...     GREEN = 2
+         ...     BLUE = 3
+         ...     CRIMSON = 1
+         Traceback (most recent call last):
+         ...
+         ValueError: aliases found in <enum 'Color'>: CRIMSON -> RED
+
+
+   .. attribute:: CONTINUOUS
+
+      Ensure that there are no missing values between the lowest-valued member
+      and the highest-valued member::
+
+         >>> from enum import Enum, verify, CONTINUOUS
+         >>> @verify(CONTINUOUS)
+         ... class Color(Enum):
+         ...     RED = 1
+         ...     GREEN = 2
+         ...     BLUE = 5
+         Traceback (most recent call last):
+         ...
+         ValueError: invalid enum 'Color': missing values 3, 4
+
+   .. attribute:: NAMED_FLAGS
+
+      Ensure that any flag groups/masks contain only named flags -- useful when
+      values are specified instead of being generated by :func:`auto`
+
+         >>> from enum import Flag, verify, NAMED_FLAGS
+         >>> @verify(NAMED_FLAGS)
+         ... class Color(Flag):
+         ...     RED = 1
+         ...     GREEN = 2
+         ...     BLUE = 4
+         ...     WHITE = 15
+         ...     NEON = 31
+         Traceback (most recent call last):
+         ...
+         ValueError: invalid Flag 'Color': 'WHITE' is missing a named flag for value 8; 'NEON' is missing named flags for values 8, 16
+
+.. note::
+
+   CONTINUOUS and NAMED_FLAGS are designed to work with integer-valued members.
+
+.. versionadded:: 3.10
+
 .. class:: FlagBoundary
 
    *FlagBoundary* controls how out-of-range values are handled in *Flag* and its
@@ -575,6 +645,7 @@ Data Types
          >>> KeepFlag(2**2 + 2**4)
          KeepFlag.BLUE|0x10
 
+.. versionadded:: 3.10
 
 Utilites and Decorators
 -----------------------
@@ -627,3 +698,11 @@ Utilites and Decorators
       Traceback (most recent call last):
       ...
       ValueError: duplicate values found in <enum 'Mistake'>: FOUR -> THREE
+
+.. decorator:: verify
+
+   A :keyword:`class` decorator specifically for enumerations.  Members from
+   :class:`EnumCheck` are used to specify which constraints should be checked
+   on the decorated enumeration.
+
+.. versionadded:: 3.10
diff --git a/Lib/enum.py b/Lib/enum.py
index 01f431001a9bf..f74cc8c31c84f 100644
--- a/Lib/enum.py
+++ b/Lib/enum.py
@@ -6,10 +6,10 @@
 __all__ = [
         'EnumType', 'EnumMeta',
         'Enum', 'IntEnum', 'StrEnum', 'Flag', 'IntFlag',
-        'auto', 'unique',
-        'property',
+        'auto', 'unique', 'property', 'verify',
         'FlagBoundary', 'STRICT', 'CONFORM', 'EJECT', 'KEEP',
         'global_flag_repr', 'global_enum_repr', 'global_enum',
+        'EnumCheck', 'CONTINUOUS', 'NAMED_FLAGS', 'UNIQUE',
         ]
 
 
@@ -89,6 +89,9 @@ def _break_on_call_reduce(self, proto):
         setattr(obj, '__module__', '<unknown>')
 
 def _iter_bits_lsb(num):
+    # num must be an integer
+    if isinstance(num, Enum):
+        num = num.value
     while num:
         b = num & (~num + 1)
         yield b
@@ -538,13 +541,6 @@ def __new__(metacls, cls, bases, classdict, *, boundary=None, _simple=False, **k
                 else:
                     # multi-bit flags are considered aliases
                     multi_bit_total |= flag_value
-            if enum_class._boundary_ is not KEEP:
-                missed = list(_iter_bits_lsb(multi_bit_total & ~single_bit_total))
-                if missed:
-                    raise TypeError(
-                            'invalid Flag %r -- missing values: %s'
-                            % (cls, ', '.join((str(i) for i in missed)))
-                            )
             enum_class._flag_mask_ = single_bit_total
             #
             # set correct __iter__
@@ -688,7 +684,10 @@ def __members__(cls):
         return MappingProxyType(cls._member_map_)
 
     def __repr__(cls):
-        return "<enum %r>" % cls.__name__
+        if Flag is not None and issubclass(cls, Flag):
+            return "<flag %r>" % cls.__name__
+        else:
+            return "<enum %r>" % cls.__name__
 
     def __reversed__(cls):
         """
@@ -1303,7 +1302,8 @@ def __invert__(self):
             else:
                 # calculate flags not in this member
                 self._inverted_ = self.__class__(self._flag_mask_ ^ self._value_)
-            self._inverted_._inverted_ = self
+            if isinstance(self._inverted_, self.__class__):
+                self._inverted_._inverted_ = self
         return self._inverted_
 
 
@@ -1561,6 +1561,91 @@ def convert_class(cls):
         return enum_class
     return convert_class
 
+ at _simple_enum(StrEnum)
+class EnumCheck:
+    """
+    various conditions to check an enumeration for
+    """
+    CONTINUOUS = "no skipped integer values"
+    NAMED_FLAGS = "multi-flag aliases may not contain unnamed flags"
+    UNIQUE = "one name per value"
+CONTINUOUS, NAMED_FLAGS, UNIQUE = EnumCheck
+
+
+class verify:
+    """
+    Check an enumeration for various constraints. (see EnumCheck)
+    """
+    def __init__(self, *checks):
+        self.checks = checks
+    def __call__(self, enumeration):
+        checks = self.checks
+        cls_name = enumeration.__name__
+        if Flag is not None and issubclass(enumeration, Flag):
+            enum_type = 'flag'
+        elif issubclass(enumeration, Enum):
+            enum_type = 'enum'
+        else:
+            raise TypeError("the 'verify' decorator only works with Enum and Flag")
+        for check in checks:
+            if check is UNIQUE:
+                # check for duplicate names
+                duplicates = []
+                for name, member in enumeration.__members__.items():
+                    if name != member.name:
+                        duplicates.append((name, member.name))
+                if duplicates:
+                    alias_details = ', '.join(
+                            ["%s -> %s" % (alias, name) for (alias, name) in duplicates])
+                    raise ValueError('aliases found in %r: %s' %
+                            (enumeration, alias_details))
+            elif check is CONTINUOUS:
+                values = set(e.value for e in enumeration)
+                if len(values) < 2:
+                    continue
+                low, high = min(values), max(values)
+                missing = []
+                if enum_type == 'flag':
+                    # check for powers of two
+                    for i in range(_high_bit(low)+1, _high_bit(high)):
+                        if 2**i not in values:
+                            missing.append(2**i)
+                elif enum_type == 'enum':
+                    # check for powers of one
+                    for i in range(low+1, high):
+                        if i not in values:
+                            missing.append(i)
+                else:
+                    raise Exception('verify: unknown type %r' % enum_type)
+                if missing:
+                    raise ValueError('invalid %s %r: missing values %s' % (
+                            enum_type, cls_name, ', '.join((str(m) for m in missing)))
+                            )
+            elif check is NAMED_FLAGS:
+                # examine each alias and check for unnamed flags
+                member_names = enumeration._member_names_
+                member_values = [m.value for m in enumeration]
+                missing = []
+                for name, alias in enumeration._member_map_.items():
+                    if name in member_names:
+                        # not an alias
+                        continue
+                    values = list(_iter_bits_lsb(alias.value))
+                    missed = [v for v in values if v not in member_values]
+                    if missed:
+                        plural = ('', 's')[len(missed) > 1]
+                        a =  ('a ', '')[len(missed) > 1]
+                        missing.append('%r is missing %snamed flag%s for value%s %s' % (
+                                name, a, plural, plural,
+                                ', '.join(str(v) for v in missed)
+                                ))
+                if missing:
+                    raise ValueError(
+                            'invalid Flag %r: %s'
+                            % (cls_name, '; '.join(missing))
+                            )
+        return enumeration
+
 def _test_simple_enum(checked_enum, simple_enum):
     """
     A function that can be used to test an enum created with :func:`_simple_enum`
diff --git a/Lib/test/test_enum.py b/Lib/test/test_enum.py
index e918b03cc6c52..34b190b0d289f 100644
--- a/Lib/test/test_enum.py
+++ b/Lib/test/test_enum.py
@@ -9,6 +9,7 @@
 from collections import OrderedDict
 from enum import Enum, IntEnum, StrEnum, EnumType, Flag, IntFlag, unique, auto
 from enum import STRICT, CONFORM, EJECT, KEEP, _simple_enum, _test_simple_enum
+from enum import verify, UNIQUE, CONTINUOUS, NAMED_FLAGS
 from io import StringIO
 from pickle import dumps, loads, PicklingError, HIGHEST_PROTOCOL
 from test import support
@@ -2774,13 +2775,6 @@ class Dupes(Enum):
             third = auto()
         self.assertEqual([Dupes.first, Dupes.second, Dupes.third], list(Dupes))
 
-    def test_bizarre(self):
-        with self.assertRaisesRegex(TypeError, "invalid Flag 'Bizarre' -- missing values: 1, 2"):
-            class Bizarre(Flag):
-                b = 3
-                c = 4
-                d = 6
-
     def test_multiple_mixin(self):
         class AllMixin:
             @classproperty
@@ -3345,12 +3339,6 @@ def test_bool(self):
         for f in Open:
             self.assertEqual(bool(f.value), bool(f))
 
-    def test_bizarre(self):
-        with self.assertRaisesRegex(TypeError, "invalid Flag 'Bizarre' -- missing values: 1, 2"):
-            class Bizarre(IntFlag):
-                b = 3
-                c = 4
-                d = 6
 
     def test_multiple_mixin(self):
         class AllMixin:
@@ -3459,6 +3447,7 @@ class Clean(Enum):
             one = 1
             two = 'dos'
             tres = 4.0
+        #
         @unique
         class Cleaner(IntEnum):
             single = 1
@@ -3484,12 +3473,137 @@ class Dirtier(IntEnum):
                 turkey = 3
 
     def test_unique_with_name(self):
-        @unique
+        @verify(UNIQUE)
         class Silly(Enum):
             one = 1
             two = 'dos'
             name = 3
-        @unique
+        #
+        @verify(UNIQUE)
+        class Sillier(IntEnum):
+            single = 1
+            name = 2
+            triple = 3
+            value = 4
+
+class TestVerify(unittest.TestCase):
+
+    def test_continuous(self):
+        @verify(CONTINUOUS)
+        class Auto(Enum):
+            FIRST = auto()
+            SECOND = auto()
+            THIRD = auto()
+            FORTH = auto()
+        #
+        @verify(CONTINUOUS)
+        class Manual(Enum):
+            FIRST = 3
+            SECOND = 4
+            THIRD = 5
+            FORTH = 6
+        #
+        with self.assertRaisesRegex(ValueError, 'invalid enum .Missing.: missing values 5, 6, 7, 8, 9, 10, 12'):
+            @verify(CONTINUOUS)
+            class Missing(Enum):
+                FIRST = 3
+                SECOND = 4
+                THIRD = 11
+                FORTH = 13
+        #
+        with self.assertRaisesRegex(ValueError, 'invalid flag .Incomplete.: missing values 32'):
+            @verify(CONTINUOUS)
+            class Incomplete(Flag):
+                FIRST = 4
+                SECOND = 8
+                THIRD = 16
+                FORTH = 64
+        #
+        with self.assertRaisesRegex(ValueError, 'invalid flag .StillIncomplete.: missing values 16'):
+            @verify(CONTINUOUS)
+            class StillIncomplete(Flag):
+                FIRST = 4
+                SECOND = 8
+                THIRD = 11
+                FORTH = 32
+
+
+    def test_composite(self):
+        class Bizarre(Flag):
+            b = 3
+            c = 4
+            d = 6
+        self.assertEqual(list(Bizarre), [Bizarre.c])
+        self.assertEqual(Bizarre.b.value, 3)
+        self.assertEqual(Bizarre.c.value, 4)
+        self.assertEqual(Bizarre.d.value, 6)
+        with self.assertRaisesRegex(
+                ValueError,
+                "invalid Flag 'Bizarre': 'b' is missing named flags for values 1, 2; 'd' is missing a named flag for value 2",
+            ):
+            @verify(NAMED_FLAGS)
+            class Bizarre(Flag):
+                b = 3
+                c = 4
+                d = 6
+        #
+        class Bizarre(IntFlag):
+            b = 3
+            c = 4
+            d = 6
+        self.assertEqual(list(Bizarre), [Bizarre.c])
+        self.assertEqual(Bizarre.b.value, 3)
+        self.assertEqual(Bizarre.c.value, 4)
+        self.assertEqual(Bizarre.d.value, 6)
+        with self.assertRaisesRegex(
+                ValueError,
+                "invalid Flag 'Bizarre': 'b' is missing named flags for values 1, 2; 'd' is missing a named flag for value 2",
+            ):
+            @verify(NAMED_FLAGS)
+            class Bizarre(IntFlag):
+                b = 3
+                c = 4
+                d = 6
+
+    def test_unique_clean(self):
+        @verify(UNIQUE)
+        class Clean(Enum):
+            one = 1
+            two = 'dos'
+            tres = 4.0
+        #
+        @verify(UNIQUE)
+        class Cleaner(IntEnum):
+            single = 1
+            double = 2
+            triple = 3
+
+    def test_unique_dirty(self):
+        with self.assertRaisesRegex(ValueError, 'tres.*one'):
+            @verify(UNIQUE)
+            class Dirty(Enum):
+                one = 1
+                two = 'dos'
+                tres = 1
+        with self.assertRaisesRegex(
+                ValueError,
+                'double.*single.*turkey.*triple',
+                ):
+            @verify(UNIQUE)
+            class Dirtier(IntEnum):
+                single = 1
+                double = 1
+                triple = 3
+                turkey = 3
+
+    def test_unique_with_name(self):
+        @verify(UNIQUE)
+        class Silly(Enum):
+            one = 1
+            two = 'dos'
+            name = 3
+        #
+        @verify(UNIQUE)
         class Sillier(IntEnum):
             single = 1
             name = 2
diff --git a/Misc/NEWS.d/next/Library/2021-06-07-10-26-14.bpo-44242.MKeMCQ.rst b/Misc/NEWS.d/next/Library/2021-06-07-10-26-14.bpo-44242.MKeMCQ.rst
new file mode 100644
index 0000000000000..39740b6736591
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2021-06-07-10-26-14.bpo-44242.MKeMCQ.rst
@@ -0,0 +1,2 @@
+Remove missing flag check from Enum creation and move into a ``verify``
+decorator.



More information about the Python-checkins mailing list