Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/skeleton/instance.py: 13%

179 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-13 11:04 +0000

1from __future__ import annotations # noqa: required for pre-defined annotations 

2 

3import copy 

4import fnmatch 

5import typing as t 

6import warnings 

7 

8from functools import partial 

9from ..bones.base import BaseBone 

10from .skeleton import Skeleton 

11from viur.core import db 

12 

13 

14class SkeletonInstance: 

15 """ 

16 The actual wrapper around a Skeleton-Class. An object of this class is what's actually returned when you 

17 call a Skeleton-Class. With ViUR3, you don't get an instance of a Skeleton-Class any more - it's always this 

18 class. This is much faster as this is a small class. 

19 """ 

20 __slots__ = { 

21 "_cascade_deletion", 

22 "accessedValues", 

23 "boneMap", 

24 "dbEntity", 

25 "errors", 

26 "is_cloned", 

27 "renderAccessedValues", 

28 "renderPreparation", 

29 "skeletonCls", 

30 } 

31 

32 def __init__( 

33 self, 

34 skel_cls: t.Type[Skeleton], 

35 entity: t.Optional[db.Entity | dict] = None, 

36 *, 

37 bones: t.Iterable[str] = (), 

38 bone_map: t.Optional[t.Dict[str, BaseBone]] = None, 

39 clone: bool = False, 

40 # FIXME: BELOW IS DEPRECATED! 

41 clonedBoneMap: t.Optional[t.Dict[str, BaseBone]] = None, 

42 ): 

43 """ 

44 Creates a new SkeletonInstance based on `skel_cls`. 

45 

46 :param skel_cls: Is the base skeleton class to inherit from and reference to. 

47 :param bones: If given, defines an iterable of bones that are take into the SkeletonInstance. 

48 The order of the bones defines the order in the SkeletonInstance. 

49 :param bone_map: A pre-defined bone map to use, or extend. 

50 :param clone: If set True, performs a cloning of the used bone map, to be entirely stand-alone. 

51 """ 

52 

53 # TODO: Remove with ViUR-core 3.8; required by viur-datastore :'-( 

54 if clonedBoneMap: 

55 msg = "'clonedBoneMap' was renamed into 'bone_map'" 

56 warnings.warn(msg, DeprecationWarning, stacklevel=2) 

57 # logging.warning(msg, stacklevel=2) 

58 

59 if bone_map: 

60 raise ValueError("Can't provide both 'bone_map' and 'clonedBoneMap'") 

61 

62 bone_map = clonedBoneMap 

63 

64 bone_map = bone_map or {} 

65 

66 if bones: 

67 names = ("key",) + tuple(bones) 

68 

69 # generate full keys sequence based on definition; keeps order of patterns! 

70 keys = [] 

71 for name in names: 

72 if name in skel_cls.__boneMap__: 

73 keys.append(name) 

74 else: 

75 keys.extend(fnmatch.filter(skel_cls.__boneMap__.keys(), name)) 

76 

77 if clone: 

78 bone_map |= {k: copy.deepcopy(skel_cls.__boneMap__[k]) for k in keys if skel_cls.__boneMap__[k]} 

79 else: 

80 bone_map |= {k: skel_cls.__boneMap__[k] for k in keys if skel_cls.__boneMap__[k]} 

81 

82 elif clone: 

83 if bone_map: 

84 bone_map = copy.deepcopy(bone_map) 

85 else: 

86 bone_map = copy.deepcopy(skel_cls.__boneMap__) 

87 

88 # generated or use provided bone_map 

89 if bone_map: 

90 self.boneMap = bone_map 

91 

92 else: # No Subskel, no Clone 

93 self.boneMap = skel_cls.__boneMap__.copy() 

94 

95 if clone: 

96 for v in self.boneMap.values(): 

97 v.isClonedInstance = True 

98 

99 self._cascade_deletion = False 

100 self.accessedValues = {} 

101 self.dbEntity = entity 

102 self.errors = [] 

103 self.is_cloned = clone 

104 self.renderAccessedValues = {} 

105 self.renderPreparation = None 

106 self.skeletonCls = skel_cls 

107 

108 def items(self, yieldBoneValues: bool = False) -> t.Iterable[tuple[str, BaseBone]]: 

109 if yieldBoneValues: 

110 for key in self.boneMap.keys(): 

111 yield key, self[key] 

