From 27a203e3e4a9ca055fa48f676f81e8ee5eaab803 Mon Sep 17 00:00:00 2001 From: Sven Eberth Date: Fri, 31 Jan 2025 18:12:56 +0100 Subject: [PATCH 01/10] feat: Implement `CloneBehavior` + `CloneStrategy` for a bone individual clone behavior --- src/viur/core/bones/__init__.py | 2 + src/viur/core/bones/base.py | 69 +++++++++++++++++++++++++++++++- src/viur/core/bones/sortindex.py | 4 ++ src/viur/core/prototypes/list.py | 4 +- src/viur/core/skeleton.py | 13 ++++-- 5 files changed, 86 insertions(+), 6 deletions(-) diff --git a/src/viur/core/bones/__init__.py b/src/viur/core/bones/__init__.py index 7856ae478..52ce1ce81 100644 --- a/src/viur/core/bones/__init__.py +++ b/src/viur/core/bones/__init__.py @@ -9,6 +9,8 @@ ReadFromClientException, UniqueLockMethod, UniqueValue, + CloneBehavior, + CloneStrategy, ) from .boolean import BooleanBone from .captcha import CaptchaBone diff --git a/src/viur/core/bones/base.py b/src/viur/core/bones/base.py index 71ecda57c..001acb681 100644 --- a/src/viur/core/bones/base.py +++ b/src/viur/core/bones/base.py @@ -6,6 +6,7 @@ """ import copy +import dataclasses import hashlib import inspect import logging @@ -13,7 +14,8 @@ from collections.abc import Iterable from dataclasses import dataclass, field from datetime import timedelta -from enum import Enum +from enum import Enum, auto +import enum from viur.core import db, utils, current, i18n from viur.core.config import conf @@ -204,6 +206,33 @@ class Compute: raw: bool = True # defines whether the value returned by fn is used as is, or is passed through bone.fromClient +class CloneStrategy(enum.StrEnum): + SET_NULL = enum.auto() + SET_DEFAULT = enum.auto() + SET_EMPTY = enum.auto() + COPY_VALUE = enum.auto() + CUSTOM = enum.auto() + +class CloneCustomFunc(t.Protocol): + def __call__(self, skel: "SkeletonInstance", src_skel: "SkeletonInstance", bone_name:str) -> t.Any: ... + +@dataclass +class CloneBehavior: + strategy: CloneStrategy + custom_func: callable = None + + def __post_init__(self): + if self.strategy == CloneStrategy.CUSTOM and self.custom_func is None: + raise ValueError("CloneStrategy is CUSTOM, but custom_func is not set") + elif self.strategy != CloneStrategy.CUSTOM and self.custom_func is not None: + raise ValueError("custom_func is set, but CloneStrategy is not CUSTOM") + # if not ((self.strategy == CloneStrategy.CUSTOM) ^ (self.custom_func is None)): + # raise ValueError("custom_func is not defined") + + + + + class BaseBone(object): """ The BaseBone class serves as the base class for all bone types in the ViUR framework. @@ -260,6 +289,7 @@ def __init__( unique: None | UniqueValue = None, vfunc: callable = None, # fixme: Rename this, see below. visible: bool = True, + clone_behavior: CloneBehavior | CloneStrategy | None = None, ): """ Initializes a new Bone. @@ -376,6 +406,19 @@ def __init__( self.compute = compute + if clone_behavior is None: # auto choose + if self.unique and self.readOnly: + self.clone_behavior = CloneBehavior(CloneStrategy.SET_DEFAULT) + else: + self.clone_behavior = CloneBehavior(CloneStrategy.COPY_VALUE) + # TODO: Any different setting for computed bones? + elif isinstance(clone_behavior, CloneStrategy): + self.clone_behavior = CloneBehavior(strategy=clone_behavior) + elif isinstance(clone_behavior, CloneBehavior): + self.clone_behavior = clone_behavior + else: + raise TypeError(f"'clone_behavior' must be an instance of Clone, but {clone_behavior=} was specified") + def __set_name__(self, owner: "Skeleton", name: str) -> None: self.skel_cls = owner self.name = name @@ -1252,6 +1295,29 @@ def postDeletedHandler(self, skel: 'viur.core.skeleton.SkeletonInstance', boneNa """ pass + def clone_value(self, skel: "SkeletonInstance", src_skel:"SkeletonInstance", bone_name: str) -> None: + logging.debug(f"{bone_name=} | {self.clone_behavior=}") + match self.clone_behavior.strategy: + case CloneStrategy.COPY_VALUE: + try: + skel.accessedValues[bone_name] = copy.deepcopy(src_skel.accessedValues[bone_name]) + except KeyError: + pass # bone_name is not in accessedValues, no need to clone_behavior + try: + skel.renderAccessedValues[bone_name] = copy.deepcopy(src_skel.renderAccessedValues[bone_name]) + except KeyError: + pass # bone_name is not in renderAccessedValues, no need to clone_behavior + case CloneStrategy.SET_NULL: + skel.accessedValues[bone_name] = None + case CloneStrategy.SET_DEFAULT: + skel.accessedValues[bone_name] = self.getDefaultValue(skel) + case CloneStrategy.SET_EMPTY: + skel.accessedValues[bone_name] = self.getEmptyValue() + case CloneStrategy.CUSTOM: + skel.accessedValues[bone_name] = self.clone_behavior.custom_func(skel, src_skel, bone_name) + case other: + raise NotImplementedError(other) + def refresh(self, skel: 'viur.core.skeleton.SkeletonInstance', boneName: str) -> None: """ Refresh all values we might have cached from other entities. @@ -1460,6 +1526,7 @@ def structure(self) -> dict: "languages": self.languages, "emptyvalue": self.getEmptyValue(), "indexed": self.indexed, + "clone_behavior": dataclasses.asdict(self.clone_behavior), } # Provide a defaultvalue, if it's not a function. diff --git a/src/viur/core/bones/sortindex.py b/src/viur/core/bones/sortindex.py index f85413d5e..efebb2737 100644 --- a/src/viur/core/bones/sortindex.py +++ b/src/viur/core/bones/sortindex.py @@ -1,5 +1,7 @@ import typing as t import time + +from viur.core.bones.base import CloneBehavior, CloneStrategy from viur.core.bones.numeric import NumericBone @@ -23,11 +25,13 @@ def __init__( defaultValue: int | float = lambda *args, **kwargs: time.time(), descr: str = "SortIndex", precision: int = 8, + clone_behavior = CloneBehavior(CloneStrategy.SET_DEFAULT), **kwargs ): super().__init__( defaultValue=defaultValue, descr=descr, precision=precision, + clone_behavior=clone_behavior, **kwargs ) diff --git a/src/viur/core/prototypes/list.py b/src/viur/core/prototypes/list.py index e1957f239..800c2c09f 100644 --- a/src/viur/core/prototypes/list.py +++ b/src/viur/core/prototypes/list.py @@ -350,7 +350,7 @@ def getDefaultListParams(self): @skey(allow_empty=True) def clone(self, key: db.Key | str | int, **kwargs): """ - Clone an existing entry, and render the entry, eventually with error notes on incorrect data. + CloneBehavior an existing entry, and render the entry, eventually with error notes on incorrect data. Data is taken by any other arguments in *kwargs*. The function performs several access control checks on the requested entity before it is added. @@ -376,7 +376,7 @@ def clone(self, key: db.Key | str | int, **kwargs): # Remember source skel and unset the key for clone operation! src_skel = skel - skel = skel.clone() + skel = skel.clone(apply_clone_strategy=True) skel["key"] = None # Check all required preconditions for clone diff --git a/src/viur/core/skeleton.py b/src/viur/core/skeleton.py index 9e33f6cdc..31d5fa1af 100644 --- a/src/viur/core/skeleton.py +++ b/src/viur/core/skeleton.py @@ -399,16 +399,23 @@ def __ior__(self, other: dict | SkeletonInstance | db.Entity) -> SkeletonInstanc raise ValueError("Unsupported Type") return self - def clone(self): + def clone(self, *, apply_clone_strategy:bool=False) -> t.Self: """ Clones a SkeletonInstance into a modificable, stand-alone instance. This will also allow to modify the underlying data model. """ res = SkeletonInstance(self.skeletonCls, bone_map=self.boneMap, clone=True) - res.accessedValues = copy.deepcopy(self.accessedValues) + if apply_clone_strategy: + for bone_name, bone_instance in self.items(): + bone_instance.clone_value(res, self, bone_name) + else: + res.accessedValues = copy.deepcopy(self.accessedValues) res.dbEntity = copy.deepcopy(self.dbEntity) res.is_cloned = True - res.renderAccessedValues = copy.deepcopy(self.renderAccessedValues) + if not apply_clone_strategy: + res.renderAccessedValues = copy.deepcopy(self.renderAccessedValues) + # else: Depending on the strategy the values are cloned in bone_instance.clone_value too + return res def ensure_is_cloned(self): From 1f57b033c606be7cada6296227b1bd2e05cd43f9 Mon Sep 17 00:00:00 2001 From: Sven Eberth Date: Fri, 31 Jan 2025 18:24:23 +0100 Subject: [PATCH 02/10] docs --- src/viur/core/bones/base.py | 47 +++++++++++++++++++++++--------- src/viur/core/prototypes/tree.py | 2 +- src/viur/core/skeleton.py | 6 ++-- 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/src/viur/core/bones/base.py b/src/viur/core/bones/base.py index 001acb681..886d63b8d 100644 --- a/src/viur/core/bones/base.py +++ b/src/viur/core/bones/base.py @@ -7,6 +7,7 @@ import copy import dataclasses +import enum import hashlib import inspect import logging @@ -14,10 +15,9 @@ from collections.abc import Iterable from dataclasses import dataclass, field from datetime import timedelta -from enum import Enum, auto -import enum +from enum import Enum -from viur.core import db, utils, current, i18n +from viur.core import current, db, i18n, utils from viur.core.config import conf if t.TYPE_CHECKING: @@ -207,30 +207,50 @@ class Compute: class CloneStrategy(enum.StrEnum): + """Strategy for selecting the value of a cloned skeleton""" + SET_NULL = enum.auto() + """Sets the cloned bone value to None.""" + SET_DEFAULT = enum.auto() + """Sets the cloned bone value to its defaultValue.""" + SET_EMPTY = enum.auto() + """Sets the cloned bone value to its emptyValue.""" + COPY_VALUE = enum.auto() + """Copies the bone value from the source skeleton.""" + CUSTOM = enum.auto() + """Uses a custom-defined logic for setting the cloned value. + Requires :attr:`CloneBehavior.custom_func` to be set. + """ + class CloneCustomFunc(t.Protocol): - def __call__(self, skel: "SkeletonInstance", src_skel: "SkeletonInstance", bone_name:str) -> t.Any: ... + """Type for a custom clone function assigned to :attr:`CloneBehavior.custom_func`""" + + def __call__(self, skel: "SkeletonInstance", src_skel: "SkeletonInstance", bone_name: str) -> t.Any: ... + @dataclass class CloneBehavior: + """Strategy configuration for selecting the value of a cloned skeleton""" + strategy: CloneStrategy - custom_func: callable = None + """The strategy used to select a value from a cloned skeleton""" + + custom_func: CloneCustomFunc = None + """custom-defined logic for setting the cloned value + Only required when :attr:`strategy` is set to :attr:`CloneStrategy.CUSTOM`. + """ def __post_init__(self): + """Validate this configuration.""" if self.strategy == CloneStrategy.CUSTOM and self.custom_func is None: raise ValueError("CloneStrategy is CUSTOM, but custom_func is not set") elif self.strategy != CloneStrategy.CUSTOM and self.custom_func is not None: raise ValueError("custom_func is set, but CloneStrategy is not CUSTOM") - # if not ((self.strategy == CloneStrategy.CUSTOM) ^ (self.custom_func is None)): - # raise ValueError("custom_func is not defined") - - - class BaseBone(object): @@ -1295,18 +1315,19 @@ def postDeletedHandler(self, skel: 'viur.core.skeleton.SkeletonInstance', boneNa """ pass - def clone_value(self, skel: "SkeletonInstance", src_skel:"SkeletonInstance", bone_name: str) -> None: + def clone_value(self, skel: "SkeletonInstance", src_skel: "SkeletonInstance", bone_name: str) -> None: + """Clone / Set the value for this bone depending on :attr:`clone_behavior`""" logging.debug(f"{bone_name=} | {self.clone_behavior=}") match self.clone_behavior.strategy: case CloneStrategy.COPY_VALUE: try: skel.accessedValues[bone_name] = copy.deepcopy(src_skel.accessedValues[bone_name]) except KeyError: - pass # bone_name is not in accessedValues, no need to clone_behavior + pass # bone_name is not in accessedValues, no need to clone_behavior try: skel.renderAccessedValues[bone_name] = copy.deepcopy(src_skel.renderAccessedValues[bone_name]) except KeyError: - pass # bone_name is not in renderAccessedValues, no need to clone_behavior + pass # bone_name is not in renderAccessedValues, no need to clone_behavior case CloneStrategy.SET_NULL: skel.accessedValues[bone_name] = None case CloneStrategy.SET_DEFAULT: diff --git a/src/viur/core/prototypes/tree.py b/src/viur/core/prototypes/tree.py index 61f2741bd..7615a47f2 100644 --- a/src/viur/core/prototypes/tree.py +++ b/src/viur/core/prototypes/tree.py @@ -727,7 +727,7 @@ def clone(self, skelType: SkelType, key: db.Key | str | int, **kwargs): # Remember source skel and unset the key for clone operation! src_skel = skel - skel = skel.clone() + skel = skel.clone(apply_clone_strategy=True) skel["key"] = None # make parententry required and writeable when provided diff --git a/src/viur/core/skeleton.py b/src/viur/core/skeleton.py index 31d5fa1af..9a3731833 100644 --- a/src/viur/core/skeleton.py +++ b/src/viur/core/skeleton.py @@ -10,9 +10,11 @@ import time import typing as t import warnings -from deprecated.sphinx import deprecated from functools import partial from itertools import chain + +from deprecated.sphinx import deprecated + from viur.core import conf, current, db, email, errors, translate, utils from viur.core.bones import ( BaseBone, @@ -399,7 +401,7 @@ def __ior__(self, other: dict | SkeletonInstance | db.Entity) -> SkeletonInstanc raise ValueError("Unsupported Type") return self - def clone(self, *, apply_clone_strategy:bool=False) -> t.Self: + def clone(self, *, apply_clone_strategy: bool = False) -> t.Self: """ Clones a SkeletonInstance into a modificable, stand-alone instance. This will also allow to modify the underlying data model. From f44ffa4b4ae25659e09178cbdcf654895af0ec25 Mon Sep 17 00:00:00 2001 From: Sven Eberth Date: Fri, 31 Jan 2025 18:30:21 +0100 Subject: [PATCH 03/10] fix indent --- src/viur/core/bones/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/viur/core/bones/base.py b/src/viur/core/bones/base.py index 886d63b8d..474c1b858 100644 --- a/src/viur/core/bones/base.py +++ b/src/viur/core/bones/base.py @@ -430,7 +430,7 @@ def __init__( if self.unique and self.readOnly: self.clone_behavior = CloneBehavior(CloneStrategy.SET_DEFAULT) else: - self.clone_behavior = CloneBehavior(CloneStrategy.COPY_VALUE) + self.clone_behavior = CloneBehavior(CloneStrategy.COPY_VALUE) # TODO: Any different setting for computed bones? elif isinstance(clone_behavior, CloneStrategy): self.clone_behavior = CloneBehavior(strategy=clone_behavior) From c5b3f99127e1b4912def2c497eb30776558d7167 Mon Sep 17 00:00:00 2001 From: Sven Eberth Date: Fri, 31 Jan 2025 18:31:21 +0100 Subject: [PATCH 04/10] comment --- src/viur/core/bones/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/viur/core/bones/base.py b/src/viur/core/bones/base.py index 474c1b858..0115d240f 100644 --- a/src/viur/core/bones/base.py +++ b/src/viur/core/bones/base.py @@ -1323,11 +1323,11 @@ def clone_value(self, skel: "SkeletonInstance", src_skel: "SkeletonInstance", bo try: skel.accessedValues[bone_name] = copy.deepcopy(src_skel.accessedValues[bone_name]) except KeyError: - pass # bone_name is not in accessedValues, no need to clone_behavior + pass # bone_name is not in accessedValues, cannot clone it try: skel.renderAccessedValues[bone_name] = copy.deepcopy(src_skel.renderAccessedValues[bone_name]) except KeyError: - pass # bone_name is not in renderAccessedValues, no need to clone_behavior + pass # bone_name is not in renderAccessedValues, cannot clone it case CloneStrategy.SET_NULL: skel.accessedValues[bone_name] = None case CloneStrategy.SET_DEFAULT: From cb1392279d731e0739e2b722eb9d987808f28749 Mon Sep 17 00:00:00 2001 From: Sven Eberth Date: Fri, 31 Jan 2025 18:38:48 +0100 Subject: [PATCH 05/10] docs --- src/viur/core/bones/base.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/viur/core/bones/base.py b/src/viur/core/bones/base.py index 0115d240f..27dbb041f 100644 --- a/src/viur/core/bones/base.py +++ b/src/viur/core/bones/base.py @@ -230,7 +230,9 @@ class CloneStrategy(enum.StrEnum): class CloneCustomFunc(t.Protocol): """Type for a custom clone function assigned to :attr:`CloneBehavior.custom_func`""" - def __call__(self, skel: "SkeletonInstance", src_skel: "SkeletonInstance", bone_name: str) -> t.Any: ... + def __call__(self, skel: "SkeletonInstance", src_skel: "SkeletonInstance", bone_name: str) -> t.Any: + """Return the value for the cloned bone""" + ... @dataclass @@ -1547,7 +1549,9 @@ def structure(self) -> dict: "languages": self.languages, "emptyvalue": self.getEmptyValue(), "indexed": self.indexed, - "clone_behavior": dataclasses.asdict(self.clone_behavior), + "clone_behavior": { + "strategy": self.clone_behavior.strategy, + }, } # Provide a defaultvalue, if it's not a function. From 4d1dcc36bce9f722ec479289f93d3f7ed307f802 Mon Sep 17 00:00:00 2001 From: Sven Eberth Date: Fri, 31 Jan 2025 18:39:30 +0100 Subject: [PATCH 06/10] pep8 --- src/viur/core/bones/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/viur/core/bones/base.py b/src/viur/core/bones/base.py index 27dbb041f..881619ef4 100644 --- a/src/viur/core/bones/base.py +++ b/src/viur/core/bones/base.py @@ -428,7 +428,7 @@ def __init__( self.compute = compute - if clone_behavior is None: # auto choose + if clone_behavior is None: # auto choose if self.unique and self.readOnly: self.clone_behavior = CloneBehavior(CloneStrategy.SET_DEFAULT) else: From c335f18a7e06db05b25c6c134f91db7a7651f967 Mon Sep 17 00:00:00 2001 From: Sven Eberth Date: Fri, 31 Jan 2025 18:40:41 +0100 Subject: [PATCH 07/10] pep8 --- src/viur/core/bones/sortindex.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/viur/core/bones/sortindex.py b/src/viur/core/bones/sortindex.py index efebb2737..6305a1ead 100644 --- a/src/viur/core/bones/sortindex.py +++ b/src/viur/core/bones/sortindex.py @@ -25,7 +25,7 @@ def __init__( defaultValue: int | float = lambda *args, **kwargs: time.time(), descr: str = "SortIndex", precision: int = 8, - clone_behavior = CloneBehavior(CloneStrategy.SET_DEFAULT), + clone_behavior=CloneBehavior(CloneStrategy.SET_DEFAULT), **kwargs ): super().__init__( From dadcf9d41713679e5379ba8510b0ff8a0d621b32 Mon Sep 17 00:00:00 2001 From: Sven Eberth Date: Fri, 31 Jan 2025 18:46:30 +0100 Subject: [PATCH 08/10] export --- src/viur/core/bones/__init__.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/viur/core/bones/__init__.py b/src/viur/core/bones/__init__.py index 52ce1ce81..e99e36e8e 100644 --- a/src/viur/core/bones/__init__.py +++ b/src/viur/core/bones/__init__.py @@ -1,5 +1,7 @@ from .base import ( BaseBone, + CloneBehavior, + CloneStrategy, Compute, ComputeInterval, ComputeMethod, @@ -9,8 +11,6 @@ ReadFromClientException, UniqueLockMethod, UniqueValue, - CloneBehavior, - CloneStrategy, ) from .boolean import BooleanBone from .captcha import CaptchaBone @@ -50,6 +50,8 @@ "BaseBone", "BooleanBone", "CaptchaBone", + "CloneBehavior", + "CloneStrategy", "ColorBone", "Compute", "ComputeInterval", @@ -84,8 +86,8 @@ "UidBone", "UniqueLockMethod", "UniqueValue", - "UserBone", "UriBone", + "UserBone", "translation_key_prefix_bonename", "translation_key_prefix_skeleton_bonename", ] From 7063f72f976bef1801b1b2c4a788e404c4968977 Mon Sep 17 00:00:00 2001 From: Jan Max Meyer Date: Wed, 5 Feb 2025 22:25:47 +0100 Subject: [PATCH 09/10] Update src/viur/core/prototypes/list.py --- src/viur/core/prototypes/list.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/viur/core/prototypes/list.py b/src/viur/core/prototypes/list.py index 800c2c09f..486783bfd 100644 --- a/src/viur/core/prototypes/list.py +++ b/src/viur/core/prototypes/list.py @@ -350,7 +350,7 @@ def getDefaultListParams(self): @skey(allow_empty=True) def clone(self, key: db.Key | str | int, **kwargs): """ - CloneBehavior an existing entry, and render the entry, eventually with error notes on incorrect data. + Clone an existing entry, and render the entry, eventually with error notes on incorrect data. Data is taken by any other arguments in *kwargs*. The function performs several access control checks on the requested entity before it is added. From 3a6f9426bb948705c431c263cbaa4836676cf1b5 Mon Sep 17 00:00:00 2001 From: Sven Eberth Date: Wed, 5 Feb 2025 22:53:54 +0100 Subject: [PATCH 10/10] remove debug --- src/viur/core/bones/base.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/viur/core/bones/base.py b/src/viur/core/bones/base.py index 881619ef4..8fadccb25 100644 --- a/src/viur/core/bones/base.py +++ b/src/viur/core/bones/base.py @@ -1319,7 +1319,6 @@ def postDeletedHandler(self, skel: 'viur.core.skeleton.SkeletonInstance', boneNa def clone_value(self, skel: "SkeletonInstance", src_skel: "SkeletonInstance", bone_name: str) -> None: """Clone / Set the value for this bone depending on :attr:`clone_behavior`""" - logging.debug(f"{bone_name=} | {self.clone_behavior=}") match self.clone_behavior.strategy: case CloneStrategy.COPY_VALUE: try: