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

180 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-11 20:18 +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 

151 if key not in self.accessedValues: 

152 if bone := self.boneMap.get(key): 

153 if self.dbEntity is not None: 

154 bone.unserialize(self, key) 

155 elif bone.unserialize_compute(self, key): 

156 pass # self.accessedValues[key] updated by unserialize_compute() 

157 else: 

158 self.accessedValues[key] = bone.getDefaultValue(self) 

159 

160 if not self.renderPreparation: 

161 return self.accessedValues.get(key) 

162 

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

164 self.renderAccessedValues[key] = value 

165 return value 

166 

167 def __getattr__(self, item: str): 

168 """ 

169 Get a special attribute from the SkeletonInstance 

170 

171 __getattr__ is called when an attribute access fails with an 

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

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

174 attributes are loaded from the skeleton class. 

175 """ 

176 if item == "boneMap": 

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

178 

179 # Load attribute value from the Skeleton class 

180 elif item in { 

181 "database_adapters", 

182 "interBoneValidations", 

183 "kindName", 

184 }: 

185 return getattr(self.skeletonCls, item) 

186 

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

188 elif item == "customDatabaseAdapter": 

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

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

191 

192 return None 

193 

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

195 elif item in { 

196 "all", 

197 "delete", 

198 "patch", 

199 "fromClient", 

200 "fromDB", 

201 "getCurrentSEOKeys", 

202 "postDeletedHandler", 

203 "postSavedHandler", 

204 "preProcessBlobLocks", 

205 "preProcessSerializedData", 

206 "read", 

207 "readonly", 

208 "refresh", 

209 "serialize", 

210 "setBoneValue", 

211 "toDB", 

212 "unserialize", 

213 "write", 

214 }: 

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

216 

217 # Load a @property from the Skeleton class 

218 try: 

219 # Use try/except to save an if check 

220 class_value = getattr(self.skeletonCls, item) 

221 

222 except AttributeError: 

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

224 pass 

225 

226 else: 

227 if isinstance(class_value, property): 

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

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

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

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

232 try: 

233 return class_value.fget(self) 

234 except AttributeError as exc: 

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

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

237 # to the property attribute. 

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

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

240 msg, *args = exc.args 

241 msg = f"AttributeError: {msg}" 

242 raise ValueError(msg, *args) from exc 

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

244 try: 

245 return self.boneMap[item] 

246 except KeyError as exc: 

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

248 

249 def __delattr__(self, item): 

250 del self.boneMap[item] 

251 if item in self.accessedValues: 

252 del self.accessedValues[item] 

253 if item in self.renderAccessedValues: 

254 del self.renderAccessedValues[item] 

255 

256 def __setattr__(self, key, value): 

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

258 if value is None: 

259 del self.boneMap[key] 

260 else: 

261 value.__set_name__(self.skeletonCls, key) 

262 self.boneMap[key] = value 

263 elif key == "renderPreparation": 

264 super().__setattr__(key, value) 

265 self.renderAccessedValues.clear() 

266 else: 

267 super().__setattr__(key, value) 

268 

269 def __repr__(self) -> str: 

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

271 

272 def __str__(self) -> str: 

273 return str(dict(self)) 

274 

275 def __len__(self) -> int: 

276 return len(self.boneMap) 

277 

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

279 if isinstance(other, dict): 

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

281 self.setBoneValue(key, value) 

282 elif isinstance(other, db.Entity): 

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

284 # We're not overriding the key 

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

286 new_entity[key] = value 

287 self.setEntity(new_entity) 

288 elif isinstance(other, SkeletonInstance): 

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

290 self.accessedValues[key] = value 

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

292 self.dbEntity[key] = value 

293 else: 

294 raise ValueError("Unsupported Type") 

295 return self 

296 

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

298 """ 

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

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

301 """ 

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

303 if apply_clone_strategy: 

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

305 bone_instance.clone_value(res, self, bone_name) 

306 else: 

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

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

309 res.is_cloned = True 

310 if not apply_clone_strategy: 

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

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

313 

314 return res 

315 

316 def ensure_is_cloned(self): 

317 """ 

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

319 Does nothing in case it was already cloned before. 

320 """ 

321 if not self.is_cloned: 

322 return self.clone() 

323 

324 return self 

325 

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

327 self.dbEntity = entity 

328 self.accessedValues = {} 

329 self.renderAccessedValues = {} 

330 

331 def structure(self) -> dict: 

332 return { 

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

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

335 } 

336 

337 def dump(self): 

338 """ 

339 Return a JSON-serializable version of the bone values in this skeleton. 

340 

341 The function is not called "to_json()" because the JSON-serializable 

342 format can be used for different purposes and renderings, not just 

343 JSON. 

344 """ 

345 

346 return { 

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

348 } 

349 

350 def __deepcopy__(self, memodict): 

351 res = self.clone() 

352 memodict[id(self)] = res 

353 return res