From aae4c72c8f487760d22f15dd97660b16612433c5 Mon Sep 17 00:00:00 2001 From: "Eric V. Smith" Date: Mon, 23 Sep 2024 20:28:21 -0400 Subject: [PATCH 1/7] Fix zero-argument super() for dataclasses with slots=True. --- Doc/library/dataclasses.rst | 7 -- Lib/dataclasses.py | 50 ++++++++-- Lib/test/test_dataclasses/__init__.py | 98 ++++++++++++++++++- ...4-09-23-18-26-17.gh-issue-90562.Yj566G.rst | 2 + 4 files changed, 141 insertions(+), 16 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2024-09-23-18-26-17.gh-issue-90562.Yj566G.rst diff --git a/Doc/library/dataclasses.rst b/Doc/library/dataclasses.rst index cfca11afbd2e41..1457392ce6e86c 100644 --- a/Doc/library/dataclasses.rst +++ b/Doc/library/dataclasses.rst @@ -187,13 +187,6 @@ Module contents If :attr:`!__slots__` is already defined in the class, then :exc:`TypeError` is raised. - .. warning:: - Calling no-arg :func:`super` in dataclasses using ``slots=True`` - will result in the following exception being raised: - ``TypeError: super(type, obj): obj must be an instance or subtype of type``. - The two-arg :func:`super` is a valid workaround. - See :gh:`90562` for full details. - .. warning:: Passing parameters to a base class :meth:`~object.__init_subclass__` when using ``slots=True`` will result in a :exc:`TypeError`. diff --git a/Lib/dataclasses.py b/Lib/dataclasses.py index ac7d40cf2cac2e..53a63d0d374a28 100644 --- a/Lib/dataclasses.py +++ b/Lib/dataclasses.py @@ -1221,9 +1221,28 @@ def _get_slots(cls): raise TypeError(f"Slots of '{cls.__name__}' cannot be determined") +def _update_func_cell_for__class__(f, oldcls, newcls): + if f is None: + # f will be None in the case of a property where not all of + # fget, fset, and fdel are used. Nothing to do in that case. + return + try: + idx = f.__code__.co_freevars.index("__class__") + except ValueError: + # This function doesn't reference __class__, so nothing to do. + return + # Fix the cell to point to the new class, if it's already pointing + # at the old class. I'm not convinced that the "is oldcls" test + # is needed, but other than performance can't hurt. + closure = f.__closure__[idx] + if closure.cell_contents is oldcls: + closure.cell_contents = newcls + + def _add_slots(cls, is_frozen, weakref_slot): - # Need to create a new class, since we can't set __slots__ - # after a class has been created. + # Need to create a new class, since we can't set __slots__ after a + # class has been created, and the @dataclass decorator is called + # after the class is created. # Make sure __slots__ isn't already set. if '__slots__' in cls.__dict__: @@ -1262,18 +1281,33 @@ def _add_slots(cls, is_frozen, weakref_slot): # And finally create the class. qualname = getattr(cls, '__qualname__', None) - cls = type(cls)(cls.__name__, cls.__bases__, cls_dict) + newcls = type(cls)(cls.__name__, cls.__bases__, cls_dict) if qualname is not None: - cls.__qualname__ = qualname + newcls.__qualname__ = qualname if is_frozen: # Need this for pickling frozen classes with slots. if '__getstate__' not in cls_dict: - cls.__getstate__ = _dataclass_getstate + newcls.__getstate__ = _dataclass_getstate if '__setstate__' not in cls_dict: - cls.__setstate__ = _dataclass_setstate - - return cls + newcls.__setstate__ = _dataclass_setstate + + # Fix up any closures which reference __class__. This is used to + # fix zero argument super so that it points to the correct class + # (the newly created one, which we're returning) and not the + # original class. + for member in newcls.__dict__.values(): + # If this is a wrapped function, unwrap it. + member = inspect.unwrap(member) + + if isinstance(member, types.FunctionType): + _update_func_cell_for__class__(member, cls, newcls) + elif isinstance(member, property): + _update_func_cell_for__class__(member.fget, cls, newcls) + _update_func_cell_for__class__(member.fset, cls, newcls) + _update_func_cell_for__class__(member.fdel, cls, newcls) + + return newcls def dataclass(cls=None, /, *, init=True, repr=True, eq=True, order=False, diff --git a/Lib/test/test_dataclasses/__init__.py b/Lib/test/test_dataclasses/__init__.py index 6934e88d9d338c..6254ce98bf6072 100644 --- a/Lib/test/test_dataclasses/__init__.py +++ b/Lib/test/test_dataclasses/__init__.py @@ -17,7 +17,7 @@ from typing import ClassVar, Any, List, Union, Tuple, Dict, Generic, TypeVar, Optional, Protocol, DefaultDict from typing import get_type_hints from collections import deque, OrderedDict, namedtuple, defaultdict -from functools import total_ordering +from functools import total_ordering, wraps import typing # Needed for the string "typing.ClassVar[int]" to work as an annotation. import dataclasses # Needed for the string "dataclasses.InitVar[int]" to work as an annotation. @@ -4869,5 +4869,101 @@ class A: self.assertEqual(fs[0].name, 'x') +class TestZeroArgumentSuperWithSlots(unittest.TestCase): + def test_zero_argument_super(self): + @dataclass(slots=True) + class A: + def foo(self): + super() + + A().foo() + + def test_zero_argument_super_with_old_property(self): + @dataclass(slots=True) + class A: + def _get_foo(slf): + return slf.__class__ + + def _set_foo(slf, value): + self.assertEqual(__class__, type(slf)) + + def _del_foo(slf): + self.assertEqual(__class__, type(slf)) + + foo = property(_get_foo, _set_foo, _del_foo) + + a = A() + self.assertEqual(a.foo, A) + a.foo = 4 + del a.foo + + def test_zero_argument_super_with_new_property(self): + @dataclass(slots=True) + class A: + @property + def foo(slf): + return slf.__class__ + + @foo.setter + def foo(slf, value): + self.assertEqual(__class__, type(slf)) + + @foo.deleter + def foo(slf): + self.assertEqual(__class__, type(slf)) + + a = A() + self.assertEqual(a.foo, A) + a.foo = 4 + del a.foo + + # Test the parts of a property individually. + def test_slots_dunder_class_property_getter(self): + @dataclass(slots=True) + class A: + @property + def foo(slf): + return __class__ + + a = A() + self.assertEqual(a.foo, A) + + def test_slots_dunder_class_property_setter(self): + @dataclass(slots=True) + class A: + foo = property() + @foo.setter + def foo(slf, val): + self.assertEqual(__class__, type(slf)) + + a = A() + a.foo = 4 + + def test_slots_dunder_class_property_deleter(self): + @dataclass(slots=True) + class A: + foo = property() + @foo.deleter + def foo(slf): + self.assertEqual(__class__, type(slf)) + + a = A() + del a.foo + + def test_wrapped(self): + def mydecorator(f): + @wraps(f) + def wrapper(*args, **kwargs): + return f(*args, **kwargs) + return wrapper + + @dataclass(slots=True) + class A: + @mydecorator + def foo(self): + super() + + A().foo() + if __name__ == '__main__': unittest.main() diff --git a/Misc/NEWS.d/next/Library/2024-09-23-18-26-17.gh-issue-90562.Yj566G.rst b/Misc/NEWS.d/next/Library/2024-09-23-18-26-17.gh-issue-90562.Yj566G.rst new file mode 100644 index 00000000000000..b73726aeec3f43 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2024-09-23-18-26-17.gh-issue-90562.Yj566G.rst @@ -0,0 +1,2 @@ +Modify dataclasses to support zero-argument super() when `slots=True` is +specified. From 97d4f31bb6f79c15f9018c4934b2b38981b6ab09 Mon Sep 17 00:00:00 2001 From: "Eric V. Smith" Date: Tue, 24 Sep 2024 13:57:00 -0400 Subject: [PATCH 2/7] Use assertIs instead of assertEqual for class comparisons. Add a test showing that the original class, if saved, will have in incorrect __class__ value. --- Lib/test/test_dataclasses/__init__.py | 39 ++++++++++++++++++++------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/Lib/test/test_dataclasses/__init__.py b/Lib/test/test_dataclasses/__init__.py index 6254ce98bf6072..95b59b733f6277 100644 --- a/Lib/test/test_dataclasses/__init__.py +++ b/Lib/test/test_dataclasses/__init__.py @@ -4885,15 +4885,15 @@ def _get_foo(slf): return slf.__class__ def _set_foo(slf, value): - self.assertEqual(__class__, type(slf)) + self.assertIs(__class__, type(slf)) def _del_foo(slf): - self.assertEqual(__class__, type(slf)) + self.assertIs(__class__, type(slf)) foo = property(_get_foo, _set_foo, _del_foo) a = A() - self.assertEqual(a.foo, A) + self.assertIs(a.foo, A) a.foo = 4 del a.foo @@ -4906,14 +4906,14 @@ def foo(slf): @foo.setter def foo(slf, value): - self.assertEqual(__class__, type(slf)) + self.assertIs(__class__, type(slf)) @foo.deleter def foo(slf): - self.assertEqual(__class__, type(slf)) + self.assertIs(__class__, type(slf)) a = A() - self.assertEqual(a.foo, A) + self.assertIs(a.foo, A) a.foo = 4 del a.foo @@ -4926,7 +4926,7 @@ def foo(slf): return __class__ a = A() - self.assertEqual(a.foo, A) + self.assertIs(a.foo, A) def test_slots_dunder_class_property_setter(self): @dataclass(slots=True) @@ -4934,7 +4934,7 @@ class A: foo = property() @foo.setter def foo(slf, val): - self.assertEqual(__class__, type(slf)) + self.assertIs(__class__, type(slf)) a = A() a.foo = 4 @@ -4945,7 +4945,7 @@ class A: foo = property() @foo.deleter def foo(slf): - self.assertEqual(__class__, type(slf)) + self.assertIs(__class__, type(slf)) a = A() del a.foo @@ -4965,5 +4965,26 @@ def foo(self): A().foo() + def test_remembered_class(self): + class A: + def cls(self): + return __class__ + + self.assertIs(A().cls(), A) + + B = dataclass(slots=True)(A) + self.assertIs(B().cls(), B) + + # This is probably undesirable behavior, but is a function of + # how modifying __class__ in the closure works. I'm not sure + # this should be tested or not: I don't really want to + # guarantee this behavior, but I don't want to lose the point + # that this is how it works. + + # The underlying class is "broken" by changing its __class__ + # in A.foo() to B. This normally isn't a problem, because no + # one will be keeping a reference to the underlying class. + self.assertIs(A().cls(), B) + if __name__ == '__main__': unittest.main() From c066fe2d98f92bc5ef50376b87e4728282249e9a Mon Sep 17 00:00:00 2001 From: "Eric V. Smith" Date: Tue, 24 Sep 2024 14:01:23 -0400 Subject: [PATCH 3/7] Fix default role. --- .../next/Library/2024-09-23-18-26-17.gh-issue-90562.Yj566G.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Misc/NEWS.d/next/Library/2024-09-23-18-26-17.gh-issue-90562.Yj566G.rst b/Misc/NEWS.d/next/Library/2024-09-23-18-26-17.gh-issue-90562.Yj566G.rst index b73726aeec3f43..1d76066a9df3b7 100644 --- a/Misc/NEWS.d/next/Library/2024-09-23-18-26-17.gh-issue-90562.Yj566G.rst +++ b/Misc/NEWS.d/next/Library/2024-09-23-18-26-17.gh-issue-90562.Yj566G.rst @@ -1,2 +1,2 @@ -Modify dataclasses to support zero-argument super() when `slots=True` is +Modify dataclasses to support zero-argument super() when ``slots=True`` is specified. From fbffc50b2dc4203db362e5777d1cd055bfa709ba Mon Sep 17 00:00:00 2001 From: "Eric V. Smith" Date: Tue, 24 Sep 2024 14:08:02 -0400 Subject: [PATCH 4/7] Improved NEWS entry. Tweak some comments. --- Lib/test/test_dataclasses/__init__.py | 5 ++++- .../Library/2024-09-23-18-26-17.gh-issue-90562.Yj566G.rst | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_dataclasses/__init__.py b/Lib/test/test_dataclasses/__init__.py index 95b59b733f6277..08905797717e90 100644 --- a/Lib/test/test_dataclasses/__init__.py +++ b/Lib/test/test_dataclasses/__init__.py @@ -4966,6 +4966,9 @@ def foo(self): A().foo() def test_remembered_class(self): + # Apply the dataclass decorator manually (not when the class + # is created), so that we can keep a reference to the + # undecorated class. class A: def cls(self): return __class__ @@ -4983,7 +4986,7 @@ def cls(self): # The underlying class is "broken" by changing its __class__ # in A.foo() to B. This normally isn't a problem, because no - # one will be keeping a reference to the underlying class. + # one will be keeping a reference to the underlying class A. self.assertIs(A().cls(), B) if __name__ == '__main__': diff --git a/Misc/NEWS.d/next/Library/2024-09-23-18-26-17.gh-issue-90562.Yj566G.rst b/Misc/NEWS.d/next/Library/2024-09-23-18-26-17.gh-issue-90562.Yj566G.rst index 1d76066a9df3b7..f15309fc9d8b37 100644 --- a/Misc/NEWS.d/next/Library/2024-09-23-18-26-17.gh-issue-90562.Yj566G.rst +++ b/Misc/NEWS.d/next/Library/2024-09-23-18-26-17.gh-issue-90562.Yj566G.rst @@ -1,2 +1,3 @@ Modify dataclasses to support zero-argument super() when ``slots=True`` is -specified. +specified. This works by modifying all referenced to ``__class__`` to point +to the newly created class. From 356beed1e4fd498542601ad451356ed92096bc32 Mon Sep 17 00:00:00 2001 From: "Eric V. Smith" Date: Tue, 24 Sep 2024 15:05:27 -0400 Subject: [PATCH 5/7] Fix trivial typo in NEWS. --- .../next/Library/2024-09-23-18-26-17.gh-issue-90562.Yj566G.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Misc/NEWS.d/next/Library/2024-09-23-18-26-17.gh-issue-90562.Yj566G.rst b/Misc/NEWS.d/next/Library/2024-09-23-18-26-17.gh-issue-90562.Yj566G.rst index f15309fc9d8b37..7a389fefc6c54b 100644 --- a/Misc/NEWS.d/next/Library/2024-09-23-18-26-17.gh-issue-90562.Yj566G.rst +++ b/Misc/NEWS.d/next/Library/2024-09-23-18-26-17.gh-issue-90562.Yj566G.rst @@ -1,3 +1,3 @@ Modify dataclasses to support zero-argument super() when ``slots=True`` is -specified. This works by modifying all referenced to ``__class__`` to point +specified. This works by modifying all references to ``__class__`` to point to the newly created class. From c801c6d97df32070b1d506d7aba88b562c0e7d03 Mon Sep 17 00:00:00 2001 From: "Eric V. Smith" Date: Tue, 24 Sep 2024 19:38:48 -0400 Subject: [PATCH 6/7] Apply suggestions from code review Apply review suggestions. Co-authored-by: Carl Meyer --- Lib/test/test_dataclasses/__init__.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Lib/test/test_dataclasses/__init__.py b/Lib/test/test_dataclasses/__init__.py index 08905797717e90..95bc32ec91bda6 100644 --- a/Lib/test/test_dataclasses/__init__.py +++ b/Lib/test/test_dataclasses/__init__.py @@ -4878,17 +4878,20 @@ def foo(self): A().foo() - def test_zero_argument_super_with_old_property(self): + def test_dunder_class_with_old_property(self): @dataclass(slots=True) class A: def _get_foo(slf): - return slf.__class__ + self.assertIs(__class__, type(slf)) + self.assertIs(__class__, slf.__class__) def _set_foo(slf, value): self.assertIs(__class__, type(slf)) + self.assertIs(__class__, slf.__class__) def _del_foo(slf): self.assertIs(__class__, type(slf)) + self.assertIs(__class__, slf.__class__) foo = property(_get_foo, _set_foo, _del_foo) @@ -4897,7 +4900,7 @@ def _del_foo(slf): a.foo = 4 del a.foo - def test_zero_argument_super_with_new_property(self): + def test_dunder_class_with_new_property(self): @dataclass(slots=True) class A: @property From 1c60e6b77e90f894324f27641f1b83f68eca5356 Mon Sep 17 00:00:00 2001 From: "Eric V. Smith" Date: Tue, 24 Sep 2024 19:40:27 -0400 Subject: [PATCH 7/7] Address review comments: Only update one cell, then break out of the loop; minor change to comments; fix a test. --- Lib/dataclasses.py | 21 ++++++++++++++------- Lib/test/test_dataclasses/__init__.py | 11 ++++++----- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/Lib/dataclasses.py b/Lib/dataclasses.py index 53a63d0d374a28..4cd4e86fc207b6 100644 --- a/Lib/dataclasses.py +++ b/Lib/dataclasses.py @@ -1222,21 +1222,24 @@ def _get_slots(cls): def _update_func_cell_for__class__(f, oldcls, newcls): + # Returns True if we update a cell, else False. if f is None: # f will be None in the case of a property where not all of # fget, fset, and fdel are used. Nothing to do in that case. - return + return False try: idx = f.__code__.co_freevars.index("__class__") except ValueError: # This function doesn't reference __class__, so nothing to do. - return + return False # Fix the cell to point to the new class, if it's already pointing # at the old class. I'm not convinced that the "is oldcls" test # is needed, but other than performance can't hurt. closure = f.__closure__[idx] if closure.cell_contents is oldcls: closure.cell_contents = newcls + return True + return False def _add_slots(cls, is_frozen, weakref_slot): @@ -1295,17 +1298,21 @@ def _add_slots(cls, is_frozen, weakref_slot): # Fix up any closures which reference __class__. This is used to # fix zero argument super so that it points to the correct class # (the newly created one, which we're returning) and not the - # original class. + # original class. We can break out of this loop as soon as we + # make an update, since all closures for a class will share a + # given cell. for member in newcls.__dict__.values(): # If this is a wrapped function, unwrap it. member = inspect.unwrap(member) if isinstance(member, types.FunctionType): - _update_func_cell_for__class__(member, cls, newcls) + if _update_func_cell_for__class__(member, cls, newcls): + break elif isinstance(member, property): - _update_func_cell_for__class__(member.fget, cls, newcls) - _update_func_cell_for__class__(member.fset, cls, newcls) - _update_func_cell_for__class__(member.fdel, cls, newcls) + if (_update_func_cell_for__class__(member.fget, cls, newcls) + or _update_func_cell_for__class__(member.fset, cls, newcls) + or _update_func_cell_for__class__(member.fdel, cls, newcls)): + break return newcls diff --git a/Lib/test/test_dataclasses/__init__.py b/Lib/test/test_dataclasses/__init__.py index 95bc32ec91bda6..69e86162e0c11a 100644 --- a/Lib/test/test_dataclasses/__init__.py +++ b/Lib/test/test_dataclasses/__init__.py @@ -4884,6 +4884,7 @@ class A: def _get_foo(slf): self.assertIs(__class__, type(slf)) self.assertIs(__class__, slf.__class__) + return __class__ def _set_foo(slf, value): self.assertIs(__class__, type(slf)) @@ -4981,11 +4982,11 @@ def cls(self): B = dataclass(slots=True)(A) self.assertIs(B().cls(), B) - # This is probably undesirable behavior, but is a function of - # how modifying __class__ in the closure works. I'm not sure - # this should be tested or not: I don't really want to - # guarantee this behavior, but I don't want to lose the point - # that this is how it works. + # This is undesirable behavior, but is a function of how + # modifying __class__ in the closure works. I'm not sure this + # should be tested or not: I don't really want to guarantee + # this behavior, but I don't want to lose the point that this + # is how it works. # The underlying class is "broken" by changing its __class__ # in A.foo() to B. This normally isn't a problem, because no pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy