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

192 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-25 15:02 +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, utils 

11from .skeleton import Skeleton 

12 

13if t.TYPE_CHECKING: 13 ↛ 14line 13 didn't jump to line 14 because the condition on line 13 was never true

14 from .meta import Skeleton_Cls 

15else: 

16 # Avoid circular import at runtime: meta.py → bones/__init__.py → image.py → relskel.py → meta.py 

17 Skeleton_Cls = t.TypeVar("Skeleton_Cls") 

18from ..bones.base import BaseBone 

19 

20 

21class SkeletonInstance(t.Generic[Skeleton_Cls]): 

22 """The actual wrapper around a Skeleton-Class. 

23 

24 An object of this class is what's actually returned when you call a Skeleton-Class. 

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

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

27 

28 The class is generic over :data:`Skeleton_Cls`, which lets the type checker track which 

29 concrete Skeleton subclass a given instance belongs to. Without the type parameter the 

30 class still works exactly as before — the parameter is purely a static-analysis hint. 

31 

32 **Basic usage** 

33 

34 Calling a Skeleton class returns a typed ``SkeletonInstance``:: 

35 

36 skel = ProductSkel() # -> SkeletonInstance[ProductSkel] 

37 skel.skeletonCls # ProductSkel 

38 skel["price"] # type-safe bone access 

39 

40 **Typed module method** 

41 

42 Override ``viewSkel`` / ``editSkel`` etc. in your module with an explicit return type 

43 so that callers and IDE auto-complete know which bones are available:: 

44 

45 class ProductModule(List): 

46 def editSkel(self) -> SkeletonInstance[ProductSkel]: 

47 skel = super().editSkel() 

48 skel.price.readOnly = True 

49 return skel 

50 

51 **Generic helper** 

52 

53 Use :data:`Skeleton_Cls` when writing utilities that must stay agnostic about the 

54 concrete skeleton but still preserve the type through the call:: 

55 

56 def set_owner(skel: SkeletonInstance[Skeleton_Cls], owner: str) -> SkeletonInstance[Skeleton_Cls]: 

57 skel = skel.clone() 

58 skel["owner"] = owner 

59 return skel # type checker keeps SkeletonInstance[ProductSkel] etc. 

60 

61 **Classmethod signatures** (``t.Self``) 

62 

63 Inside Skeleton classmethods, ``t.Self`` is preferred over :data:`Skeleton_Cls` because 

64 the type checker automatically narrows to the class the method is called on:: 

65 

66 class BaseSkeleton: 

67 @classmethod 

68 def fromClient(cls, skel: SkeletonInstance[t.Self], data: dict) -> bool: ... 

69 

70 # Inferred as SkeletonInstance[ProductSkel] when called on ProductSkel 

71 ProductSkel.fromClient(skel, request.POST) 

72 """ 

73 __slots__ = { 

74 "_cascade_deletion", 

75 "accessedValues", 

76 "boneMap", 

77 "dbEntity", 

78 "errors", 

79 "is_cloned", 

80 "renderAccessedValues", 

81 "renderPreparation", 

82 "skeletonCls", 

83 } 

84 

85 def __init__( 

86 self, 

87 skel_cls: t.Type[Skeleton_Cls], 

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

89 *, 

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

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

92 clone: bool = False, 

93 # FIXME: BELOW IS DEPRECATED! 

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

95 ): 

96 """ 

97 Creates a new SkeletonInstance based on `skel_cls`. 

98 

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

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

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

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

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

104 """ 

105 

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

107 if clonedBoneMap: 107 ↛ 108line 107 didn't jump to line 108 because the condition on line 107 was never true

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

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

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

111 

112 if bone_map: 

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

114 

115 bone_map = clonedBoneMap 

116 

117 bone_map = bone_map or {} 

118 

119 if bones: 119 ↛ 120line 119 didn't jump to line 120 because the condition on line 119 was never true

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

121 

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

123 keys = [] 

124 for name in names: 

125 if name in skel_cls.__boneMap__: 

126 keys.append(name) 

127 else: 

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

