Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/bones/numeric.py: 50%
129 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-03 12:27 +0000
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-03 12:27 +0000
1import logging
2import numbers
3import sys
4import typing as t
5import warnings
7from viur.core import db
8from viur.core.bones.base import BaseBone, ReadFromClientError, ReadFromClientErrorSeverity
10if t.TYPE_CHECKING: 10 ↛ 11line 10 didn't jump to line 11 because the condition on line 10 was never true
11 from viur.core.skeleton import SkeletonInstance
13# Constants for Mne (MIN/MAX-never-exceed)
14MIN = -(sys.maxsize - 1)
15"""Constant for the minimum possible value in the system"""
16MAX = sys.maxsize
17"""Constant for the maximum possible value in the system
18Also limited by the datastore (8 bytes). Halved for positive and negative values.
19Which are around 2 ** (8 * 8 - 1) negative and 2 ** (8 * 8 - 1) positive values.
20"""
23class NumericBone(BaseBone):
24 """
25 A bone for storing numeric values, either integers or floats.
26 For floats, the precision can be specified in decimal-places.
27 """
28 type = "numeric"
30 def __init__(
31 self,
32 *,
33 min: int | float = MIN,
34 max: int | float = MAX,
35 precision: int = 0,
36 mode=None, # deprecated!
37 **kwargs
38 ):
39 """
40 Initializes a new NumericBone.
42 :param min: Minimum accepted value (including).
43 :param max: Maximum accepted value (including).
44 :param precision: How may decimal places should be saved. Zero casts the value to int instead of float.
45 """
46 super().__init__(**kwargs)
48 if mode: 48 ↛ 49line 48 didn't jump to line 49 because the condition on line 48 was never true
49 logging.warning("mode-parameter to NumericBone is deprecated")
50 warnings.warn(
51 "mode-parameter to NumericBone is deprecated", DeprecationWarning
52 )
54 if not precision and mode == "float": 54 ↛ 55line 54 didn't jump to line 55 because the condition on line 54 was never true
55 logging.warning("mode='float' is deprecated, use precision=8 for same behavior")
56 warnings.warn(
57 "mode='float' is deprecated, use precision=8 for same behavior", DeprecationWarning
58 )
59 precision = 8
61 self.precision = precision
62 self.min = min
63 self.max = max
65 def __setattr__(self, key, value):
66 """
67 Sets the attribute with the specified key to the given value.
69 This method is overridden in the NumericBone class to handle the special case of setting
70 the 'multiple' attribute to True while the bone is of type float. In this case, an
71 AssertionError is raised to prevent creating a multiple float bone.
73 :param key: The name of the attribute to be set.
74 :param value: The value to set the attribute to.
75 :raises AssertionError: If the 'multiple' attribute is set to True for a float bone.
76 """
77 if key in ("min", "max"):
78 if value < MIN or value > MAX: 78 ↛ 79line 78 didn't jump to line 79 because the condition on line 78 was never true
79 raise ValueError(f"{key} can only be set to something between {MIN} and {MAX}")
81 return super().__setattr__(key, value)
83 def singleValueUnserialize(self, val):
84 if val is not None:
85 try:
86 return self._convert_to_numeric(val)
87 except (ValueError, TypeError):
88 return self.getDefaultValue(None) # FIXME: callable needs the skeleton instance
90 return val
92 def singleValueSerialize(self, value, skel: 'SkeletonInstance', name: str, parentIndexed: bool):
93 return self.singleValueUnserialize(value) # same logic for unserialize here!
95 def isInvalid(self, value):
96 """
97 This method checks if a given value is invalid (e.g., NaN) for the NumericBone instance.
99 :param value: The value to be checked for validity.
100 :return: Returns a string "NaN not allowed" if the value is invalid (NaN), otherwise None.
101 """
102 if value != value: # NaN 102 ↛ 103line 102 didn't jump to line 103 because the condition on line 102 was never true
103 return "NaN not allowed"
105 def getEmptyValue(self):
106 """
107 This method returns an empty value depending on the precision attribute of the NumericBone
108 instance.
110 :return: Returns 0 for integers (when precision is 0) or 0.0 for floating-point numbers (when
111 precision is non-zero).
112 """
113 if self.precision:
114 return 0.0
115 else:
116 return 0
118 def isEmpty(self, value: t.Any):
119 """
120 This method checks if a given raw value is considered empty for the NumericBone instance.
121 It attempts to convert the raw value into a valid numeric value (integer or floating-point
122 number), depending on the precision attribute of the NumericBone instance.
124 :param value: The raw value to be checked for emptiness.
125 :return: Returns True if the raw value is considered empty, otherwise False.
126 """
127 if isinstance(value, str) and not value:
128 return True
129 try:
130 value = self._convert_to_numeric(value)
131 except (ValueError, TypeError):
132 return True
133 return value == self.getEmptyValue()
135 def singleValueFromClient(self, value, skel, bone_name, client_data):
136 if not isinstance(value, (int, float)):
137 # Replace , with .
138 try:
139 value = str(value).replace(",", ".", 1)
140 except TypeError:
141 return self.getEmptyValue(), [ReadFromClientError(
142 ReadFromClientErrorSeverity.Invalid, "Cannot handle this value"
143 )]
144 # Convert to float or int -- depending on the precision
145 # Since we convert direct to int if precision=0, a float value isn't valid
146 try:
147 value = float(value) if self.precision else int(value)
148 except ValueError:
149 return self.getEmptyValue(), [ReadFromClientError(
150 ReadFromClientErrorSeverity.Invalid,
151 f'Not a valid {"float" if self.precision else "int"} value'
152 )]
154 assert isinstance(value, (int, float))
155 if self.precision:
156 value = round(float(value), self.precision)
157 else:
158 value = int(value)
160 # Check the limits after rounding, as the rounding may change the value.
161 if not (self.min <= value <= self.max):
162 return self.getEmptyValue(), [ReadFromClientError(
163 ReadFromClientErrorSeverity.Invalid, f"Value not between {self.min} and {self.max}"
164 )]
166 if err := self.isInvalid(value): 166 ↛ 167line 166 didn't jump to line 167 because the condition on line 166 was never true
167 return self.getEmptyValue(), [ReadFromClientError(ReadFromClientErrorSeverity.Invalid, err)]
169 return value, None
171 def buildDBFilter(
172 self,
173 name: str,
174 skel: "SkeletonInstance",
175 dbFilter: db.Query,
176 rawFilter: dict,
177 prefix: t.Optional[str] = None
178 ) -> db.Query:
179 updatedFilter = {}
181 for parmKey, paramValue in rawFilter.items():
182 if parmKey.startswith(name):
183 if parmKey != name and not parmKey.startswith(name + "$"):
184 # It's just another bone which name start's with our's
185 continue
186 try:
187 if not self.precision:
188 paramValue = int(paramValue)
189 else:
190 paramValue = float(paramValue)
191 except ValueError:
192 # The value we should filter by is garbage, cancel this query
193 logging.warning(f"Invalid filtering! Unparsable int/float supplied to NumericBone {name}")
194 raise RuntimeError()
195 updatedFilter[parmKey] = paramValue
197 return super().buildDBFilter(name, skel, dbFilter, updatedFilter, prefix)
199 def getSearchTags(self, skel: "SkeletonInstance", name: str) -> set[str]:
200 """
201 This method generates a set of search tags based on the numeric values stored in the NumericBone
202 instance. It iterates through the bone values and adds the string representation of each value
203 to the result set.
205 :param skel: The skeleton instance containing the bone.
206 :param name: The name of the bone.
207 :return: Returns a set of search tags as strings.
208 """
209 result = set()
210 for idx, lang, value in self.iter_bone_value(skel, name):
211 if value is None:
212 continue
213 result.add(str(value))
214 return result
216 def _convert_to_numeric(self, value: t.Any) -> int | float:
217 """Convert a value to an int or float considering the precision.
219 If the value is not convertable an exception will be raised."""
220 if isinstance(value, db.Entity | dict) and "val" in value:
221 value = value["val"] # was a StringBone before
222 if isinstance(value, str):
223 value = value.replace(",", ".", 1)
224 if self.precision:
225 return round(float(value), self.precision)
226 else:
227 # First convert to float then to int to support "42.5" (str)
228 return int(float(value))
230 def refresh(self, skel: "SkeletonInstance", boneName: str) -> None:
231 """Ensure the value is numeric or None.
233 This ensures numeric values, for example after changing
234 a bone from StringBone to a NumericBone.
235 """
236 super().refresh(skel, boneName)
238 def refresh_single_value(value: t.Any) -> float | int:
239 if value == "":
240 return self.getEmptyValue()
241 elif not isinstance(value, (int, float, type(None))):
242 return self._convert_to_numeric(value)
243 return value
245 # TODO: duplicate code, this is the same iteration logic as in StringBone
246 new_value = {}
247 for _, lang, value in self.iter_bone_value(skel, boneName):
248 new_value.setdefault(lang, []).append(refresh_single_value(value))
250 if not self.multiple:
251 # take the first one
252 new_value = {lang: values[0] for lang, values in new_value.items() if values}
254 if self.languages:
255 skel[boneName] = new_value
256 elif not self.languages:
257 # just the value(s) with None language
258 skel[boneName] = new_value.get(None, [] if self.multiple else self.getEmptyValue())
260 def iter_bone_value(
261 self, skel: "SkeletonInstance", name: str
262 ) -> t.Iterator[tuple[t.Optional[int], t.Optional[str], t.Any]]:
263 value = skel[name]
264 if not value and isinstance(value, numbers.Number):
265 # 0 and 0.0 are falsy, but can be valid numeric values and should be kept
266 yield None, None, value
267 yield from super().iter_bone_value(skel, name)
269 def structure(self) -> dict:
270 return super().structure() | {
271 "min": self.min,
272 "max": self.max,
273 "precision": self.precision,
274 }