From 6bd9288b805c765ec2433f66aa4d82e05767325f Mon Sep 17 00:00:00 2001 From: Ethan Furman Date: Tue, 27 Apr 2021 13:05:08 -0700 Subject: bpo-43957: [Enum] Deprecate ``TypeError`` from containment checks. (GH-25670) In 3.12 ``True`` or ``False`` will be returned for all containment checks, with ``True`` being returned if the value is either a member of that enum or one of its members' value. --- Doc/library/enum.rst | 6 + Lib/enum.py | 29 +++-- Lib/test/test_enum.py | 143 +++++++++++++++++---- Lib/test/test_signal.py | 2 +- .../2021-04-27-12-13-51.bpo-43957.6EaPD-.rst | 4 + 5 files changed, 148 insertions(+), 36 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2021-04-27-12-13-51.bpo-43957.6EaPD-.rst diff --git a/Doc/library/enum.rst b/Doc/library/enum.rst index 91c214e..b5f9c2f 100644 --- a/Doc/library/enum.rst +++ b/Doc/library/enum.rst @@ -140,6 +140,12 @@ Data Types >>> some_var in Color True + .. note:: + + In Python 3.12 it will be possible to check for member values and not + just members; until then, a ``TypeError`` will be raised if a + non-Enum-member is used in a containment check. + .. method:: EnumType.__dir__(cls) Returns ``['__class__', '__doc__', '__members__', '__module__']`` and the diff --git a/Lib/enum.py b/Lib/enum.py index bcf411c..bccf024 100644 --- a/Lib/enum.py +++ b/Lib/enum.py @@ -280,7 +280,8 @@ class _proto_member: # linear. enum_class._value2member_map_.setdefault(value, enum_member) except TypeError: - pass + # keep track of the value in a list so containment checks are quick + enum_class._unhashable_values_.append(value) class _EnumDict(dict): @@ -440,6 +441,7 @@ class EnumType(type): classdict['_member_names_'] = [] classdict['_member_map_'] = {} classdict['_value2member_map_'] = {} + classdict['_unhashable_values_'] = [] classdict['_member_type_'] = member_type # # Flag structures (will be removed if final class is not a Flag @@ -622,6 +624,13 @@ class EnumType(type): def __contains__(cls, member): if not isinstance(member, Enum): + import warnings + warnings.warn( + "in 3.12 __contains__ will no longer raise TypeError, but will return True or\n" + "False depending on whether the value is a member or the value of a member", + DeprecationWarning, + stacklevel=2, + ) raise TypeError( "unsupported operand type(s) for 'in': '%s' and '%s'" % ( type(member).__qualname__, cls.__class__.__qualname__)) @@ -1005,14 +1014,15 @@ class Enum(metaclass=EnumType): val = str(self) # mix-in branch else: - import warnings - warnings.warn( - "in 3.12 format() will use the enum member, not the enum member's value;\n" - "use a format specifier, such as :d for an IntEnum member, to maintain" - "the current display", - DeprecationWarning, - stacklevel=2, - ) + if not format_spec or format_spec in ('{}','{:}'): + import warnings + warnings.warn( + "in 3.12 format() will use the enum member, not the enum member's value;\n" + "use a format specifier, such as :d for an IntEnum member, to maintain" + "the current display", + DeprecationWarning, + stacklevel=2, + ) cls = self._member_type_ val = self._value_ return cls.__format__(val, format_spec) @@ -1434,6 +1444,7 @@ def _simple_enum(etype=Enum, *, boundary=None, use_args=None): body['_member_names_'] = member_names = [] body['_member_map_'] = member_map = {} body['_value2member_map_'] = value2member_map = {} + body['_unhashable_values_'] = [] body['_member_type_'] = member_type = etype._member_type_ if issubclass(etype, Flag): body['_boundary_'] = boundary or etype._boundary_ diff --git a/Lib/test/test_enum.py b/Lib/test/test_enum.py index 983c54b..e918b03 100644 --- a/Lib/test/test_enum.py +++ b/Lib/test/test_enum.py @@ -16,6 +16,8 @@ from test.support import ALWAYS_EQ from test.support import threading_helper from datetime import timedelta +python_version = sys.version_info[:2] + def load_tests(loader, tests, ignore): tests.addTests(doctest.DocTestSuite(enum)) if os.path.exists('Doc/library/enum.rst'): @@ -352,17 +354,38 @@ class TestEnum(unittest.TestCase): self.assertTrue(IntLogic.true) self.assertFalse(IntLogic.false) - def test_contains(self): + @unittest.skipIf( + python_version >= (3, 12), + '__contains__ now returns True/False for all inputs', + ) + def test_contains_er(self): Season = self.Season self.assertIn(Season.AUTUMN, Season) with self.assertRaises(TypeError): - 3 in Season + with self.assertWarns(DeprecationWarning): + 3 in Season with self.assertRaises(TypeError): - 'AUTUMN' in Season - + with self.assertWarns(DeprecationWarning): + 'AUTUMN' in Season val = Season(3) self.assertIn(val, Season) + # + class OtherEnum(Enum): + one = 1; two = 2 + self.assertNotIn(OtherEnum.two, Season) + @unittest.skipIf( + python_version < (3, 12), + '__contains__ only works with enum memmbers before 3.12', + ) + def test_contains_tf(self): + Season = self.Season + self.assertIn(Season.AUTUMN, Season) + self.assertTrue(3 in Season) + self.assertFalse('AUTUMN' in Season) + val = Season(3) + self.assertIn(val, Season) + # class OtherEnum(Enum): one = 1; two = 2 self.assertNotIn(OtherEnum.two, Season) @@ -528,8 +551,16 @@ class TestEnum(unittest.TestCase): self.assertEqual(str(TestFloat.one), 'one') self.assertEqual('{}'.format(TestFloat.one), 'TestFloat success!') - @unittest.skipUnless( - sys.version_info[:2] < (3, 12), + @unittest.skipIf( + python_version < (3, 12), + 'mixin-format is still using member.value', + ) + def test_mixin_format_warning(self): + with self.assertWarns(DeprecationWarning): + self.assertEqual(f'{self.Grades.B}', 'Grades.B') + + @unittest.skipIf( + python_version >= (3, 12), 'mixin-format now uses member instead of member.value', ) def test_mixin_format_warning(self): @@ -537,7 +568,11 @@ class TestEnum(unittest.TestCase): self.assertEqual(f'{self.Grades.B}', '4') def assertFormatIsValue(self, spec, member): - self.assertEqual(spec.format(member), spec.format(member.value)) + if python_version < (3, 12) and (not spec or spec in ('{}','{:}')): + with self.assertWarns(DeprecationWarning): + self.assertEqual(spec.format(member), spec.format(member.value)) + else: + self.assertEqual(spec.format(member), spec.format(member.value)) def test_format_enum_date(self): Holiday = self.Holiday @@ -2202,7 +2237,7 @@ class TestEnum(unittest.TestCase): description = 'Bn$', 3 @unittest.skipUnless( - sys.version_info[:2] == (3, 9), + python_version == (3, 9), 'private variables are now normal attributes', ) def test_warning_for_private_variables(self): @@ -2225,7 +2260,7 @@ class TestEnum(unittest.TestCase): self.assertEqual(Private._Private__major_, 'Hoolihan') @unittest.skipUnless( - sys.version_info[:2] < (3, 12), + python_version < (3, 12), 'member-member access now raises an exception', ) def test_warning_for_member_from_member_access(self): @@ -2237,7 +2272,7 @@ class TestEnum(unittest.TestCase): self.assertIs(Di.NO, nope) @unittest.skipUnless( - sys.version_info[:2] >= (3, 12), + python_version >= (3, 12), 'member-member access currently issues a warning', ) def test_exception_for_member_from_member_access(self): @@ -2617,19 +2652,41 @@ class TestFlag(unittest.TestCase): test_pickle_dump_load(self.assertIs, FlagStooges.CURLY|FlagStooges.MOE) test_pickle_dump_load(self.assertIs, FlagStooges) - def test_contains(self): + @unittest.skipIf( + python_version >= (3, 12), + '__contains__ now returns True/False for all inputs', + ) + def test_contains_er(self): Open = self.Open Color = self.Color self.assertFalse(Color.BLACK in Open) self.assertFalse(Open.RO in Color) with self.assertRaises(TypeError): - 'BLACK' in Color + with self.assertWarns(DeprecationWarning): + 'BLACK' in Color with self.assertRaises(TypeError): - 'RO' in Open + with self.assertWarns(DeprecationWarning): + 'RO' in Open with self.assertRaises(TypeError): - 1 in Color + with self.assertWarns(DeprecationWarning): + 1 in Color with self.assertRaises(TypeError): - 1 in Open + with self.assertWarns(DeprecationWarning): + 1 in Open + + @unittest.skipIf( + python_version < (3, 12), + '__contains__ only works with enum memmbers before 3.12', + ) + def test_contains_tf(self): + Open = self.Open + Color = self.Color + self.assertFalse(Color.BLACK in Open) + self.assertFalse(Open.RO in Color) + self.assertFalse('BLACK' in Color) + self.assertFalse('RO' in Open) + self.assertTrue(1 in Color) + self.assertTrue(1 in Open) def test_member_contains(self): Perm = self.Perm @@ -2954,10 +3011,15 @@ class TestIntFlag(unittest.TestCase): self.assertEqual(repr(~(Open.WO | Open.CE)), 'Open.RW') self.assertEqual(repr(Open(~4)), '-5') + @unittest.skipUnless( + python_version < (3, 12), + 'mixin-format now uses member instead of member.value', + ) def test_format(self): - Perm = self.Perm - self.assertEqual(format(Perm.R, ''), '4') - self.assertEqual(format(Perm.R | Perm.X, ''), '5') + with self.assertWarns(DeprecationWarning): + Perm = self.Perm + self.assertEqual(format(Perm.R, ''), '4') + self.assertEqual(format(Perm.R | Perm.X, ''), '5') def test_or(self): Perm = self.Perm @@ -3189,7 +3251,11 @@ class TestIntFlag(unittest.TestCase): self.assertEqual(len(lst), len(Thing)) self.assertEqual(len(Thing), 0, Thing) - def test_contains(self): + @unittest.skipIf( + python_version >= (3, 12), + '__contains__ now returns True/False for all inputs', + ) + def test_contains_er(self): Open = self.Open Color = self.Color self.assertTrue(Color.GREEN in Color) @@ -3197,13 +3263,33 @@ class TestIntFlag(unittest.TestCase): self.assertFalse(Color.GREEN in Open) self.assertFalse(Open.RW in Color) with self.assertRaises(TypeError): - 'GREEN' in Color + with self.assertWarns(DeprecationWarning): + 'GREEN' in Color with self.assertRaises(TypeError): - 'RW' in Open + with self.assertWarns(DeprecationWarning): + 'RW' in Open with self.assertRaises(TypeError): - 2 in Color + with self.assertWarns(DeprecationWarning): + 2 in Color with self.assertRaises(TypeError): - 2 in Open + with self.assertWarns(DeprecationWarning): + 2 in Open + + @unittest.skipIf( + python_version < (3, 12), + '__contains__ only works with enum memmbers before 3.12', + ) + def test_contains_tf(self): + Open = self.Open + Color = self.Color + self.assertTrue(Color.GREEN in Color) + self.assertTrue(Open.RW in Open) + self.assertTrue(Color.GREEN in Open) + self.assertTrue(Open.RW in Color) + self.assertFalse('GREEN' in Color) + self.assertFalse('RW' in Open) + self.assertTrue(2 in Color) + self.assertTrue(2 in Open) def test_member_contains(self): Perm = self.Perm @@ -3685,7 +3771,7 @@ class TestIntEnumConvert(unittest.TestCase): if name[0:2] not in ('CO', '__')], [], msg='Names other than CONVERT_TEST_* found.') - @unittest.skipUnless(sys.version_info[:2] == (3, 8), + @unittest.skipUnless(python_version == (3, 8), '_convert was deprecated in 3.8') def test_convert_warn(self): with self.assertWarns(DeprecationWarning): @@ -3694,7 +3780,7 @@ class TestIntEnumConvert(unittest.TestCase): ('test.test_enum', '__main__')[__name__=='__main__'], filter=lambda x: x.startswith('CONVERT_TEST_')) - @unittest.skipUnless(sys.version_info >= (3, 9), + @unittest.skipUnless(python_version >= (3, 9), '_convert was removed in 3.9') def test_convert_raise(self): with self.assertRaises(AttributeError): @@ -3703,6 +3789,10 @@ class TestIntEnumConvert(unittest.TestCase): ('test.test_enum', '__main__')[__name__=='__main__'], filter=lambda x: x.startswith('CONVERT_TEST_')) + @unittest.skipUnless( + python_version < (3, 12), + 'mixin-format now uses member instead of member.value', + ) def test_convert_repr_and_str(self): module = ('test.test_enum', '__main__')[__name__=='__main__'] test_type = enum.IntEnum._convert_( @@ -3711,7 +3801,8 @@ class TestIntEnumConvert(unittest.TestCase): filter=lambda x: x.startswith('CONVERT_STRING_TEST_')) self.assertEqual(repr(test_type.CONVERT_STRING_TEST_NAME_A), '%s.CONVERT_STRING_TEST_NAME_A' % module) self.assertEqual(str(test_type.CONVERT_STRING_TEST_NAME_A), 'CONVERT_STRING_TEST_NAME_A') - self.assertEqual(format(test_type.CONVERT_STRING_TEST_NAME_A), '5') + with self.assertWarns(DeprecationWarning): + self.assertEqual(format(test_type.CONVERT_STRING_TEST_NAME_A), '5') # global names for StrEnum._convert_ test CONVERT_STR_TEST_2 = 'goodbye' diff --git a/Lib/test/test_signal.py b/Lib/test/test_signal.py index 06b644e..daecf19 100644 --- a/Lib/test/test_signal.py +++ b/Lib/test/test_signal.py @@ -1323,7 +1323,7 @@ class StressTest(unittest.TestCase): # race condition, check it. self.assertIsInstance(cm.unraisable.exc_value, OSError) self.assertIn( - f"Signal {signum} ignored due to race condition", + f"Signal {signum:d} ignored due to race condition", str(cm.unraisable.exc_value)) ignored = True diff --git a/Misc/NEWS.d/next/Library/2021-04-27-12-13-51.bpo-43957.6EaPD-.rst b/Misc/NEWS.d/next/Library/2021-04-27-12-13-51.bpo-43957.6EaPD-.rst new file mode 100644 index 0000000..c6d1dde --- /dev/null +++ b/Misc/NEWS.d/next/Library/2021-04-27-12-13-51.bpo-43957.6EaPD-.rst @@ -0,0 +1,4 @@ +[Enum] Deprecate ``TypeError`` when non-member is used in a containment +check; In 3.12 ``True`` or ``False`` will be returned instead, and +containment will return ``True`` if the value is either a member of that +enum or one of its members' value. -- cgit v0.12