Skip to content

Commit 80be0a7

Browse files
author
Danny Cooper
committed
Add support for cached_properties to slotted attrs classes.
1 parent 1955f72 commit 80be0a7

2 files changed

Lines changed: 204 additions & 2 deletions

File tree

src/attr/_make.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
import contextlib
44
import copy
55
import enum
6+
import functools
67
import inspect
78
import linecache
89
import sys
910
import types
1011
import typing
1112

13+
from _thread import RLock
1214
from operator import itemgetter
1315

1416
# We need to import _compat itself in addition to the _compat members to avoid
@@ -598,6 +600,28 @@ def _transform_attrs(
598600
return _Attributes((AttrsClass(attrs), base_attrs, base_attr_map))
599601

600602

603+
def _make_cached_property_getattr(cached_properties, original_getattr=None):
604+
lock = RLock() # This is global for the class, can that be avoided?
605+
606+
def __getattr__(instance, item: str):
607+
func = cached_properties.get(item)
608+
if func is not None:
609+
with lock:
610+
try:
611+
# In case another thread has set it.
612+
return object.__getattribute__(instance, item)
613+
except AttributeError:
614+
result = func(instance)
615+
object.__setattr__(instance, item, result)
616+
return result
617+
elif original_getattr is not None:
618+
return original_getattr(instance, item)
619+
else:
620+
raise AttributeError(item)
621+
622+
return __getattr__
623+
624+
601625
def _frozen_setattrs(self, name, value):
602626
"""
603627
Attached to frozen classes as __setattr__.
@@ -858,9 +882,31 @@ def _create_slots_class(self):
858882
):
859883
names += ("__weakref__",)
860884

885+
cached_properties = {
886+
name: cached_property.func
887+
for name, cached_property in cd.items()
888+
if isinstance(cached_property, functools.cached_property)
889+
}
890+
891+
if cached_properties:
892+
# Add cached properties to names for slotting.
893+
names += tuple(cached_properties.keys())
894+
895+
if "__annotations__" in cd:
896+
for name, func in cached_properties.items():
897+
annotation = inspect.signature(func).return_annotation
898+
if annotation is not inspect.Parameter.empty:
899+
cd["__annotations__"][name] = annotation
900+
901+
cd["__getattr__"] = _make_cached_property_getattr(
902+
cached_properties, cd.get("__getattr__")
903+
)
904+
del cd[name]
905+
861906
# We only add the names of attributes that aren't inherited.
862907
# Setting __slots__ to inherited attributes wastes memory.
863908
slot_names = [name for name in names if name not in base_names]
909+
864910
# There are slots for attributes from current class
865911
# that are defined in parent classes.
866912
# As their descriptors may be overridden by a child class,
@@ -874,6 +920,7 @@ def _create_slots_class(self):
874920
cd.update(reused_slots)
875921
if self._cache_hash:
876922
slot_names.append(_hash_cache_field)
923+
877924
cd["__slots__"] = tuple(slot_names)
878925

879926
cd["__qualname__"] = self._cls.__qualname__
@@ -910,7 +957,6 @@ def _create_slots_class(self):
910957
else:
911958
if match:
912959
set_closure_cell(cell, cls)
913-
914960
return cls
915961

916962
def add_repr(self, ns):

tests/test_slots.py

Lines changed: 157 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"""
44
Unit tests for slots-related functionality.
55
"""
6-
6+
import functools
77
import pickle
88
import sys
99
import types
@@ -14,6 +14,7 @@
1414
import pytest
1515

1616
import attr
17+
import attrs
1718

1819
from attr._compat import PYPY, just_warn, make_set_closure_cell
1920

@@ -747,6 +748,161 @@ def f(self):
747748
assert B(17).f == 289
748749

749750

751+
def test_slots_cached_property_allows_call():
752+
"""
753+
cached_property in slotted class allows call.
754+
"""
755+
756+
@attr.s(slots=True)
757+
class A:
758+
x = attr.ib()
759+
760+
@functools.cached_property
761+
def f(self):
762+
return self.x
763+
764+
assert A(11).f == 11
765+
766+
767+
def test_slots_cached_property_class_does_not_have__dict__():
768+
"""
769+
slotted class with cached property has no __dict__ attribute.
770+
"""
771+
772+
@attr.s(slots=True)
773+
class A:
774+
x = attr.ib()
775+
776+
@functools.cached_property
777+
def f(self):
778+
return self.x
779+
780+
assert set(A.__slots__) == {"x", "f", "__weakref__"}
781+
assert "__dict__" not in dir(A)
782+
783+
784+
def test_slots_cached_property_infers_type():
785+
"""
786+
Infers type of cached property.
787+
"""
788+
789+
@attrs.frozen(slots=True)
790+
class A:
791+
x: int
792+
793+
@functools.cached_property
794+
def f(self) -> int:
795+
return self.x
796+
797+
assert A.__annotations__ == {"x": int, "f": int}
798+
799+
800+
def test_slots_sub_class_avoids_duplicated_slots():
801+
"""
802+
Duplicating the slots is a wast of memory.
803+
"""
804+
805+
@attr.s(slots=True)
806+
class A:
807+
x = attr.ib()
808+
809+
@functools.cached_property
810+
def f(self):
811+
return self.x
812+
813+
@attr.s(slots=True)
814+
class B(A):
815+
@functools.cached_property
816+
def f(self):
817+
return self.x * 2
818+
819+
assert B(1).f == 2
820+
assert B.__slots__ == ()
821+
822+
823+
def test_slots_sub_class_with_actual_slot():
824+
"""
825+
A sub-class can have an explicit attrs field that replaces a cached property.
826+
"""
827+
828+
@attr.s(slots=True)
829+
class A: # slots : (x, f)
830+
x = attr.ib()
831+
832+
@functools.cached_property
833+
def f(self):
834+
return self.x
835+
836+
@attr.s(slots=True)
837+
class B(A):
838+
f: int = attr.ib()
839+
840+
assert B(1, 2).f == 2
841+
assert B.__slots__ == ()
842+
843+
844+
def test_slots_cached_property_is_not_called_at_construction():
845+
"""
846+
A cached property function should only be called at property access point.
847+
"""
848+
call_count = 0
849+
850+
@attr.s(slots=True)
851+
class A:
852+
x = attr.ib()
853+
854+
@functools.cached_property
855+
def f(self):
856+
nonlocal call_count
857+
call_count += 1
858+
return self.x
859+
860+
A(1)
861+
assert call_count == 0
862+
863+
864+
def test_slots_cached_property_repeat_call_only_once():
865+
"""
866+
A cached property function should be called only once, on repeated attribute access.
867+
"""
868+
call_count = 0
869+
870+
@attr.s(slots=True)
871+
class A:
872+
x = attr.ib()
873+
874+
@functools.cached_property
875+
def f(self):
876+
nonlocal call_count
877+
call_count += 1
878+
return self.x
879+
880+
obj = A(1)
881+
obj.f
882+
obj.f
883+
assert call_count == 1
884+
885+
886+
def test_slots_cached_property_called_independent_across_instances():
887+
"""
888+
A cached property value should be specific to the given instance.
889+
"""
890+
891+
@attr.s(slots=True)
892+
class A:
893+
x = attr.ib()
894+
895+
@functools.cached_property
896+
def f(self):
897+
return self.x
898+
899+
obj_1 = A(1)
900+
obj_2 = A(2)
901+
902+
assert obj_1.f == 1
903+
assert obj_2.f == 2
904+
905+
750906
@attr.s(slots=True)
751907
class A:
752908
x = attr.ib()

0 commit comments

Comments
 (0)