129 

130 if clone: 

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

132 else: 

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

134 

135 elif clone: 

136 if bone_map: 136 ↛ 139line 136 didn't jump to line 139 because the condition on line 136 was always true

137 bone_map = copy.deepcopy(bone_map) 

138 else: 

139 bone_map = copy.deepcopy(skel_cls.__boneMap__) 

140 

141 # generated or use provided bone_map 

142 if bone_map: 

143 self.boneMap = bone_map 

144 

145 else: # No Subskel, no Clone 

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

147 

148 if clone: 

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

150 v.isClonedInstance = True 

151 

152 self._cascade_deletion = False 

153 self.accessedValues = {} 

154 self.dbEntity = entity 

155 self.errors = [] 

156 self.is_cloned = clone 

157 self.renderAccessedValues = {} 

158 self.renderPreparation = None 

159 self.skeletonCls: t.Type[Skeleton_Cls] = skel_cls 

160 

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

162 if yieldBoneValues: 162 ↛ 163line 162 didn't jump to line 163 because the condition on line 162 was never true

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

164 yield key, self[key] 

165 else: 

166 yield from self.boneMap.items() 

167 

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

169 yield from self.boneMap.keys() 

170 

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

172 yield from self.boneMap.values() 

173 

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

175 yield from self.keys() 

176 

177 def __contains__(self, item): 

178 return item in self.boneMap 

179 

180 def __bool__(self): 

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

182 

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

184 if item not in self: 

185 return default 

186 

187 return self[item] 

188 

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

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

191 

192 def __setitem__(self, key, value): 

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

194 if isinstance(value, BaseBone): 194 ↛ 195line 194 didn't jump to line 195 because the condition on line 194 was never true

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

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

197 self.accessedValues[key] = value 

198 

199 def __getitem__(self, key): 

200 if self.renderPreparation: 200 ↛ 201line 200 didn't jump to line 201 because the condition on line 200 was never true

201 if key in self.renderAccessedValues: 

202 return self.renderAccessedValues[key] 

203 

204 if key not in self.accessedValues: 

205 if bone := self.boneMap.get(key): 205 ↛ 213line 205 didn't jump to line 213 because the condition on line 205 was always true

206 if self.dbEntity is not None: 

207 bone.unserialize(self, key) 

208 elif bone.unserialize_compute(self, key): 208 ↛ 209line 208 didn't jump to line 209 because the condition on line 208 was never true

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

210 else: 

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

212 

213 if not self.renderPreparation: 213 ↛ 216line 213 didn't jump to line 216 because the condition on line 213 was always true

214 return self.accessedValues.get(key) 

215 

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

217 self.renderAccessedValues[key] = value 

218 return value 

219 

220 def __getattr__(self, item: str): 

221 """ 

222 Get a special attribute from the SkeletonInstance 

223 

224 __getattr__ is called when an attribute access fails with an 

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

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

227 attributes are loaded from the skeleton class. 

228 """ 

229 if item == "boneMap": 

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

231 

232 # Load attribute value from the Skeleton class 

233 elif item in { 

234 "database_adapters", 

235 "interBoneValidations", 

236 "kindName", 

237 }: 

238 return getattr(self.skeletonCls, item) 

239 

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

241 elif item == "customDatabaseAdapter": 241 ↛ 242line 241 didn't jump to line 242 because the condition on line 241 was never true

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

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

244 

245 return None 

246 

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

248 elif item in { 

249 "all", 

250 "delete", 

251 "patch", 

252 "fromClient", 

253 "fromDB", 

254 "getCurrentSEOKeys", 

255 "postDeletedHandler", 

256 "postSavedHandler", 

257 "preProcessBlobLocks", 

258 "preProcessSerializedData", 

259 "read", 

260 "readonly", 

261 "refresh", 

262 "serialize", 

263 "setBoneValue", 

264 "toDB", 

265 "unserialize", 

266 "write", 

267 }: 

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

269 

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

271 from .relskel import RefSkel 

272 from .utils import without_render_preparation 

273 

