Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/bones/numeric.py: 55%
187 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-25 15:02 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-25 15:02 +0000
1import logging
2import numbers
3import sys
4import typing as t
5import warnings
6import decimal as deci
8from viur.core import db, i18n
9from viur.core.bones.base import BaseBone, ReadFromClientError, ReadFromClientErrorSeverity
11if t.TYPE_CHECKING: 11 ↛ 12line 11 didn't jump to line 12 because the condition on line 11 was never true
12 from viur.core.skeleton import SkeletonInstance
14# Constants for Mne (MIN/MAX-never-exceed)
15MIN = -(sys.maxsize - 1)
16"""Constant for the minimum possible value in the system"""
17MAX = sys.maxsize
18"""Constant for the maximum possible value in the system
19Also limited by the datastore (8 bytes). Halved for positive and negative values.
20Which are around 2 ** (8 * 8 - 1) negative and 2 ** (8 * 8 - 1) positive values.
21"""
24class NumericBone(BaseBone):
25 """
26 A bone for storing numeric values, either integers or floats.
27 For floats, the precision can be specified in decimal-places.
28 """
29 type = "numeric"
31 def __init__(
32 self,
33 *,
34 min: int | float = MIN,
35 max: int | float = MAX,
36 precision: int = 0,
37 decimal: bool = False,
38 mode=None, # deprecated!
39 **kwargs
40 ):
41 """
42 Initializes a new NumericBone.
44 :param min: Minimum accepted value (including).
45 :param max: Maximum accepted value (including).
46 :param precision: How may decimal places should be saved. Zero casts the value to int instead of float.
47 :param decimal: If True, use deci.Decimal internally for exact arithmetic.
48 """
49 super().__init__(**kwargs)
51 if mode: 51 ↛ 52line 51 didn't jump to line 52 because the condition on line 51 was never true
52 logging.warning("mode-parameter to NumericBone is deprecated")
53 warnings.warn(
54 "mode-parameter to NumericBone is deprecated", DeprecationWarning
55 )
57 if not precision and mode == "float": 57 ↛ 58line 57 didn't jump to line 58 because the condition on line 57 was never true
58 logging.warning("mode='float' is deprecated, use precision=8 for same behavior")
59 warnings.warn(
60 "mode='float' is deprecated, use precision=8 for same behavior", DeprecationWarning
61 )
62 precision = 8
64 self.precision = precision
65 self.min = min
66 self.max = max
67 self.decimal = decimal
68 if decimal:
69 self._quantize_exp = deci.Decimal(10) ** -precision
71 def __setattr__(self, key, value):
72 """
73 Sets the attribute with the specified key to the given value.
75 This method is overridden in the NumericBone class to handle the special case of setting
76 the 'multiple' attribute to True while the bone is of type float. In this case, an
77 AssertionError is raised to prevent creating a multiple float bone.
79 :param key: The name of the attribute to be set.
80 :param value: The value to set the attribute to.
81 :raises AssertionError: If the 'multiple' attribute is set to True for a float bone.
82 """
83 if key in ("min", "max"):
84 if value < MIN or value > MAX:
85 raise ValueError(f"{key} can only be set to something between {MIN} and {MAX}")
87 return super().__setattr__(key, value)
89 def _convert_to_decimal(self, value) -> deci.Decimal | None:
90 """Convert *value* to a quantized Decimal. Uses str() roundtrip for floats.
91 Accepts comma as decimal separator in strings."""
92 if value is None:
93 return None
94 with deci.localcontext() as ctx:
95 # +20 as buffer for integer digits to avoid InvalidOperation on quantize
96 ctx.prec = self.precision + 20
97 if isinstance(value, deci.Decimal):
98 return value.quantize(self._quantize_exp)
99 if isinstance(value, str):
100 value = value.replace(",", ".", 1)
101 return deci.Decimal(value).quantize(self._quantize_exp)
102 if isinstance(value, (int, float)): 102 ↛ 104line 102 didn't jump to line 104
103 return deci.Decimal(str(value)).quantize(self._quantize_exp)
104 raise ValueError(f"Cannot convert {type(value).__name__} to Decimal")
106 def singleValueUnserialize(self, val):
107 if val is not None:
108 try:
109 if self.decimal: 109 ↛ 113line 109 didn't jump to line 113 because the condition on line 109 was always true
110 if isinstance(val, dict) and "decimal" in val:
111 val = val["decimal"]
112 return self._convert_to_decimal(val)
113 return self._convert_to_numeric(val)
114 except (ValueError, TypeError, deci.InvalidOperation):
115 return self.getDefaultValue(None) # FIXME: callable needs the skeleton instance
117 return val
119 def singleValueSerialize(self, value, skel: 'SkeletonInstance', name: str, parentIndexed: bool):
120 if self.decimal and value is not None:
121 return {
122 "val": self._convert_to_numeric(value),
123 "decimal": str(self._convert_to_decimal(value)),
124 }
125 return self.singleValueUnserialize(value) # same logic for unserialize here!
127 def isInvalid(self, value):
128 """
129 This method checks if a given value is invalid (e.g., NaN) for the NumericBone instance.
131 :param value: The value to be checked for validity.
132 :return: Returns a string "NaN not allowed" if the value is invalid (NaN), otherwise None.
133 """
134 if value != value: # NaN 134 ↛ 135line 134 didn't jump to line 135 because the condition on line 134 was never true
135 return "NaN not allowed"
137 def getEmptyValue(self):
138 """
139 This method returns an empty value depending on the precision attribute of the NumericBone
140 instance.
142 :return: Returns 0 for integers (when precision is 0) or 0.0 for floating-point numbers (when
143 precision is non-zero).
144 """
145 if self.decimal:
146 return deci.Decimal(0).quantize(self._quantize_exp)
147 if self.precision:
148 return 0.0
149 else:
150 return 0
152 def isEmpty(self, value: t.Any):
153 """
154 This method checks if a given raw value is considered empty for the NumericBone instance.
155 It attempts to convert the raw value into a valid numeric value (integer or floating-point
156 number), depending on the precision attribute of the NumericBone instance.
158 :param value: The raw value to be checked for emptiness.
159 :return: Returns True if the raw value is considered empty, otherwise False.
160 """
161 if value is None:
162 return True
163 if isinstance(value, str) and not value:
164 return True
165 try:
166 if self.decimal:
167 value = self._convert_to_decimal(value)
168 else:
169 value = self._convert_to_numeric(value)
170 except (ValueError, TypeError, deci.InvalidOperation):
171 return True
172 return value == self.getEmptyValue()
174 def singleValueFromClient(self, value, skel, bone_name, client_data):
175 if self.decimal:
176 if value is None or (isinstance(value, str) and not value.strip()): 176 ↛ 177line 176 didn't jump to line 177 because the condition on line 176 was never true
177 return self.getEmptyValue(), [
178 ReadFromClientError(ReadFromClientErrorSeverity.Empty, "No value entered")
179 ]
180 try:
181 if isinstance(value, str):
182 value = value.replace(",", ".", 1)
183 value = self._convert_to_decimal(value)
184 except (deci.InvalidOperation, ValueError, TypeError):
185 return self.getEmptyValue(), [
186 ReadFromClientError(ReadFromClientErrorSeverity.Invalid, "Invalid decimal value")
187 ]
189 else:
190 if not isinstance(value, (int, float)):
191 # Replace , with .
192 try:
193 value = str(value).replace(",", ".", 1)
194 except TypeError:
195 return self.getEmptyValue(), [ReadFromClientError(ReadFromClientErrorSeverity.Invalid)]
197 # Convert to float or int -- depending on the precision
198 # Since we convert direct to int if precision=0, a float value isn't valid
199 try:
200 value = float(value) if self.precision else int(value)
201 except ValueError:
202 return self.getEmptyValue(), [ReadFromClientError(ReadFromClientErrorSeverity.Invalid)]
204 if self.precision:
205 value = round(float(value), self.precision)
206 else:
207 value = int(value)
209 # Check the limits after rounding, as the rounding may change the value.
210 if not (self.min <= value <= self.max):
211 return self.getEmptyValue(), [
212 ReadFromClientError(
213 ReadFromClientErrorSeverity.Invalid,
214 i18n.translate(
215 "core.bones.error.minmax",
216 "Value not between {{min}} and {{max}}",
217 default_variables={
218 "min": self.min,
219 "max": self.max,
220 }
221 )
222 )
223 ]
225 if err := self.isInvalid(value): 225 ↛ 226line 225 didn't jump to line 226 because the condition on line 225 was never true
226 return self.getEmptyValue(), [ReadFromClientError(ReadFromClientErrorSeverity.Invalid, err)]
228 return value, None
230 def buildDBFilter(
231 self,
232 name: str,
233 skel: "SkeletonInstance",
234 dbFilter: db.Query,
235 rawFilter: dict,
236 prefix: t.Optional[str] = None
237 ) -> db.Query:
238 updatedFilter = {}
239 for parmKey, paramValue in rawFilter.items():
240 if parmKey.startswith(name):
241 if parmKey != name and not parmKey.startswith(name + "$"):
242 # It's just another bone which name start's with our's
243 continue
244 try:
245 if self.decimal:
246 paramValue = float(str(paramValue).replace(",", ".", 1))
247 elif not self.precision:
248 paramValue = int(paramValue)
249 else:
250 paramValue = float(paramValue)
251 except (ValueError, deci.InvalidOperation):
252 # The value we should filter by is garbage, cancel this query
253 logging.warning(f"Invalid filtering! Unparsable int/float supplied to NumericBone {name}")
254 raise RuntimeError()
255 updatedFilter[parmKey] = paramValue
257 if self.decimal:
258 # Values are stored as {"val": float, "decimal": str} — filter on the .val sub-property
259 prop = (prefix or "") + name + ".val"
260 for key, value in updatedFilter.items():
261 if key == name:
262 dbFilter.filter(prop + " =", value)
263 else:
264 op = key[len(name) + 1:] # the part after "$"
265 if op == "lt":
266 dbFilter.filter(prop + " <", value)
267 elif op == "le":
268 dbFilter.filter(prop + " <=", value)
269 elif op == "gt":
270 dbFilter.filter(prop + " >", value)
271 elif op == "ge":
272 dbFilter.filter(prop + " >=", value)
273 else:
274 dbFilter.filter(prop + " =", value)
275 return dbFilter
277 return super().buildDBFilter(name, skel, dbFilter, updatedFilter, prefix)
279 def getSearchTags(self, skel: "SkeletonInstance", name: str) -> set[str]:
280 """
281 This method generates a set of search tags based on the numeric values stored in the NumericBone
282 instance. It iterates through the bone values and adds the string representation of each value
283 to the result set.
285 :param skel: The skeleton instance containing the bone.
286 :param name: The name of the bone.
287 :return: Returns a set of search tags as strings.
288 """
289 result = set()
290 for idx, lang, value in self.iter_bone_value(skel, name):
291 if value is None:
292 continue
293 result.add(str(value))
294 return result
296 def _convert_to_numeric(self, value: t.Any) -> int | float:
297 """Convert a value to an int or float considering the precision.
299 If the value is not convertable an exception will be raised."""
300 if isinstance(value, db.Entity | dict) and "val" in value:
301 value = value["val"] # was a StringBone before
302 if isinstance(value, str):
303 value = value.replace(",", ".", 1)
304 if self.precision:
305 return round(float(value), self.precision)
306 else:
307 # First convert to float then to int to support "42.5" (str)
308 return int(float(value))
310 def refresh(self, skel: "SkeletonInstance", boneName: str) -> None:
311 """Ensure the value is numeric or None.
313 This ensures numeric values, for example after changing
314 a bone from StringBone to a NumericBone.
315 """
316 super().refresh(skel, boneName)
318 def refresh_single_value(value: t.Any) -> float | int | deci.Decimal:
319 if value == "":
320 return self.getEmptyValue()
321 elif self.decimal:
322 if not isinstance(value, (deci.Decimal, type(None))):
323 return self._convert_to_decimal(value)
324 elif not isinstance(value, (int, float, type(None))):
325 return self._convert_to_numeric(value)
326 return value
328 # TODO: duplicate code, this is the same iteration logic as in StringBone
329 new_value = {}
330 for _, lang, value in self.iter_bone_value(skel, boneName):
331 new_value.setdefault(lang, []).append(refresh_single_value(value))
333 if not self.multiple:
334 # take the first one
335 new_value = {lang: values[0] for lang, values in new_value.items() if values}
337 if self.languages:
338 skel[boneName] = new_value
339 elif not self.languages:
340 # just the value(s) with None language
341 skel[boneName] = new_value.get(None, [] if self.multiple else self.getEmptyValue())
343 def iter_bone_value(
344 self, skel: "SkeletonInstance", name: str
345 ) -> t.Iterator[tuple[t.Optional[int], t.Optional[str], t.Any]]:
346 value = skel[name]
347 if not value and isinstance(value, numbers.Number):
348 # 0 and 0.0 are falsy, but can be valid numeric values and should be kept
349 yield None, None, value
350 yield from super().iter_bone_value(skel, name)
352 def structure(self) -> dict:
353 return super().structure() | {
354 "min": self.min,
355 "max": self.max,
356 "precision": self.precision,
357 "decimal": self.decimal,
359 }