Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/bones/numeric.py: 57%
194 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-27 10:50 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-27 10:50 +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"""
23ROUND = frozenset({
24 deci.ROUND_CEILING,
25 deci.ROUND_DOWN,
26 deci.ROUND_FLOOR,
27 deci.ROUND_HALF_DOWN,
28 deci.ROUND_HALF_EVEN,
29 deci.ROUND_HALF_UP,
30 deci.ROUND_UP,
31 deci.ROUND_05UP,
32})
33"""The rounding modes accepted by NumericBone(round=...), as defined by the decimal module."""
36class NumericBone(BaseBone):
37 """
38 A bone for storing numeric values, either integers or floats.
39 For floats, the precision can be specified in decimal-places.
40 """
41 type = "numeric"
43 def __init__(
44 self,
45 *,
46 min: int | float = MIN,
47 max: int | float = MAX,
48 precision: int = 0,
49 decimal: bool = False,
50 round: str | None = None,
51 mode=None, # deprecated!
52 **kwargs
53 ):
54 """
55 Initializes a new NumericBone.
57 :param min: Minimum accepted value (including).
58 :param max: Maximum accepted value (including).
59 :param precision: How may decimal places should be saved. Zero casts the value to int instead of float.
60 :param decimal: If True, use deci.Decimal internally for exact arithmetic.
61 :param round: Rounding mode applied when quantizing to `precision`, one of
62 :data:`ROUND`. Only valid together with `decimal=True`. Defaults to None,
63 which leaves the choice to the active decimal context (`ROUND_HALF_EVEN` unless the
64 application changed it). Commercial rounding - the norm for invoices in many
65 jurisdictions - is `decimal.ROUND_HALF_UP`.
66 """
67 super().__init__(**kwargs)
69 if mode: 69 ↛ 70line 69 didn't jump to line 70 because the condition on line 69 was never true
70 logging.warning("mode-parameter to NumericBone is deprecated")
71 warnings.warn(
72 "mode-parameter to NumericBone is deprecated", DeprecationWarning
73 )
75 if not precision and mode == "float": 75 ↛ 76line 75 didn't jump to line 76 because the condition on line 75 was never true
76 logging.warning("mode='float' is deprecated, use precision=8 for same behavior")
77 warnings.warn(
78 "mode='float' is deprecated, use precision=8 for same behavior", DeprecationWarning
79 )
80 precision = 8
82 self.precision = precision
83 self.min = min
84 self.max = max
85 self.decimal = decimal
86 self.round = round
87 if decimal:
88 self._quantize_exp = deci.Decimal(10) ** -precision
89 elif round is not None:
90 # Silently ignoring it would be worse: the caller asked for a specific rounding and
91 # would get the float mode's round(), which is neither configurable nor exact.
92 raise ValueError("round is only supported in combination with decimal=True")
94 def __setattr__(self, key, value):
95 """
96 Sets the attribute with the specified key to the given value.
98 This method is overridden in the NumericBone class to handle the special case of setting
99 the 'multiple' attribute to True while the bone is of type float. In this case, an
100 AssertionError is raised to prevent creating a multiple float bone.
102 :param key: The name of the attribute to be set.
103 :param value: The value to set the attribute to.
104 :raises AssertionError: If the 'multiple' attribute is set to True for a float bone.
105 """
106 if key in ("min", "max"):
107 if value < MIN or value > MAX:
108 raise ValueError(f"{key} can only be set to something between {MIN} and {MAX}")
110 elif key == "round" and value is not None and value not in ROUND:
111 raise ValueError(f"round must be one of {sorted(ROUND)}, not {value!r}")
113 return super().__setattr__(key, value)
115 def _convert_to_decimal(self, value) -> deci.Decimal | None:
116 """Convert *value* to a quantized Decimal. Uses str() roundtrip for floats.
117 Accepts comma as decimal separator in strings."""
118 if value is None:
119 return None
120 with deci.localcontext() as ctx:
121 # +20 as buffer for integer digits to avoid InvalidOperation on quantize
122 ctx.prec = self.precision + 20
123 # rounding=None is the decimal module's own "use the context" - passing it through
124 # unconditionally keeps the previous behaviour for every bone that does not set it.
125 if isinstance(value, deci.Decimal):
126 return value.quantize(self._quantize_exp, rounding=self.round)
127 if isinstance(value, str):
128 value = value.replace(",", ".", 1)
129 return deci.Decimal(value).quantize(self._quantize_exp, rounding=self.round)
130 if isinstance(value, (int, float)): 130 ↛ 132line 130 didn't jump to line 132
131 return deci.Decimal(str(value)).quantize(self._quantize_exp, rounding=self.round)
132 raise ValueError(f"Cannot convert {type(value).__name__} to Decimal")
134 def singleValueUnserialize(self, val):
135 if val is not None:
136 try:
137 if self.decimal:
138 if isinstance(val, dict) and "decimal" in val:
139 val = val["decimal"]
140 return self._convert_to_decimal(val)
141 return self._convert_to_numeric(val)
142 except (ValueError, TypeError, deci.InvalidOperation):
143 return self.getDefaultValue(None) # FIXME: callable needs the skeleton instance
145 return val
147 def singleValueSerialize(self, value, skel: 'SkeletonInstance', name: str, parentIndexed: bool):
148 if self.decimal and value is not None:
149 return {
150 "val": self._convert_to_numeric(value),
151 "decimal": str(self._convert_to_decimal(value)),
152 }
153 return self.singleValueUnserialize(value) # same logic for unserialize here!
155 def isInvalid(self, value):
156 """
157 This method checks if a given value is invalid (e.g., NaN) for the NumericBone instance.
159 :param value: The value to be checked for validity.
160 :return: Returns a string "NaN not allowed" if the value is invalid (NaN), otherwise None.
161 """
162 if value != value: # NaN 162 ↛ 163line 162 didn't jump to line 163 because the condition on line 162 was never true
163 return "NaN not allowed"
165 def getEmptyValue(self):
166 """
167 This method returns an empty value depending on the precision attribute of the NumericBone
168 instance.
170 :return: Returns 0 for integers (when precision is 0) or 0.0 for floating-point numbers (when
171 precision is non-zero).
172 """
173 if self.decimal:
174 return deci.Decimal(0).quantize(self._quantize_exp, rounding=self.round)
175 if self.precision:
176 return 0.0
177 else:
178 return 0
180 def isEmpty(self, value: t.Any):
181 """
182 This method checks if a given raw value is considered empty for the NumericBone instance.
183 It attempts to convert the raw value into a valid numeric value (integer or floating-point
184 number), depending on the precision attribute of the NumericBone instance.
186 :param value: The raw value to be checked for emptiness.
187 :return: Returns True if the raw value is considered empty, otherwise False.
188 """
189 if value is None:
190 return True
191 if isinstance(value, str) and not value:
192 return True
193 try:
194 if self.decimal:
195 value = self._convert_to_decimal(value)
196 else:
197 value = self._convert_to_numeric(value)
198 except (ValueError, TypeError, deci.InvalidOperation):
199 return True
200 return value == self.getEmptyValue()
202 def singleValueFromClient(self, value, skel, bone_name, client_data):
203 if self.decimal:
204 if value is None or (isinstance(value, str) and not value.strip()): 204 ↛ 205line 204 didn't jump to line 205 because the condition on line 204 was never true
205 return self.getEmptyValue(), [
206 ReadFromClientError(ReadFromClientErrorSeverity.Empty, "No value entered")
207 ]
208 try:
209 if isinstance(value, str):
210 value = value.replace(",", ".", 1)
211 value = self._convert_to_decimal(value)
212 except (deci.InvalidOperation, ValueError, TypeError):
213 return self.getEmptyValue(), [
214 ReadFromClientError(ReadFromClientErrorSeverity.Invalid, "Invalid decimal value")
215 ]
217 else:
218 if not isinstance(value, (int, float)):
219 # Replace , with .
220 try:
221 value = str(value).replace(",", ".", 1)
222 except TypeError:
223 return self.getEmptyValue(), [ReadFromClientError(ReadFromClientErrorSeverity.Invalid)]
225 # Convert to float or int -- depending on the precision
226 # Since we convert direct to int if precision=0, a float value isn't valid
227 try:
228 value = float(value) if self.precision else int(value)
229 except ValueError:
230 return self.getEmptyValue(), [ReadFromClientError(ReadFromClientErrorSeverity.Invalid)]
232 if self.precision:
233 value = round(float(value), self.precision)
234 else:
235 value = int(value)
237 # Check the limits after rounding, as the rounding may change the value.
238 if not (self.min <= value <= self.max):
239 return self.getEmptyValue(), [
240 ReadFromClientError(
241 ReadFromClientErrorSeverity.Invalid,
242 i18n.translate(
243 "core.bones.error.minmax",
244 "Value not between {{min}} and {{max}}",
245 default_variables={
246 "min": self.min,
247 "max": self.max,
248 }
249 )
250 )
251 ]
253 if err := self.isInvalid(value): 253 ↛ 254line 253 didn't jump to line 254 because the condition on line 253 was never true
254 return self.getEmptyValue(), [ReadFromClientError(ReadFromClientErrorSeverity.Invalid, err)]
256 return value, None
258 def buildDBFilter(
259 self,
260 name: str,
261 skel: "SkeletonInstance",
262 dbFilter: db.Query,
263 rawFilter: dict,
264 prefix: t.Optional[str] = None
265 ) -> db.Query:
266 updatedFilter = {}
267 for parmKey, paramValue in rawFilter.items():
268 if parmKey.startswith(name):
269 if parmKey != name and not parmKey.startswith(name + "$"):
270 # It's just another bone which name start's with our's
271 continue
272 try:
273 if self.decimal:
274 paramValue = float(str(paramValue).replace(",", ".", 1))
275 elif not self.precision:
276 paramValue = int(paramValue)
277 else:
278 paramValue = float(paramValue)
279 except (ValueError, deci.InvalidOperation):
280 # The value we should filter by is garbage, cancel this query
281 logging.warning(f"Invalid filtering! Unparsable int/float supplied to NumericBone {name}")
282 raise RuntimeError()
283 updatedFilter[parmKey] = paramValue
285 if self.decimal:
286 # Values are stored as {"val": float, "decimal": str} — filter on the .val sub-property
287 prop = (prefix or "") + name + ".val"
288 for key, value in updatedFilter.items():
289 if key == name:
290 dbFilter.filter(prop + " =", value)
291 else:
292 op = key[len(name) + 1:] # the part after "$"
293 if op == "lt":
294 dbFilter.filter(prop + " <", value)
295 elif op == "le":
296 dbFilter.filter(prop + " <=", value)
297 elif op == "gt":
298 dbFilter.filter(prop + " >", value)
299 elif op == "ge":
300 dbFilter.filter(prop + " >=", value)
301 else:
302 dbFilter.filter(prop + " =", value)
303 return dbFilter
305 return super().buildDBFilter(name, skel, dbFilter, updatedFilter, prefix)
307 def getSearchTags(self, skel: "SkeletonInstance", name: str) -> set[str]:
308 """
309 This method generates a set of search tags based on the numeric values stored in the NumericBone
310 instance. It iterates through the bone values and adds the string representation of each value
311 to the result set.
313 :param skel: The skeleton instance containing the bone.
314 :param name: The name of the bone.
315 :return: Returns a set of search tags as strings.
316 """
317 result = set()
318 for idx, lang, value in self.iter_bone_value(skel, name):
319 if value is None:
320 continue
321 result.add(str(value))
322 return result
324 def _convert_to_numeric(self, value: t.Any) -> int | float:
325 """Convert a value to an int or float considering the precision.
327 If the value is not convertable an exception will be raised."""
328 if isinstance(value, db.Entity | dict) and "val" in value:
329 value = value["val"] # was a StringBone before
330 if isinstance(value, str):
331 value = value.replace(",", ".", 1)
332 if self.precision:
333 return round(float(value), self.precision)
334 else:
335 # First convert to float then to int to support "42.5" (str)
336 return int(float(value))
338 def refresh(self, skel: "SkeletonInstance", boneName: str) -> None:
339 """Ensure the value is numeric or None.
341 This ensures numeric values, for example after changing
342 a bone from StringBone to a NumericBone.
343 """
344 super().refresh(skel, boneName)
346 def refresh_single_value(value: t.Any) -> float | int | deci.Decimal:
347 if value == "":
348 return self.getEmptyValue()
349 elif self.decimal:
350 if not isinstance(value, (deci.Decimal, type(None))):
351 return self._convert_to_decimal(value)
352 elif not isinstance(value, (int, float, type(None))):
353 return self._convert_to_numeric(value)
354 return value
356 # TODO: duplicate code, this is the same iteration logic as in StringBone
357 new_value = {}
358 for _, lang, value in self.iter_bone_value(skel, boneName):
359 new_value.setdefault(lang, []).append(refresh_single_value(value))
361 if not self.multiple:
362 # take the first one
363 new_value = {lang: values[0] for lang, values in new_value.items() if values}
365 if self.languages:
366 skel[boneName] = new_value
367 elif not self.languages:
368 # just the value(s) with None language
369 skel[boneName] = new_value.get(None, [] if self.multiple else self.getEmptyValue())
371 def iter_bone_value(
372 self, skel: "SkeletonInstance", name: str
373 ) -> t.Iterator[tuple[t.Optional[int], t.Optional[str], t.Any]]:
374 value = skel[name]
375 if not value and isinstance(value, numbers.Number):
376 # 0 and 0.0 are falsy, but can be valid numeric values and should be kept
377 yield None, None, value
378 yield from super().iter_bone_value(skel, name)
380 def structure(self) -> dict:
381 return super().structure() | {
382 "min": self.min,
383 "max": self.max,
384 "precision": self.precision,
385 "decimal": self.decimal,
387 }