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

71 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-01 16:37 -0600

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

2 

3This module provides CacheablePropertiesMixin, which adds functionality to track 

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

5The mixin enables efficient cache management by automatically discovering 

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

7 

8Note: 

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""" 

14from functools import cached_property, cache 

15from typing import Any 

16 

17 

18class 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]