From 02fddced6ff85fe7d4a154900385c7244cf2fc4a Mon Sep 17 00:00:00 2001 From: Andreas Kloeckner Date: Mon, 2 Dec 2024 16:49:38 -0600 Subject: [PATCH 1/2] opt_frozen_dataclass: Enable hashing with -O Co-authored-by: Matthias Diener --- pytools/__init__.py | 44 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/pytools/__init__.py b/pytools/__init__.py index 32ada2ba..7ea5c083 100644 --- a/pytools/__init__.py +++ b/pytools/__init__.py @@ -1,5 +1,6 @@ __copyright__ = """ Copyright (C) 2009-2013 Andreas Kloeckner +Copyright (C) 2013- University of Illinois Board of Trustees Copyright (C) 2020 Matt Wala """ @@ -2987,35 +2988,64 @@ 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, # Added in 3.11. - # weakref_slot: bool = False + weakref_slot: bool = False, ) -> 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% - cost penalty (from having to call :meth:`object.__setattr__`) that - this decorator avoid when the interpreter runs with "optimization" + cost penalty (on creation, from having to call :meth:`object.__setattr__`) that + this decorator avoids when the interpreter runs with "optimization" enabled. + The resulting dataclass supports hashing, even when it is not actually frozen, + 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 + + dc_extra_kwargs: dict[str, bool] = {} + if weakref_slot: + if sys.version_info < (3, 11): + raise TypeError("weakref_slot is not available before Python 3.11") + dc_extra_kwargs["weakref_slot"] = weakref_slot + 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, slots=slots, - # Added in 3.11. - # weakref_slot=weakref_slot, + **dc_extra_kwargs, )(cls) return map_cls From 7b4d68abf35fdf03936a0e16a73d43e26a4b5111 Mon Sep 17 00:00:00 2001 From: Matthias Diener Date: Wed, 4 Dec 2024 11:27:43 -0600 Subject: [PATCH 2/2] Add tests for opt_frozen_dataclass --- pytools/test/test_dataclasses.py | 104 +++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 pytools/test/test_dataclasses.py diff --git a/pytools/test/test_dataclasses.py b/pytools/test/test_dataclasses.py new file mode 100644 index 00000000..145e90cd --- /dev/null +++ b/pytools/test/test_dataclasses.py @@ -0,0 +1,104 @@ +__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 # type: ignore[misc] + + assert a.__dataclass_params__.frozen is True # type: ignore[attr-defined] # pylint: disable=no-member + + # }}} + + with pytest.raises(TypeError): + # Can't specify frozen parameter + @opt_frozen_dataclass(frozen=False) # type: ignore[call-arg] # pylint: disable=unexpected-keyword-arg + 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) + + # }}} + + +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__])