From f20e04a60612d062b145bcc9779b22bd15f25d8d Mon Sep 17 00:00:00 2001 From: Andreas Kloeckner Date: Mon, 2 Dec 2024 16:49:38 -0600 Subject: [PATCH 1/3] opt_frozen_dataclass: Enable hashing with -O Co-authored-by: Matthias Diener --- pytools/__init__.py | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/pytools/__init__.py b/pytools/__init__.py index 32ada2ba..b6d1f12e 100644 --- a/pytools/__init__.py +++ b/pytools/__init__.py @@ -2987,7 +2987,7 @@ def opt_frozen_dataclass( repr: bool = True, eq: bool = True, order: bool = False, - unsafe_hash: bool = False, + unsafe_hash: bool | None = None, match_args: bool = True, kw_only: bool = False, slots: bool = False, @@ -3000,16 +3000,41 @@ def opt_frozen_dataclass( this decorator avoid when the interpreter runs with "optimization" enabled. + The resulting dataclass supports hashing unless *eq* is set to *False*, + if *unsafe_hash* is left at the default or set to *True*. + + + .. note:: + + Python prevents non-frozen dataclasses from inheriting from frozen ones, + and vice versa. To ensure frozen-ness is applied predictably in all + scenarios (mainly :data:`__debug__` on and off), it is strongly recommended + that all dataclasses inheriting from ones with this decorator *also* + use this decorator. There are no run-time checks to make sure of this. + .. versionadded:: 2024.1.18 """ def map_cls(cls: type[T]) -> type[T]: + # This ensures that the resulting dataclass is hashable with and without + # __debug__, unless the user overrides unsafe_hash or provides their own + # __hash__ method. + if unsafe_hash is None: + if (eq + and not __debug__ + and "__hash__" not in cls.__dict__): + loc_unsafe_hash = True + else: + loc_unsafe_hash = False + else: + loc_unsafe_hash = unsafe_hash + from dataclasses import dataclass return dataclass( init=init, repr=repr, eq=eq, order=order, - unsafe_hash=unsafe_hash, + unsafe_hash=loc_unsafe_hash, frozen=__debug__, match_args=match_args, kw_only=kw_only, From a24ad7c1573a3ab3f3a2f38247ce34c9e53c2198 Mon Sep 17 00:00:00 2001 From: Matthias Diener Date: Tue, 3 Dec 2024 12:24:40 -0600 Subject: [PATCH 2/3] opt_frozen_dataclass: more improvements --- pytools/__init__.py | 19 +++-- pytools/test/test_dataclasses.py | 125 +++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 pytools/test/test_dataclasses.py diff --git a/pytools/__init__.py b/pytools/__init__.py index b6d1f12e..2ce1dc0e 100644 --- a/pytools/__init__.py +++ b/pytools/__init__.py @@ -2991,8 +2991,7 @@ def opt_frozen_dataclass( match_args: bool = True, kw_only: bool = False, slots: bool = False, - # Added in 3.11. - # weakref_slot: bool = False + **kwargs: Any, # Extra, version dependent arguments (weakref_slot in 3.11) ) -> Callable[[type[T]], type[T]]: """Like :func:`dataclasses.dataclass`, but marks the dataclass frozen only if :data:`__debug__` is active. Frozen dataclasses have a ~20% @@ -3014,13 +3013,22 @@ def opt_frozen_dataclass( .. versionadded:: 2024.1.18 """ + + if "frozen" in kwargs: + raise ValueError("frozen must not be specified in opt_frozen_dataclass") + def map_cls(cls: type[T]) -> type[T]: # This ensures that the resulting dataclass is hashable with and without # __debug__, unless the user overrides unsafe_hash or provides their own # __hash__ method. + + # Make it possible to override 'frozen' in the class definition for testing. + # It would be nice to have something like https://discuss.python.org/t/allow-debug-to-be-set-at-runtime/64840 + loc_frozen = __debug__ if "_frozen_override" not in cls.__dict__ else False + if unsafe_hash is None: if (eq - and not __debug__ + and not loc_frozen and "__hash__" not in cls.__dict__): loc_unsafe_hash = True else: @@ -3035,12 +3043,11 @@ def map_cls(cls: type[T]) -> type[T]: eq=eq, order=order, unsafe_hash=loc_unsafe_hash, - frozen=__debug__, + frozen=loc_frozen, match_args=match_args, kw_only=kw_only, slots=slots, - # Added in 3.11. - # weakref_slot=weakref_slot, + **kwargs, )(cls) return map_cls diff --git a/pytools/test/test_dataclasses.py b/pytools/test/test_dataclasses.py new file mode 100644 index 00000000..c52a109f --- /dev/null +++ b/pytools/test/test_dataclasses.py @@ -0,0 +1,125 @@ +__copyright__ = "Copyright (C) 2024 University of Illinois Board of Trustees" + +__license__ = """ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +""" + + +import sys + +import pytest + +from pytools import opt_frozen_dataclass + + +def test_opt_frozen_dataclass() -> None: + # {{{ basic usage + + @opt_frozen_dataclass() + class A: + x: int + + a = A(1) + assert a.x == 1 + + # Needs to be hashable by default, not using object.__hash__ + hash(a) + assert hash(a) == hash(A(1)) + assert a == A(1) + + # Needs to be frozen by default + with pytest.raises(AttributeError): + a.x = 2 + + assert a.__dataclass_params__.frozen is True + + # }}} + + with pytest.raises(ValueError): + # Can't specify frozen parameter + @opt_frozen_dataclass(frozen=False) + class B: + x: int + + # {{{ eq=False + + @opt_frozen_dataclass(eq=False) + class C: + x: int + + c = C(1) + + # Hashing still works, but uses object.__hash__ (i.e., id()) + assert hash(c) != hash(C(1)) + + # Equality is not defined and uses id() + assert c != C(1) + + # }}} + + # {{{ Test with __debug__ "disabled" + + @opt_frozen_dataclass() + class D: + x: int + + _frozen_override = True + + d = D(1) + assert d.x == 1 + + # Actually mutable + d.x = 2 + + # Must be hashable, despite not frozen (via unsafe_hash) + assert hash(d) == hash(D(2)) + assert d.__dataclass_params__.frozen is False + assert d.__dataclass_params__.unsafe_hash is True + + # }}} + + +def test_dataclass_weakref() -> None: + if sys.version_info < (3, 11): + pytest.skip("weakref support needs Python 3.11+") + + @opt_frozen_dataclass(weakref_slot=True, slots=True) + class Weakref: + x: int + + a = Weakref(1) + assert a.x == 1 + + import weakref + ref = weakref.ref(a) + + _ = ref().x + + with pytest.raises(TypeError): + @opt_frozen_dataclass(weakref_slot=True) # needs slots=True to work + class Weakref2: + x: int + + +if __name__ == "__main__": + if len(sys.argv) > 1: + exec(sys.argv[1]) + else: + from pytest import main + main([__file__]) From bca09db6cd62bc3dd2e9c4a97706950fc3dc1fdd Mon Sep 17 00:00:00 2001 From: Matthias Diener Date: Tue, 3 Dec 2024 12:34:56 -0600 Subject: [PATCH 3/3] lint --- pytools/test/test_dataclasses.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pytools/test/test_dataclasses.py b/pytools/test/test_dataclasses.py index c52a109f..9de50ef3 100644 --- a/pytools/test/test_dataclasses.py +++ b/pytools/test/test_dataclasses.py @@ -45,9 +45,9 @@ class A: # Needs to be frozen by default with pytest.raises(AttributeError): - a.x = 2 + a.x = 2 # type: ignore[misc] - assert a.__dataclass_params__.frozen is True + assert a.__dataclass_params__.frozen is True # type: ignore[attr-defined] # pylint: disable=no-member # }}} @@ -85,12 +85,12 @@ class D: assert d.x == 1 # Actually mutable - d.x = 2 + d.x = 2 # type: ignore[misc] # Must be hashable, despite not frozen (via unsafe_hash) assert hash(d) == hash(D(2)) - assert d.__dataclass_params__.frozen is False - assert d.__dataclass_params__.unsafe_hash is True + assert d.__dataclass_params__.frozen is False # type: ignore[attr-defined] # pylint: disable=no-member + assert d.__dataclass_params__.unsafe_hash is True # type: ignore[attr-defined] # pylint: disable=no-member # }}}