274 if issubclass(self.skeletonCls, RefSkel) and self.skeletonCls.skeletonCls is not None: 274 ↛ 275line 274 didn't jump to line 275 because the condition on line 274 was never true

275 skeletonCls = self.skeletonCls.skeletonCls 

276 else: 

277 skeletonCls = self.skeletonCls 

278 

279 try: 

280 # Use try/except to save an if check 

281 class_value = getattr(skeletonCls, item) 

282 

283 except AttributeError: 

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

285 pass 

286 

287 else: 

288 if isinstance(class_value, property): 288 ↛ 293line 288 didn't jump to line 293 because the condition on line 288 was never true

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

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

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

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

293 try: 

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

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

296 return class_value.fget(without_render_preparation(self)) 

297 except AttributeError as exc: 

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

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

300 # to the property attribute. 

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

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

303 msg, *args = exc.args 

304 msg = f"AttributeError: {msg}" 

305 raise ValueError(msg, *args) from exc 

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

307 try: 

308 return self.boneMap[item] 

309 except KeyError as exc: 

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

311 

312 def __delattr__(self, item): 

313 del self.boneMap[item] 

314 if item in self.accessedValues: 

315 del self.accessedValues[item] 

316 if item in self.renderAccessedValues: 

317 del self.renderAccessedValues[item] 

318 

319 def __setattr__(self, key, value): 

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

321 if value is None: 

322 del self.boneMap[key] 

323 else: 

324 value.__set_name__(self.skeletonCls, key) 

325 self.boneMap[key] = value 

326 elif key == "renderPreparation": 

327 super().__setattr__(key, value) 

328 self.renderAccessedValues.clear() 

329 else: 

330 super().__setattr__(key, value) 

331 

332 def __repr__(self) -> str: 

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

334 

335 def __str__(self) -> str: 

336 return str(dict(self)) 

337 

338 def __len__(self) -> int: 

339 return len(self.boneMap) 

340 

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

342 if isinstance(other, dict): 

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

344 self.setBoneValue(key, value) 

345 elif isinstance(other, db.Entity): 

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

347 # We're not overriding the key 

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

349 new_entity[key] = value 

350 self.setEntity(new_entity) 

351 elif isinstance(other, SkeletonInstance): 

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

353 self.accessedValues[key] = value 

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

355 self.dbEntity[key] = value 

356 else: 

357 raise ValueError("Unsupported Type") 

358 return self 

359 

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

361 """ 

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

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

364 """ 

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

366 if apply_clone_strategy: 366 ↛ 367line 366 didn't jump to line 367 because the condition on line 366 was never true

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

368 bone_instance.clone_value(res, self, bone_name) 

369 else: 

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

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

372 res.is_cloned = True 

373 if not apply_clone_strategy: 373 ↛ 377line 373 didn't jump to line 377 because the condition on line 373 was always true

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

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

376 

377 return res 

378 

379 def ensure_is_cloned(self): 

380 """ 

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

382 Does nothing in case it was already cloned before. 

383 """ 

384 if not self.is_cloned: 

385 return self.clone() 

386 

387 return self 

388 

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

390 self.dbEntity = entity 

391 self.accessedValues = {} 

392 self.renderAccessedValues = {} 

393 

394 def structure(self) -> dict: 

395 return { 

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

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

398 } 

399 

400 def dump(self, *, bones: t.Iterable[str] = ()) -> dict[str, t.Any]: 

401 """ 

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

403 

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

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

406 JSON. 

407 

408 :param bones: Iterable of bone names to include. If None, all bones are dumped. 

409 """ 

410 if bones: 

411 bones = set(utils.ensure_iterable(bones)) 

412 return { 

413 bone_name: bone.dump(self, bone_name) 

414 for bone_name, bone in self.items() 

415 if bone_name in bones 

416 } 

417 

418 return { 

419 bone_name: bone.dump(self, bone_name) 

420 for bone_name, bone in self.items() 

421 } 

422 

423 def __deepcopy__(self, memodict): 

424 res = self.clone() 

425 memodict[id(self)] = res 

426 return res