From ea6a62dde977f06878fb006e36d70457f8b208d8 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Tue, 22 Jul 2025 08:33:57 +0200 Subject: [PATCH 1/5] Fix slotted reference cycles on 3.14 Ref https://github.com/python/cpython/pull/136893 & https://github.com/python/cpython/issues/135228 Co-authored-by: Jelle Zijlstra <906600+JelleZijlstra@users.noreply.github.com> --- src/attr/_make.py | 19 +++++++++++++++++++ tests/test_make.py | 3 +-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index 4b3ec7d03..89e8a15be 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -24,6 +24,7 @@ PY_3_10_PLUS, PY_3_11_PLUS, PY_3_13_PLUS, + PY_3_14_PLUS, _AnnotationExtractor, _get_annotations, get_generic_base, @@ -618,6 +619,17 @@ def evolve(*args, **changes): return cls(**changes) +# Hack to the get the underlying dict out of a mappingproxy +# Use it with: cls.__dict__ | _deproxier +# See: https://github.com/python/cpython/pull/136893 +class _Deproxier: + def __ror__(self, other): + return other + + +_deproxier = _Deproxier() + + class _ClassBuilder: """ Iteratively build *one* class. @@ -845,6 +857,13 @@ def _create_slots_class(self): if k not in (*tuple(self._attr_names), "__dict__", "__weakref__") } + if PY_3_14_PLUS: + # Clean up old dict to avoid leaks. + old_cls_dict = self._cls.__dict__ | _deproxier + old_cls_dict.pop("__dict__", None) + if "__weakref__" in self._cls.__dict__: + del self._cls.__weakref__ + # If our class doesn't have its own implementation of __setattr__ # (either from the user or by us), check the bases, if one of them has # an attrs-made __setattr__, that needs to be reset. We don't walk the diff --git a/tests/test_make.py b/tests/test_make.py index 2e350577c..8f4846a78 100644 --- a/tests/test_make.py +++ b/tests/test_make.py @@ -23,7 +23,7 @@ import attr from attr import _config -from attr._compat import PY_3_10_PLUS, PY_3_14_PLUS +from attr._compat import PY_3_10_PLUS from attr._make import ( Attribute, Factory, @@ -1939,7 +1939,6 @@ class C2(C): assert [C2] == C.__subclasses__() - @pytest.mark.xfail(PY_3_14_PLUS, reason="Currently broken on nightly.") def test_no_references_to_original_when_using_cached_property(self): """ When subclassing a slotted class and using cached property, there are From 8e267839b25d8bf0b1ec70b3748ff1b56b9b8b57 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Tue, 22 Jul 2025 10:06:29 +0200 Subject: [PATCH 2/5] Add news fragment --- changelog.d/1446.change.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changelog.d/1446.change.md diff --git a/changelog.d/1446.change.md b/changelog.d/1446.change.md new file mode 100644 index 000000000..2a069ea36 --- /dev/null +++ b/changelog.d/1446.change.md @@ -0,0 +1,2 @@ +On 3.14, the cycles in slotted classes are now manually broken. +An explicit call to `gc.collect()` is still necessary, unfortunately. From c60dfa0780d5adc6eced7785d887aa958bb3a752 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Wed, 23 Jul 2025 08:56:03 +0200 Subject: [PATCH 3/5] Bump version tag to CMA Co-authored-by: Brandt Bucher <40968415+brandtbucher@users.noreply.github.com> Co-authored-by: Jelle Zijlstra <906600+JelleZijlstra@users.noreply.github.com> --- src/attr/_make.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/attr/_make.py b/src/attr/_make.py index 89e8a15be..ff27eee7c 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -864,6 +864,9 @@ def _create_slots_class(self): if "__weakref__" in self._cls.__dict__: del self._cls.__weakref__ + # Manually bump internal version tag. + self._cls.__name__ = self._cls.__name__ + # If our class doesn't have its own implementation of __setattr__ # (either from the user or by us), check the bases, if one of them has # an attrs-made __setattr__, that needs to be reset. We don't walk the From 98af24aa5174865a1e07f87bff2cd419a571ca6f Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Wed, 23 Jul 2025 16:59:38 +0200 Subject: [PATCH 4/5] Apply review comment https://github.com/python-attrs/attrs/pull/1446#discussion_r2225786752 --- src/attr/_make.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index ff27eee7c..983d70556 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -865,7 +865,11 @@ def _create_slots_class(self): del self._cls.__weakref__ # Manually bump internal version tag. - self._cls.__name__ = self._cls.__name__ + if hasattr(self._cls, "__abstractmethods__"): + self._cls.__abstractmethods__ = self._cls.__abstractmethods__ + else: + self._cls.__abstractmethods__ = frozenset({"__init__"}) + del self._cls.__abstractmethods__ # If our class doesn't have its own implementation of __setattr__ # (either from the user or by us), check the bases, if one of them has From 456253a7e800ae856acb99b4cec6c05242cb4d3f Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Wed, 23 Jul 2025 17:03:09 +0200 Subject: [PATCH 5/5] EAFP --- src/attr/_make.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index 983d70556..b24c8d0d7 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -865,9 +865,9 @@ def _create_slots_class(self): del self._cls.__weakref__ # Manually bump internal version tag. - if hasattr(self._cls, "__abstractmethods__"): + try: self._cls.__abstractmethods__ = self._cls.__abstractmethods__ - else: + except AttributeError: self._cls.__abstractmethods__ = frozenset({"__init__"}) del self._cls.__abstractmethods__