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

186 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-25 14:23 +0000

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

2 

3import copy 

4import fnmatch 

5import logging # noqa 

6import typing as t 

7import warnings 

8from functools import partial 

9 

10from viur.core import db 

11from .skeleton import Skeleton 

12from ..bones.base import BaseBone 

13 

14 

15class SkeletonInstance: 

16 """ 

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

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

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

20 """ 

21 __slots__ = { 

22 "_cascade_deletion", 

23 "accessedValues", 

24 "boneMap", 

25 "dbEntity", 

26 "errors", 

27 "is_cloned", 

28 "renderAccessedValues", 

29 "renderPreparation", 

30 "skeletonCls", 

31 } 

32 

33 def __init__( 

34 self, 

35 skel_cls: t.Type[Skeleton], 

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

37 *, 

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

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

40 clone: bool = False, 

41 # FIXME: BELOW IS DEPRECATED! 

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

43 ): 

44 """ 

45 Creates a new SkeletonInstance based on `skel_cls`. 

46 

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

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

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

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

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

52 """ 

53 

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

55 if clonedBoneMap: 

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

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

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

59 

60 if bone_map: 

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

62 

63 bone_map = clonedBoneMap 

64 

65 bone_map = bone_map or {} 

66 

67 if bones: 

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

69 

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

71 keys = [] 

72 for name in names: 

73 if name in skel_cls.__boneMap__: 

74 keys.append(name) 

75 else: 

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

77 

78 if clone: 

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

80 else: 

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

82 

83 elif clone: 

84 if bone_map: 

85 bone_map = copy.deepcopy(bone_map) 

86 else: 

87 bone_map = copy.deepcopy(skel_cls.__boneMap__) 

88 

89 # generated or use provided bone_map 

90 if bone_map: 

91 self.boneMap = bone_map 

92 

93 else: # No Subskel, no Clone 

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

95 

96 if clone: 

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

98 v.isClonedInstance = True 

99 

100 self._cascade_deletion = False 

101 self.accessedValues = {} 

102 self.dbEntity = entity 

103 self.errors = [] 

104 self.is_cloned = clone 

105 self.renderAccessedValues = {} 

106 self.renderPreparation = None 

107 self.skeletonCls = skel_cls 

108 

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

110 if yieldBoneValues: 

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

112 yield key, self[key] 

113 else: 

114 yield from self.boneMap.items() 

115 

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

117 yield from self.boneMap.keys() 

118 

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

120 yield from self.boneMap.values() 

121 

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

123 yield from self.keys() 

124 

125 def __contains__(self, item): 

126 return item in self.boneMap 

127 

128 def __bool__(self): 

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

130 

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

132 if item not in self: 

133 return default 

134 

135 return self[item] 

136 

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

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

139 

140 def __setitem__(self, key, value): 

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

142 if isinstance(value, BaseBone): 

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

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

145 self.accessedValues[key] = value 

146 

147 def __getitem__(self, key): 

148 if self.renderPreparation: 

149 if key in self.renderAccessedValues: 

150 return self.renderAccessedValues[key] 

151 

152 if key not in self.accessedValues: 

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

154 if self.dbEntity is not None: 

155 bone.unserialize(self, key) 

156 elif bone.unserialize_compute(self, key): 

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

158 else: 

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

160 

161 if not self.renderPreparation: 

162 return self.accessedValues.get(key) 

163 

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

165 self.renderAccessedValues[key] = value 

166 return value 

167 

168 def __getattr__(self, item: str): 

169 """ 

170 Get a special attribute from the SkeletonInstance 

171 

172 __getattr__ is called when an attribute access fails with an 

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

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

175 attributes are loaded from the skeleton class. 

176 """ 

177 if item == "boneMap": 

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

179 

180 # Load attribute value from the Skeleton class 

181 elif item in { 

182 "database_adapters", 

183 "interBoneValidations", 

184 "kindName", 

185 }: 

186 return getattr(self.skeletonCls, item) 

187 

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

189 elif item == "customDatabaseAdapter": 

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

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

192 

193 return None 

