From ff295ab52e548472f76322a5dc1ef347b870e67b Mon Sep 17 00:00:00 2001 From: Ethan Furman Date: Tue, 27 Apr 2021 12:32:22 -0700 Subject: [PATCH] [Enum] Deprecate ``TypeError`` from containment checks. 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 91c214eb668d11..b5f9c2f08b1873 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 bcf411c6b6550c..bccf024b520a9b 100644 --- a/Lib/enum.py +++ b/Lib/enum.py @@ -280,7 +280,8 @@ def __set_name__(self, enum_class, member_name): # 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 @@ def __new__(metacls, cls, bases, classdict, *, boundary=None, _simple=False, **k 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 @@ def __call__(cls, value, names=None, *, module=None, qualname=None, type=None, s 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 @@ def __format__(self, format_spec): 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 convert_class(cls): 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 983c54b8711991..e918b03cc6c520 100644 --- a/Lib/test/test_enum.py +++ b/Lib/test/test_enum.py @@ -16,6 +16,8 @@ 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 IntLogic(int, Enum): 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 @@ def __format__(self, spec): 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 @@ def test_mixin_format_warning(self): 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 @@ def __repr__(self): 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 Private(Enum): 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 Di(Enum): 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 @@ def test_pickle(self): 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 @@ def test_repr(self): 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 @@ def test_programatic_function_from_empty_tuple(self): 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 @@ def test_contains(self): 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 @@ def test_convert(self): 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 @@ def test_convert_warn(self): ('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 @@ def test_convert_raise(self): ('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 @@ def test_convert_repr_and_str(self): 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 06b644e764aa7c..daecf196fa1461 100644 --- a/Lib/test/test_signal.py +++ b/Lib/test/test_signal.py @@ -1323,7 +1323,7 @@ def cycle_handlers(): # 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 00000000000000..c6d1ddef64ca30 --- /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.