112 else: 

113 yield from self.boneMap.items() 

114 

115 def keys(self) -> t.Iterable[str]: 

116 yield from self.boneMap.keys() 

117 

118 def values(self) -> t.Iterable[t.Any]: 

119 yield from self.boneMap.values() 

120 

121 def __iter__(self) -> t.Iterable[str]: 

122 yield from self.keys() 

123 

124 def __contains__(self, item): 

125 return item in self.boneMap 

126 

127 def __bool__(self): 

128 return bool(self.accessedValues or self.dbEntity) 

129 

130 def get(self, item, default=None): 

131 if item not in self: 

132 return default 

133 

134 return self[item] 

135 

136 def update(self, *args, **kwargs) -> None: 

137 self.__ior__(dict(*args, **kwargs)) 

138 

139 def __setitem__(self, key, value): 

140 assert self.renderPreparation is None, "Cannot modify values while rendering" 

141 if isinstance(value, BaseBone): 

142 raise AttributeError(f"Don't assign this bone object as skel[\"{key}\"] = ... anymore to the skeleton. " 

143 f"Use skel.{key} = ... for bone to skeleton assignment!") 

144 self.accessedValues[key] = value 

145 

146 def __getitem__(self, key): 

147 if self.renderPreparation: 

148 if key in self.renderAccessedValues: 

149 return self.renderAccessedValues[key] 

150 if key not in self.accessedValues: 

151 boneInstance = self.boneMap.get(key, None) 

152 if boneInstance: 

153 if self.dbEntity is not None: 

154 boneInstance.unserialize(self, key) 

155 else: 

156 self.accessedValues[key] = boneInstance.getDefaultValue(self) 

157 if not self.renderPreparation: 

158 return self.accessedValues.get(key) 

159 value = self.renderPreparation(getattr(self, key), self, key, self.accessedValues.get(key)) 

160 self.renderAccessedValues[key] = value 

161 return value 

162 

163 def __getattr__(self, item: str): 

164 """ 

165 Get a special attribute from the SkeletonInstance 

166 

167 __getattr__ is called when an attribute access fails with an 

168 AttributeError. So we know that this is not a real attribute of 

169 the SkeletonInstance. But there are still a few special cases in which 

170 attributes are loaded from the skeleton class. 

171 """ 

172 if item == "boneMap": 

173 return {} # There are __setAttr__ calls before __init__ has run 

174 

175 # Load attribute value from the Skeleton class 

176 elif item in { 

177 "database_adapters", 

178 "interBoneValidations", 

179 "kindName", 

180 }: 

181 return getattr(self.skeletonCls, item) 

182 

183 # FIXME: viur-datastore backward compatiblity REMOVE WITH VIUR4 

184 elif item == "customDatabaseAdapter": 

185 if prop := getattr(self.skeletonCls, "database_adapters"): 

186 return prop[0] # viur-datastore assumes there is only ONE! 

187 

188 return None 

189 

190 # Load a @classmethod from the Skeleton class and bound this SkeletonInstance 

191 elif item in { 

192 "all", 

193 "delete", 

194 "patch", 

195 "fromClient", 

196 "fromDB", 

197 "getCurrentSEOKeys", 

198 "postDeletedHandler", 

199 "postSavedHandler", 

200 "preProcessBlobLocks", 

201 "preProcessSerializedData", 

202 "read", 

203 "readonly", 

204 "refresh", 

205 "serialize", 

206 "setBoneValue", 

207 "toDB", 

208 "unserialize", 

209 "write", 

210 }: 

211 return partial(getattr(self.skeletonCls, item), self) 

212 

213 # Load a @property from the Skeleton class 

214 try: 

215 # Use try/except to save an if check 

216 class_value = getattr(self.skeletonCls, item) 

217 

218 except AttributeError: 

219 # Not inside the Skeleton class, okay at this point. 

220 pass 

221 

222 else: 

223 if isinstance(class_value, property): 

224 # The attribute is a @property and can be called 

225 # Note: `self` is this SkeletonInstance, not the Skeleton class. 

226 # Therefore, you can access values inside the property method 

227 # with item-access like `self["key"]`. 

228 try: 

229 return class_value.fget(self) 

230 except AttributeError as exc: 

231 # The AttributeError cannot be re-raised any further at this point. 

232 # Since this would then be evaluated as an access error 

