feat(MLRsearch): MLRsearch v7
[csit.git] / resources / libraries / python / MLRsearch / dataclass / dc_property.py
diff --git a/resources/libraries/python/MLRsearch/dataclass/dc_property.py b/resources/libraries/python/MLRsearch/dataclass/dc_property.py
new file mode 100644 (file)
index 0000000..7f3b49a
--- /dev/null
@@ -0,0 +1,173 @@
+# Copyright (c) 2023 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Module defining DataclassProperty class.
+
+The main issue that needs support is dataclasses with properties
+(including setters) and with (immutable) default values.
+
+First, this explains how property ends up passed as default constructor value:
+https://florimond.dev/en/posts/2018/10/
+/reconciling-dataclasses-and-properties-in-python/
+TL;DR: By the time __init__ is generated, original class variable (type hint)
+is replaced by property (method definition).
+
+Second, there are ways to deal with that:
+https://stackoverflow.com/a/61480946
+TL;DR: It relies on the underscored field being replaced by the value.
+
+But that does not work for field which use default_factory (or no default)
+(the underscored class field is deleted instead).
+So another way is needed to cover those cases,
+ideally without the need to define both original and underscored field.
+
+This implementation relies on a fact that decorators are executed
+when the class fields do yet exist, and decorated function
+does know its name, so the decorator can get the value stored in
+the class field, and store it as an additional attribute of the getter function.
+Then for setter, the property contains the getter (as an unbound function),
+so it can access the additional attribute to get the value.
+
+This approach circumvents the precautions dataclasses take to prevent mishaps
+when a single mutable object is shared between multiple instances.
+So it is up to setters to create an appropriate copy of the default object
+if the default value is mutable.
+
+The default value cannot be MISSING nor Field nor DataclassProperty,
+otherwise the intended logic breaks.
+"""
+
+from __future__ import annotations
+
+from dataclasses import Field, MISSING
+from functools import wraps
+from inspect import stack
+from typing import Callable, Optional, TypeVar, Union
+
+
+Self = TypeVar("Self")
+"""Type for the dataclass instances being created using properties."""
+Value = TypeVar("Value")
+"""Type for the value the property (getter, setter) handles."""
+
+
+def _calling_scope_variable(name: str) -> Value:
+    """Get a variable from a higher scope.
+
+    This feels dirty, but without this the syntactic sugar
+    would not be sweet enough.
+
+    The implementation is copied from https://stackoverflow.com/a/14694234
+    with the difference of raising RuntimeError (instead of returning None)
+    if no variable of that name is found in any of the scopes.
+
+    :param name: Name of the variable to access.
+    :type name: str
+    :returns: The value of the found variable.
+    :rtype: Value
+    :raises RuntimeError: If the variable is not found in any calling scope.
+    """
+    frame = stack()[1][0]
+    while name not in frame.f_locals:
+        frame = frame.f_back
+        if frame is None:
+            raise RuntimeError(f"Field {name} value not found.")
+    return frame.f_locals[name]
+
+
+class DataclassProperty(property):
+    """Subclass of property, handles default values for dataclass fields.
+
+    If a dataclass field does not specify a default value (nor default_factory),
+    this is not needed, and in fact it will not work (so use built-in property).
+
+    This implementation seemlessly finds and inserts the default value
+    (can be mutable) into a new attribute of the getter function.
+    Before calling a setter function in init (recognized by type),
+    the default value is retrieved and passed transparently to the setter.
+    It is the responsibilty of the setter to appropriately clone the value,
+    in order to prevent multiple instances sharing the same mutable value.
+    """
+
+    def __init__(
+        self,
+        fget: Optional[Callable[[], Value]] = None,
+        fset: Optional[Callable[[Self, Value], None]] = None,
+        fdel: Optional[Callable[[], None]] = None,
+        doc: Optional[str] = None,
+    ):
+        """Find and store the default value, construct the property.
+
+        See this for how the superclass property works:
+        https://docs.python.org/3/howto/descriptor.html#properties
+
+        :param fget: Getter (unbound) function to use, if any.
+        :param fset: Setter (unbound) function to use, if any.
+        :param fdel: Deleter (unbound) function to use, if any.
+        :param doc: Docstring to display when examining the property.
+        :type fget: Optional[Callable[[Self], Value]]
+        :type fset: Optional[Callable[[Self, Value], None]]
+        :type fdel: Optional[Callable[[Self], None]]
+        :type doc: Optional[str]
+        """
+        variable_found = _calling_scope_variable(fget.__name__)
+        if not isinstance(variable_found, DataclassProperty):
+            if isinstance(variable_found, Field):
+                if variable_found.default is not MISSING:
+                    fget.default_value = variable_found.default
+                # Else do not store any default value.
+            else:
+                fget.default_value = variable_found
+        # Else this is the second time init is called (when setting setter),
+        # in which case the default is already stored into fget.
+        super().__init__(fget=fget, fset=fset, fdel=fdel, doc=doc)
+
+    def setter(
+        self,
+        fset: Optional[Callable[[Self, Value], None]],
+    ) -> DataclassProperty:
+        """Return new instance with a wrapped setter function set.
+
+        If the argument is None, call superclass method.
+
+        The wrapped function recognizes when it is called in init
+        (by the fact the value argument is of type DataclassProperty)
+        and in that case it extracts the stored default and passes that
+        to the user-defined setter function.
+
+        :param fset: Setter function to wrap and apply.
+        :type fset: Optional[Callable[[Self, Value], None]]
+        :returns: New property instance with correct setter function set.
+        :rtype: DataclassProperty
+        """
+        if fset is None:
+            return super().setter(fset)
+
+        @wraps(fset)
+        def wrapped(sel_: Self, val: Union[Value, DataclassProperty]) -> None:
+            """Extract default from getter if needed, call the user setter.
+
+            The sel_ parameter is listed explicitly, to signify
+            this is an unbound function, not a bounded method yet.
+
+            :param sel_: Instance of dataclass (not of DataclassProperty)
+                to set the value on.
+            :param val: Set this value, or the default value stored there.
+            :type sel_: Self
+            :type val: Union[Value, DataclassProperty]
+            """
+            if isinstance(val, DataclassProperty):
+                val = val.fget.default_value
+            fset(sel_, val)
+
+        return super().setter(wrapped)