194 

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

196 elif item in { 

197 "all", 

198 "delete", 

199 "patch", 

200 "fromClient", 

201 "fromDB", 

202 "getCurrentSEOKeys", 

203 "postDeletedHandler", 

204 "postSavedHandler", 

205 "preProcessBlobLocks", 

206 "preProcessSerializedData", 

207 "read", 

208 "readonly", 

209 "refresh", 

210 "serialize", 

211 "setBoneValue", 

212 "toDB", 

213 "unserialize", 

214 "write", 

215 }: 

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

217 

218 # logging.info(f"Accessing {item=} from {self=}") 

219 from .relskel import RefSkel 

220 from .utils import without_render_preparation 

221 

222 if issubclass(self.skeletonCls, RefSkel) and self.skeletonCls.skeletonCls is not None: 

223 skeletonCls = self.skeletonCls.skeletonCls 

224 else: 

225 skeletonCls = self.skeletonCls 

226 

227 try: 

228 # Use try/except to save an if check 

229 class_value = getattr(skeletonCls, item) 

230 

231 except AttributeError: 

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

233 pass 

234 

235 else: 

236 if isinstance(class_value, property): 

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

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

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

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

241 try: 

242 # It is not reasonable to process two types of data (raw and rendered) in one 

243 # and the same @property. Therefore, @properties always receive the raw data. 

244 return class_value.fget(without_render_preparation(self)) 

245 except AttributeError as exc: 

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

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

248 # to the property attribute. 

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

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

251 msg, *args = exc.args 

252 msg = f"AttributeError: {msg}" 

253 raise ValueError(msg, *args) from exc 

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

255 try: 

256 return self.boneMap[item] 

257 except KeyError as exc: 

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

259 

260 def __delattr__(self, item): 

261 del self.boneMap[item] 

262 if item in self.accessedValues: 

263 del self.accessedValues[item] 

264 if item in self.renderAccessedValues: 

265 del self.renderAccessedValues[item] 

266 

267 def __setattr__(self, key, value): 

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

269 if value is None: 

270 del self.boneMap[key] 

271 else: 

272 value.__set_name__(self.skeletonCls, key) 

273 self.boneMap[key] = value 

274 elif key == "renderPreparation": 

275 super().__setattr__(key, value) 

276 self.renderAccessedValues.clear() 

277 else: 

278 super().__setattr__(key, value) 

279 

280 def __repr__(self) -> str: 

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

282 

283 def __str__(self) -> str: 

284 return str(dict(self)) 

285 

286 def __len__(self) -> int: 

287 return len(self.boneMap) 

288 

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

290 if isinstance(other, dict): 

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

292 self.setBoneValue(key, value) 

293 elif isinstance(other, db.Entity): 

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

295 # We're not overriding the key 

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

297 new_entity[key] = value 

298 self.setEntity(new_entity) 

299 elif isinstance(other, SkeletonInstance): 

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

301 self.accessedValues[key] = value 

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

303 self.dbEntity[key] = value 

304 else: 

305 raise ValueError("Unsupported Type") 

306 return self 

307 

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

309 """ 

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

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

312 """ 

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

314 if apply_clone_strategy: 

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

316 bone_instance.clone_value(res, self, bone_name) 

317 else: 

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

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

320 res.is_cloned = True 

321 if not apply_clone_strategy: 

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

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

324 

325 return res 

326 

327 def ensure_is_cloned(self): 

328 """ 

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

330 Does nothing in case it was already cloned before. 

331 """ 

332 if not self.is_cloned: 

333 return self.clone() 

334 

335 return self 

336 

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

338 self.dbEntity = entity 

339 self.accessedValues = {} 

340 self.renderAccessedValues = {} 

341 

342 def structure(self) -> dict: 

343 return { 

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

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

346 } 

347 

348 def dump(self): 

349 """ 

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

351 

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

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

354 JSON. 

355 """ 

356 

357 return { 

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

359 } 

360 

361 def __deepcopy__(self, memodict): 

362 res = self.clone() 

363 memodict[id(self)] = res 

364 return res