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
« 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
3import copy
4import fnmatch
5import typing as t
6import warnings
8from functools import partial
9from ..bones.base import BaseBone
10from .skeleton import Skeleton
11from viur.core import db
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 }
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`.
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 """
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)
59 if bone_map:
60 raise ValueError("Can't provide both 'bone_map' and 'clonedBoneMap'")
62 bone_map = clonedBoneMap
64 bone_map = bone_map or {}
66 if bones:
67 names = ("key",) + tuple(bones)
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))
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]}
82 elif clone:
83 if bone_map:
84 bone_map = copy.deepcopy(bone_map)
85 else:
86 bone_map = copy.deepcopy(skel_cls.__boneMap__)
88 # generated or use provided bone_map
89 if bone_map:
90 self.boneMap = bone_map
92 else: # No Subskel, no Clone
93 self.boneMap = skel_cls.__boneMap__.copy()
95 if clone:
96 for v in self.boneMap.values():
97 v.isClonedInstance = True
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
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()
115 def keys(self) -> t.Iterable[str]:
116 yield from self.boneMap.keys()
118 def values(self) -> t.Iterable[t.Any]:
119 yield from self.boneMap.values()
121 def __iter__(self) -> t.Iterable[str]:
122 yield from self.keys()
124 def __contains__(self, item):
125 return item in self.boneMap
127 def __bool__(self):
128 return bool(self.accessedValues or self.dbEntity)
130 def get(self, item, default=None):
131 if item not in self:
132 return default
134 return self[item]
136 def update(self, *args, **kwargs) -> None:
137 self.__ior__(dict(*args, **kwargs))
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
146 def __getitem__(self, key):
147 if self.renderPreparation:
148 if key in self.renderAccessedValues:
149 return self.renderAccessedValues[key]
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)
160 if not self.renderPreparation:
161 return self.accessedValues.get(key)
163 value = self.renderPreparation(getattr(self, key), self, key, self.accessedValues.get(key))
164 self.renderAccessedValues[key] = value
165 return value
167 def __getattr__(self, item: str):
168 """
169 Get a special attribute from the SkeletonInstance
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
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)
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!
192 return None
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)
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)
222 except AttributeError:
223 # Not inside the Skeleton class, okay at this point.
224 pass
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
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]
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)
269 def __repr__(self) -> str:
270 return f"<SkeletonInstance of {self.skeletonCls.__name__} with {dict(self)}>"
272 def __str__(self) -> str:
273 return str(dict(self))
275 def __len__(self) -> int:
276 return len(self.boneMap)
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
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
314 return res
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()
324 return self
326 def setEntity(self, entity: db.Entity):
327 self.dbEntity = entity
328 self.accessedValues = {}
329 self.renderAccessedValues = {}
331 def structure(self) -> dict:
332 return {
333 key: bone.structure() | {"sortindex": i}
334 for i, (key, bone) in enumerate(self.items())
335 }
337 def dump(self):
338 """
339 Return a JSON-serializable version of the bone values in this skeleton.
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 """
346 return {
347 bone_name: bone.dump(self, bone_name) for bone_name, bone in self.items()
348 }
350 def __deepcopy__(self, memodict):
351 res = self.clone()
352 memodict[id(self)] = res
353 return res