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
« 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
3import copy
4import fnmatch
5import logging # noqa
6import typing as t
7import warnings
8from functools import partial
10from viur.core import db, utils
11from .skeleton import Skeleton
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
21class SkeletonInstance(t.Generic[Skeleton_Cls]):
22 """The actual wrapper around a Skeleton-Class.
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.
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.
32 **Basic usage**
34 Calling a Skeleton class returns a typed ``SkeletonInstance``::
36 skel = ProductSkel() # -> SkeletonInstance[ProductSkel]
37 skel.skeletonCls # ProductSkel
38 skel["price"] # type-safe bone access
40 **Typed module method**
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::
45 class ProductModule(List):
46 def editSkel(self) -> SkeletonInstance[ProductSkel]:
47 skel = super().editSkel()
48 skel.price.readOnly = True
49 return skel
51 **Generic helper**
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::
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.
61 **Classmethod signatures** (``t.Self``)
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::
66 class BaseSkeleton:
67 @classmethod
68 def fromClient(cls, skel: SkeletonInstance[t.Self], data: dict) -> bool: ...
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 }
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`.
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 """
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)
112 if bone_map:
113 raise ValueError("Can't provide both 'bone_map' and 'clonedBoneMap'")
115 bone_map = clonedBoneMap
117 bone_map = bone_map or {}
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)
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))
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]}
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__)
141 # generated or use provided bone_map
142 if bone_map:
143 self.boneMap = bone_map
145 else: # No Subskel, no Clone
146 self.boneMap = skel_cls.__boneMap__.copy()
148 if clone:
149 for v in self.boneMap.values():
150 v.isClonedInstance = True
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
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()
168 def keys(self) -> t.Iterable[str]:
169 yield from self.boneMap.keys()
171 def values(self) -> t.Iterable[t.Any]:
172 yield from self.boneMap.values()
174 def __iter__(self) -> t.Iterable[str]:
175 yield from self.keys()
177 def __contains__(self, item):
178 return item in self.boneMap
180 def __bool__(self):
181 return bool(self.accessedValues or self.dbEntity)
183 def get(self, item, default=None):
184 if item not in self:
185 return default
187 return self[item]
189 def update(self, *args, **kwargs) -> None:
190 self.__ior__(dict(*args, **kwargs))
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
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]
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)
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)
216 value = self.renderPreparation(getattr(self, key), self, key, self.accessedValues.get(key))
217 self.renderAccessedValues[key] = value
218 return value
220 def __getattr__(self, item: str):
221 """
222 Get a special attribute from the SkeletonInstance
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
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)
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!
245 return None
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)
270 # logging.info(f"Accessing {item=} from {self=}")
271 from .relskel import RefSkel
272 from .utils import without_render_preparation
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
279 try:
280 # Use try/except to save an if check
281 class_value = getattr(skeletonCls, item)
283 except AttributeError:
284 # Not inside the Skeleton class, okay at this point.
285 pass
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
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]
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)
332 def __repr__(self) -> str:
333 return f"<SkeletonInstance of {self.skeletonCls.__name__} with {dict(self)}>"
335 def __str__(self) -> str:
336 return str(dict(self))
338 def __len__(self) -> int:
339 return len(self.boneMap)
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
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
377 return res
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()
387 return self
389 def setEntity(self, entity: db.Entity):
390 self.dbEntity = entity
391 self.accessedValues = {}
392 self.renderAccessedValues = {}
394 def structure(self) -> dict:
395 return {
396 key: bone.structure() | {"sortindex": i}
397 for i, (key, bone) in enumerate(self.items())
398 }
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.
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.
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 }
418 return {
419 bone_name: bone.dump(self, bone_name)
420 for bone_name, bone in self.items()
421 }
423 def __deepcopy__(self, memodict):
424 res = self.clone()
425 memodict[id(self)] = res
426 return res