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
« 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
3import copy
4import fnmatch
5import logging # noqa
6import typing as t
7import warnings
8from functools import partial
10from viur.core import db
11from .skeleton import Skeleton
12from ..bones.base import BaseBone
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 }
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`.
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 """
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)
60 if bone_map:
61 raise ValueError("Can't provide both 'bone_map' and 'clonedBoneMap'")
63 bone_map = clonedBoneMap
65 bone_map = bone_map or {}
67 if bones:
68 names = ("key",) + tuple(bones)
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))
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]}
83 elif clone:
84 if bone_map:
85 bone_map = copy.deepcopy(bone_map)
86 else:
87 bone_map = copy.deepcopy(skel_cls.__boneMap__)
89 # generated or use provided bone_map
90 if bone_map:
91 self.boneMap = bone_map
93 else: # No Subskel, no Clone
94 self.boneMap = skel_cls.__boneMap__.copy()
96 if clone:
97 for v in self.boneMap.values():
98 v.isClonedInstance = True
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
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()
116 def keys(self) -> t.Iterable[str]:
117 yield from self.boneMap.keys()
119 def values(self) -> t.Iterable[t.Any]:
120 yield from self.boneMap.values()
122 def __iter__(self) -> t.Iterable[str]:
123 yield from self.keys()
125 def __contains__(self, item):
126 return item in self.boneMap
128 def __bool__(self):
129 return bool(self.accessedValues or self.dbEntity)
131 def get(self, item, default=None):
132 if item not in self:
133 return default
135 return self[item]
137 def update(self, *args, **kwargs) -> None:
138 self.__ior__(dict(*args, **kwargs))
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
147 def __getitem__(self, key):
148 if self.renderPreparation:
149 if key in self.renderAccessedValues:
150 return self.renderAccessedValues[key]
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)
161 if not self.renderPreparation:
162 return self.accessedValues.get(key)
164 value = self.renderPreparation(getattr(self, key), self, key, self.accessedValues.get(key))
165 self.renderAccessedValues[key] = value
166 return value
168 def __getattr__(self, item: str):
169 """
170 Get a special attribute from the SkeletonInstance
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
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)
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!
193 return None
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)
218 # logging.info(f"Accessing {item=} from {self=}")
219 from .relskel import RefSkel
220 from .utils import without_render_preparation
222 if issubclass(self.skeletonCls, RefSkel) and self.skeletonCls.skeletonCls is not None:
223 skeletonCls = self.skeletonCls.skeletonCls
224 else:
225 skeletonCls = self.skeletonCls
227 try:
228 # Use try/except to save an if check
229 class_value = getattr(skeletonCls, item)
231 except AttributeError:
232 # Not inside the Skeleton class, okay at this point.
233 pass
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
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]
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)
280 def __repr__(self) -> str:
281 return f"<SkeletonInstance of {self.skeletonCls.__name__} with {dict(self)}>"
283 def __str__(self) -> str:
284 return str(dict(self))
286 def __len__(self) -> int:
287 return len(self.boneMap)
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
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
325 return res
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()
335 return self
337 def setEntity(self, entity: db.Entity):
338 self.dbEntity = entity
339 self.accessedValues = {}
340 self.renderAccessedValues = {}
342 def structure(self) -> dict:
343 return {
344 key: bone.structure() | {"sortindex": i}
345 for i, (key, bone) in enumerate(self.items())
346 }
348 def dump(self):
349 """
350 Return a JSON-serializable version of the bone values in this skeleton.
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 """
357 return {
358 bone_name: bone.dump(self, bone_name) for bone_name, bone in self.items()
359 }
361 def __deepcopy__(self, memodict):
362 res = self.clone()
363 memodict[id(self)] = res
364 return res