Coverage for Users/vladimirpavlov/PycharmProjects/parameterizable/src/mixinforge/cacheable_properties_mixin.py: 100%

1"""Mixin for managing cached properties with automatic discovery and invalidation.

2

3 This module provides CacheablePropertiesMixin, which adds functionality to track

4 and invalidate functools.cached_property attributes across a class hierarchy.

5 The mixin enables efficient cache management by automatically discovering

6 all cached properties and providing methods to inspect, set, and clear their values.

7

8 Note:

9 CacheablePropertiesMixin is not thread-safe and should not be used with dynamically

10 modified classes. The implementation relies on functools.cached_property

11 internals; any refactoring should begin with reviewing those implementation

12 details.

13"""

14 from functools import cached_property, cache

15 from typing import Any

16

17

18 class CacheablePropertiesMixin:

19 """Mixin class for automatic management of cached properties.

20

21 Provides methods to discover all functools.cached_property attributes

22 in the class hierarchy and to inspect, set, and invalidate their cached

23 values. This enables efficient cache management without manual tracking

24 of individual cached properties.

25

26 Note:

27 This class is not thread-safe and should not be used with dynamically

28 modified classes.

29

30 Subclasses using __slots__ MUST include '__dict__' to support

31 functools.cached_property, as enforced by _ensure_cache_storage_supported().

32 """

33 # Use __slots__ = () to prevent implicit addition of __dict__ or __weakref__,

34 # allowing subclasses to use __slots__ for memory optimization.

35 __slots__ = ()

36

37 def _ensure_cache_storage_supported(self) -> None:

38 """Ensure the instance can store cached_property values.

39

40 Raises:

41 TypeError: If the instance lacks __dict__, which is required

42 for functools.cached_property storage.

43 """

44 if not hasattr(self, "__dict__"):

45 cls_name = type(self).__name__

46 raise TypeError(

47 f"{cls_name} does not support cached_property caching because "

48 f"it lacks __dict__; add __slots__ = (..., '__dict__') or "

49 f"avoid cached_property on this class.")

50

51

52 @property

53 def _all_cached_properties_names(self) -> frozenset[str]:

54 """Names of all cached properties in the class hierarchy.

55

56 Returns:

57 Frozenset containing names of all functools.cached_property attributes

58 in the current class and all its parents.

59 """

60 self._ensure_cache_storage_supported()

61 return self._get_cached_properties_names_for_class(type(self))

62

63

64 @staticmethod

65 @cache

66 def _get_cached_properties_names_for_class(cls: type) -> frozenset[str]:

67 """Discover and cache all cached_property names for a class.

68

69 Traverses the MRO to find all functools.cached_property attributes,

70 including those wrapped by decorators that properly set __wrapped__.

71

72 Args:

73 cls: The class to inspect.

74

75 Returns:

76 Frozenset of cached property names.

77

78 Note:

79 Detection of wrapped cached_property relies on decorators using

80 functools.wraps or manually setting __wrapped__. Unwrapping is

81 limited to 100 levels to prevent infinite loops.

82 """

83 cached_names: set[str] = set()

84 seen_names: set[str] = set()

85

86 for curr_cls in cls.mro():

87 for name, attr in curr_cls.__dict__.items():

88 if name in seen_names:

89 continue

90 seen_names.add(name)

91

92 if isinstance(attr, cached_property):

93 cached_names.add(name)

94 continue

95

96 # Unwrap decorators to find cached_property

97 candidate = attr

98 for _ in range(100): # Prevent infinite loops

99 wrapped = getattr(candidate, "__wrapped__", None)

100 if wrapped is None:

101 break

102 candidate = wrapped

103

104 if isinstance(candidate, cached_property):

105 cached_names.add(name)

106

107 return frozenset(cached_names)

108

109

110 def _get_all_cached_properties_status(self) -> dict[str, bool]:

111 """Get caching status for all cached properties.

112

113 Returns:

114 Dictionary mapping property names to their caching status. True indicates

115 the property has a cached value, False indicates it needs computation.

116 """

117 self._ensure_cache_storage_supported()

118

119 return {name: name in self.__dict__

120 for name in self._all_cached_properties_names}

121

122

123 def _get_all_cached_properties(self) -> dict[str, Any]:

124 """Retrieve currently cached values for all cached properties.

125

126 Returns:

127 Dictionary mapping property names to their cached values.

128 Only includes properties that currently have cached values.

129 """

130 self._ensure_cache_storage_supported()

131

132 vars_dict = self.__dict__

133 cached_names = self._all_cached_properties_names

134

135 return {name: vars_dict[name]

136 for name in cached_names

137 if name in vars_dict}

138

139

140 def _get_cached_property(self, name: str) -> Any:

141 """Retrieve the cached value for a single cached property.

142

143 Args:

144 name: The name of the cached property to retrieve.

145

146 Returns:

147 The cached value for the specified property.

148

149 Raises:

150 ValueError: If the name is not a recognized cached property.

151 KeyError: If the property exists but doesn't have a cached value yet.

152 """

153 self._ensure_cache_storage_supported()

154

155 if name not in self._all_cached_properties_names:

156 raise ValueError(

157 f"'{name}' is not a cached property")

158

159 if name not in self.__dict__:

160 raise KeyError(

161 f"Cached property '{name}' has not been computed yet")

162

163 return self.__dict__[name]

164

165

166 def _get_cached_property_status(self, name: str) -> bool:

167 """Check if a cached property has a cached value.

168

169 Args:

170 name: The name of the cached property to check.

171

172 Returns:

173 True if the property has a cached value, False if it needs computation.

174

175 Raises:

176 ValueError: If the name is not a recognized cached property.

177 """

178 self._ensure_cache_storage_supported()

179

180 if name not in self._all_cached_properties_names:

181 raise ValueError(

182 f"'{name}' is not a cached property")

183

184 return name in self.__dict__

185

186

187 def _set_cached_properties(self, **names_values: Any) -> None:

188 """Set cached values for cached properties directly.

189

190 Bypasses property computation by writing values directly to __dict__.

191 This is useful for restoring cached state or for testing purposes.

192

193 Args:

194 **names_values: Property names as keys and their values to cache.

195

196 Raises:

197 ValueError: If any provided name is not a recognized cached property.

198 """

199 self._ensure_cache_storage_supported()

200

201 cached_names = self._all_cached_properties_names

202

203 invalid_names = [name for name in names_values if name not in cached_names]

204 if invalid_names:

205 raise ValueError(

206 f"Cannot set cached values for non-cached properties: {invalid_names}")

207

208 vars_dict = self.__dict__

209 for name, value in names_values.items():

210 vars_dict[name] = value

211

212

213 def _invalidate_cache(self) -> None:

214 """Clear all cached property values.

215

216 Removes cached values from __dict__, forcing re-computation on next access.

217 This is more efficient than delattr as it avoids triggering custom

218 __delattr__ logic in subclasses.

219 """

220 self._ensure_cache_storage_supported()

221

222 vars_dict = self.__dict__

223 cached_names = self._all_cached_properties_names

224

225 keys_to_delete = [k for k in vars_dict if k in cached_names]

226

227 for name in keys_to_delete:

228 if name in vars_dict:

229 del vars_dict[name]