aboutsummaryrefslogtreecommitdiffstats
path: root/resources/libraries/python/MLRsearch/dataclass/dc_property.py
diff options
context:
space:
mode:
Diffstat (limited to 'resources/libraries/python/MLRsearch/dataclass/dc_property.py')
-rw-r--r--resources/libraries/python/MLRsearch/dataclass/dc_property.py173
1 files changed, 173 insertions, 0 deletions
diff --git a/resources/libraries/python/MLRsearch/dataclass/dc_property.py b/resources/libraries/python/MLRsearch/dataclass/dc_property.py
new file mode 100644
index 0000000000..7f3b49aeb8
--- /dev/null
+++ b/resources/libraries/python/MLRsearch/dataclass/dc_property.py
@@ -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)