feat(MLRsearch): MLRsearch v7
[csit.git] / resources / libraries / python / MLRsearch / dataclass / dc_property.py
1 # Copyright (c) 2023 Cisco and/or its affiliates.
2 # Licensed under the Apache License, Version 2.0 (the "License");
3 # you may not use this file except in compliance with the License.
4 # You may obtain a copy of the License at:
5 #
6 #     http://www.apache.org/licenses/LICENSE-2.0
7 #
8 # Unless required by applicable law or agreed to in writing, software
9 # distributed under the License is distributed on an "AS IS" BASIS,
10 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11 # See the License for the specific language governing permissions and
12 # limitations under the License.
13
14 """Module defining DataclassProperty class.
15
16 The main issue that needs support is dataclasses with properties
17 (including setters) and with (immutable) default values.
18
19 First, this explains how property ends up passed as default constructor value:
20 https://florimond.dev/en/posts/2018/10/
21 /reconciling-dataclasses-and-properties-in-python/
22 TL;DR: By the time __init__ is generated, original class variable (type hint)
23 is replaced by property (method definition).
24
25 Second, there are ways to deal with that:
26 https://stackoverflow.com/a/61480946
27 TL;DR: It relies on the underscored field being replaced by the value.
28
29 But that does not work for field which use default_factory (or no default)
30 (the underscored class field is deleted instead).
31 So another way is needed to cover those cases,
32 ideally without the need to define both original and underscored field.
33
34 This implementation relies on a fact that decorators are executed
35 when the class fields do yet exist, and decorated function
36 does know its name, so the decorator can get the value stored in
37 the class field, and store it as an additional attribute of the getter function.
38 Then for setter, the property contains the getter (as an unbound function),
39 so it can access the additional attribute to get the value.
40
41 This approach circumvents the precautions dataclasses take to prevent mishaps
42 when a single mutable object is shared between multiple instances.
43 So it is up to setters to create an appropriate copy of the default object
44 if the default value is mutable.
45
46 The default value cannot be MISSING nor Field nor DataclassProperty,
47 otherwise the intended logic breaks.
48 """
49
50 from __future__ import annotations
51
52 from dataclasses import Field, MISSING
53 from functools import wraps
54 from inspect import stack
55 from typing import Callable, Optional, TypeVar, Union
56
57
58 Self = TypeVar("Self")
59 """Type for the dataclass instances being created using properties."""
60 Value = TypeVar("Value")
61 """Type for the value the property (getter, setter) handles."""
62
63
64 def _calling_scope_variable(name: str) -> Value:
65     """Get a variable from a higher scope.
66
67     This feels dirty, but without this the syntactic sugar
68     would not be sweet enough.
69
70     The implementation is copied from https://stackoverflow.com/a/14694234
71     with the difference of raising RuntimeError (instead of returning None)
72     if no variable of that name is found in any of the scopes.
73
74     :param name: Name of the variable to access.
75     :type name: str
76     :returns: The value of the found variable.
77     :rtype: Value
78     :raises RuntimeError: If the variable is not found in any calling scope.
79     """
80     frame = stack()[1][0]
81     while name not in frame.f_locals:
82         frame = frame.f_back
83         if frame is None:
84             raise RuntimeError(f"Field {name} value not found.")
85     return frame.f_locals[name]
86
87
88 class DataclassProperty(property):
89     """Subclass of property, handles default values for dataclass fields.
90
91     If a dataclass field does not specify a default value (nor default_factory),
92     this is not needed, and in fact it will not work (so use built-in property).
93
94     This implementation seemlessly finds and inserts the default value
95     (can be mutable) into a new attribute of the getter function.
96     Before calling a setter function in init (recognized by type),
97     the default value is retrieved and passed transparently to the setter.
98     It is the responsibilty of the setter to appropriately clone the value,
99     in order to prevent multiple instances sharing the same mutable value.
100     """
101
102     def __init__(
103         self,
104         fget: Optional[Callable[[], Value]] = None,
105         fset: Optional[Callable[[Self, Value], None]] = None,
106         fdel: Optional[Callable[[], None]] = None,
107         doc: Optional[str] = None,
108     ):
109         """Find and store the default value, construct the property.
110
111         See this for how the superclass property works:
112         https://docs.python.org/3/howto/descriptor.html#properties
113
114         :param fget: Getter (unbound) function to use, if any.
115         :param fset: Setter (unbound) function to use, if any.
116         :param fdel: Deleter (unbound) function to use, if any.
117         :param doc: Docstring to display when examining the property.
118         :type fget: Optional[Callable[[Self], Value]]
119         :type fset: Optional[Callable[[Self, Value], None]]
120         :type fdel: Optional[Callable[[Self], None]]
121         :type doc: Optional[str]
122         """
123         variable_found = _calling_scope_variable(fget.__name__)
124         if not isinstance(variable_found, DataclassProperty):
125             if isinstance(variable_found, Field):
126                 if variable_found.default is not MISSING:
127                     fget.default_value = variable_found.default
128                 # Else do not store any default value.
129             else:
130                 fget.default_value = variable_found
131         # Else this is the second time init is called (when setting setter),
132         # in which case the default is already stored into fget.
133         super().__init__(fget=fget, fset=fset, fdel=fdel, doc=doc)
134
135     def setter(
136         self,
137         fset: Optional[Callable[[Self, Value], None]],
138     ) -> DataclassProperty:
139         """Return new instance with a wrapped setter function set.
140
141         If the argument is None, call superclass method.
142
143         The wrapped function recognizes when it is called in init
144         (by the fact the value argument is of type DataclassProperty)
145         and in that case it extracts the stored default and passes that
146         to the user-defined setter function.
147
148         :param fset: Setter function to wrap and apply.
149         :type fset: Optional[Callable[[Self, Value], None]]
150         :returns: New property instance with correct setter function set.
151         :rtype: DataclassProperty
152         """
153         if fset is None:
154             return super().setter(fset)
155
156         @wraps(fset)
157         def wrapped(sel_: Self, val: Union[Value, DataclassProperty]) -> None:
158             """Extract default from getter if needed, call the user setter.
159
160             The sel_ parameter is listed explicitly, to signify
161             this is an unbound function, not a bounded method yet.
162
163             :param sel_: Instance of dataclass (not of DataclassProperty)
164                 to set the value on.
165             :param val: Set this value, or the default value stored there.
166             :type sel_: Self
167             :type val: Union[Value, DataclassProperty]
168             """
169             if isinstance(val, DataclassProperty):
170                 val = val.fget.default_value
171             fset(sel_, val)
172
173         return super().setter(wrapped)