diff options
Diffstat (limited to 'resources/libraries/python/MLRsearch/dataclass/dc_property.py')
-rw-r--r-- | resources/libraries/python/MLRsearch/dataclass/dc_property.py | 173 |
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) |