233 # to the property attribute. 

234 # Otherwise, it would be lost that it is an incorrect attribute access 

235 # within this property (during the method call). 

236 msg, *args = exc.args 

237 msg = f"AttributeError: {msg}" 

238 raise ValueError(msg, *args) from exc 

239 # Load the bone instance from the bone map of this SkeletonInstance 

240 try: 

241 return self.boneMap[item] 

242 except KeyError as exc: 

243 raise AttributeError(f"{self.__class__.__name__!r} object has no attribute '{item}'") from exc 

244 

245 def __delattr__(self, item): 

246 del self.boneMap[item] 

247 if item in self.accessedValues: 

248 del self.accessedValues[item] 

249 if item in self.renderAccessedValues: 

250 del self.renderAccessedValues[item] 

251 

252 def __setattr__(self, key, value): 

253 if key in self.boneMap or isinstance(value, BaseBone): 

254 if value is None: 

255 del self.boneMap[key] 

256 else: 

257 value.__set_name__(self.skeletonCls, key) 

258 self.boneMap[key] = value 

259 elif key == "renderPreparation": 

260 super().__setattr__(key, value) 

261 self.renderAccessedValues.clear() 

262 else: 

263 super().__setattr__(key, value) 

264 

265 def __repr__(self) -> str: 

266 return f"<SkeletonInstance of {self.skeletonCls.__name__} with {dict(self)}>" 

267 

268 def __str__(self) -> str: 

269 return str(dict(self)) 

270 

271 def __len__(self) -> int: 

272 return len(self.boneMap) 

273 

274 def __ior__(self, other: dict | SkeletonInstance | db.Entity) -> SkeletonInstance: 

275 if isinstance(other, dict): 

276 for key, value in other.items(): 

277 self.setBoneValue(key, value) 

278 elif isinstance(other, db.Entity): 

279 new_entity = self.dbEntity or db.Entity() 

280 # We're not overriding the key 

281 for key, value in other.items(): 

282 new_entity[key] = value 

283 self.setEntity(new_entity) 

284 elif isinstance(other, SkeletonInstance): 

285 for key, value in other.accessedValues.items(): 

286 self.accessedValues[key] = value 

287 for key, value in other.dbEntity.items(): 

288 self.dbEntity[key] = value 

289 else: 

290 raise ValueError("Unsupported Type") 

291 return self 

292 

293 def clone(self, *, apply_clone_strategy: bool = False) -> t.Self: 

294 """ 

295 Clones a SkeletonInstance into a modificable, stand-alone instance. 

296 This will also allow to modify the underlying data model. 

297 """ 

298 res = SkeletonInstance(self.skeletonCls, bone_map=self.boneMap, clone=True) 

299 if apply_clone_strategy: 

300 for bone_name, bone_instance in self.items(): 

301 bone_instance.clone_value(res, self, bone_name) 

302 else: 

303 res.accessedValues = copy.deepcopy(self.accessedValues) 

304 res.dbEntity = copy.deepcopy(self.dbEntity) 

305 res.is_cloned = True 

306 if not apply_clone_strategy: 

307 res.renderAccessedValues = copy.deepcopy(self.renderAccessedValues) 

308 # else: Depending on the strategy the values are cloned in bone_instance.clone_value too 

309 

310 return res 

311 

312 def ensure_is_cloned(self): 

313 """ 

314 Ensured this SkeletonInstance is a stand-alone clone, which can be modified. 

315 Does nothing in case it was already cloned before. 

316 """ 

317 if not self.is_cloned: 

318 return self.clone() 

319 

320 return self 

321 

322 def setEntity(self, entity: db.Entity): 

323 self.dbEntity = entity 

324 self.accessedValues = {} 

325 self.renderAccessedValues = {} 

326 

327 def structure(self) -> dict: 

328 return { 

329 key: bone.structure() | {"sortindex": i} 

330 for i, (key, bone) in enumerate(self.items()) 

331 } 

332 

333 def dump(self): 

334 """ 

335 Return a simplified version of the bone values in this skeleton. 

336 This can be used for example in the JSON renderer. 

337 """ 

338 

339 return { 

340 bone_name: bone.dump(self, bone_name) for bone_name, bone in self.items() 

341 } 

342 

343 def __deepcopy__(self, memodict): 

344 res = self.clone() 

345 memodict[id(self)] = res 

346 return res